Compare commits

..
Author SHA1 Message Date
linus 0e3b5003f6 Add code resolver, sync conflict handling, and user isolation
Introduce a CodeResolverService to classify user login codes, complete with detailed resolution logic and usability checks. Extend the sync system to handle conflicts via last-write-wins arbitration, with detailed conflict tracking for review. Update file permissions and runtime isolation in Docker to enhance security.
2026-09-12 15:40:42 +02:00
linus 9278c7fc34 style(web): redesign the zero-dependency web fallback client
- Dark theme with gradient accents, card-based layout replacing the
  bare form/section markup
- Header brand mark + live connection status badge
- Nicer inputs/buttons (focus rings, primary/ghost variants), empty
  states for the file and chat lists, auto-scroll on new chat messages
- Fix a broken template literal in app.js: the Authorization header
  was rendered as the literal string "*** ${state.token}" instead of
  "Bearer ${state.token}" -- guest Wahl submission and file loading
  were silently failing auth
- Add basic network-error handling around the three fetch() calls
2026-09-12 13:27:07 +02:00
linus 842b3c3ef4 docs: point README at split backend repo 2026-09-11 18:06:40 +02:00
linusandClaude Sonnet 5 35d8d9a623 chore: split backend out into KC-APP-Server repo
Backend moved (with full history via git filter-repo) to
https://git.konfi-castle.com/linus/KC-APP-Server. This repo is now
clients-only. README updated to point there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 18:06:23 +02:00
linusandClaude Sonnet 5 8af927c0f2 feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes
- New VerantwortlicheInvite model: LT-issued invites so a person can
  register as Gemeinde Verantwortliche(r) for a specific Gemeinde,
  skipping the self-registration approval step.
- Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl,
  beschreibung), mirroring the WP plugin's multi-phase elections.
  Teilnehmer unique constraint now scoped per phase.
- Auth: team login + guest auth adjustments, spec coverage.
- sync.service.ts: register VerantwortlicheInvite as a synced model.
- wahl.service.ts: submitTeilnehmer updated for the new phase-scoped
  unique key.
- client: login/home screen rework, new theme.dart, FCM web tweaks.
- .gitignore: ignore .DS_Store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 18:00:18 +02:00
linusandClaude Sonnet 5 72367637aa build: drop the Flutter builder stage from the Docker image
The Flutter SDK image is ~2.8 GB and filled Docker Desktop's VM disk
("read-only file system" while extracting a layer). Build the web bundle
on the host instead and COPY client/app/build/web into the 2-stage
(NestJS build -> slim runtime) image. .dockerignore keeps the bundle,
drops the platform scaffolding. README documents the host `flutter build
web` prerequisite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:23:33 +02:00
linusandClaude Sonnet 5 f42aead5ca build: Docker setup (compose: postgres + all-in-one api image)
- Dockerfile: 3-stage — Flutter web build, NestJS build, slim node runtime.
  Runtime copies dist + node_modules + prisma + the web bundle
  (WEB_CLIENT_DIR=/app/web), runs `prisma migrate deploy` then `node
  dist/main.js`. One container serves client + API on :3000.
- docker-compose.yml: postgres:16-alpine with a healthcheck + the api
  service; config from backend/.env (Compose v2 strips quotes),
  DATABASE_URL + GOOGLE_APPLICATION_CREDENTIALS overridden for the
  container, serviceAccount.json bind-mounted read-only.
- .dockerignore keeps node_modules/build/secrets out of the context.

Not run here (no Docker on this box); the stack also runs natively against
the local Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:08:43 +02:00
linusandClaude Sonnet 5 6662ca80d4 docs: FCM push verified end to end
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:02:39 +02:00
linusandClaude Sonnet 5 97c5807cb2 chore(backend): gitignore serviceAccount.json
FCM push is now verified end to end against the real konfi-castle-app
project: service-account JWT -> OAuth token (200), FCM messages:send
reached and processed (a bogus token gets a 400 INVALID_ARGUMENT and is
pruned from device_token). Real config lives only in the gitignored
backend/.env + backend/serviceAccount.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:02:18 +02:00
linusandClaude Sonnet 5 1c9887e0df chore(client): set FCM web-push VAPID key (public)
Client push-token acquisition is now fully configured; a real browser
session (logged-in user granting notification permission) is needed to
mint the first token. Backend delivery still needs the service-account JSON.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:57:07 +02:00
linusandClaude Sonnet 5 3f46088e9a chore(client): fill in Firebase web config (apiKey / appId)
Only the VAPID key (Web Push certificate) is still REPLACE_ME; the push
guard now keys off vapidKey so nothing prompts until it's set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:54:47 +02:00
linusandClaude Sonnet 5 d8aa30c606 docs: push notifications module + FCM client wiring
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:46:39 +02:00
linusandClaude Sonnet 5 530be36458 feat(client): FCM web push registration
web/index.html loads the Firebase compat SDK and defines
window.kcGetPushToken() — inits Firebase from window.KC_FIREBASE, asks for
notification permission, registers the service worker and returns an FCM
token (or null if not configured / denied). web/firebase-messaging-sw.js
shows background notifications.

browser_web.dart exposes getPushToken() over that JS function (stub returns
null off-web). After every successful login AppState fires
_registerForPush() -> POST /api/push/register, best-effort.

Config placeholders carry the known values (projectId konfi-castle-app,
messagingSenderId 307226979593); apiKey / appId / vapidKey still say
REPLACE_ME, so push stays inert until they're filled in — the app runs
either way. flutter analyze/test/build web green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:45:01 +02:00
linusandClaude Sonnet 5 cc663c7e17 feat(backend): push notifications module (FCM HTTP v1)
New global push/ module mirroring mail/ and files/storage/:
- PushProvider abstraction; default LogPushProvider (no delivery, logs),
  PUSH_PROVIDER=fcm switches to FcmPushProvider — Firebase Cloud Messaging
  HTTP v1, authenticated by a service-account JWT exchanged for an OAuth
  token (no extra dependency; jsonwebtoken does the signing). Prunes tokens
  FCM reports as invalid.
- DeviceToken model (token + platform, bound to a User or GuestAccount),
  migration + added to the sync log.
- POST /api/push/register + /unregister (any of the three token kinds).
- PushService.notifyChannel() resolves a channel's readable audience
  (DIREKT participants / LT / Gemeinde members + guests / whole KC for
  broadcast), looks up their device tokens (minus the sender), sends.
- ChatService.sendMessage() fires it best-effort after persisting.

New env: PUSH_PROVIDER, FCM_PROJECT_ID (default konfi-castle-app),
GOOGLE_APPLICATION_CREDENTIALS.

Verified against local Postgres: register a token, send a Gemeinde-group
chat message from another member -> log-push logs "would push ... to 1
device". Real FCM send needs the service-account JSON. npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:42:34 +02:00
linusandClaude Sonnet 5 55509eccb7 docs: LT Wahl controls, file upload, updated next steps
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:20:07 +02:00
linusandClaude Sonnet 5 a4ae7549ae feat: LT Wahl controls (open/close, Force-Zuteilung, CSV) + file upload
Backend:
- PATCH /api/wahl/:wahlId (isOpen) to open/close a Wahl.
- GET /api/wahl/:wahlId/teilnehmer (LT): participants with their priorities
  and any existing Force-Zuteilung.
- Verified against local Postgres: PATCH toggles isOpen, teilnehmer list
  returns, CSV export works. (A stale dev server on :3000 masked this at
  first — real routes are fine.)

Client (client/app/):
- Wahl detail: open/close switch, participant list with a "Zuteilen"
  (Force-Zuteilung) action, CSV export via a browser download
  (browser.downloadText).
- files_admin_screen.dart: LT file upload — browser.pickFile() +
  visibility picker -> multipart POST /api/files/:kcId; list existing
  files. Reachable from KcDetailScreen.
- browser_web.dart gains pickFile()/downloadText() (native <input file> +
  Blob), with throwing stubs for the VM.
- Dropped the file_picker package again (heavy transitive deps, and the
  native web input is enough); disk on this box is nearly full.

flutter analyze/test/build web green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:19:35 +02:00
linusandClaude Sonnet 5 73ff55643f docs: LT Wahl/Teamer admin + Verantwortlichen self-registration screens
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:10:07 +02:00
linusandClaude Sonnet 5 2e3e62b896 feat(client): LT Wahl admin, Teamer admin, Verantwortlichen self-registration
New screens (client/app/lib/screens/):
- wahl_admin_screen.dart — per KC: list/create Wahlen; per Wahl: add
  workshops, run the assignment (POST /wahl/:id/zuteilung/run), view the
  result table.
- teamer_admin_screen.dart — per Gemeinde: list/create local Teamer
  accounts, create group-link or per-email invites (shows the token).
- verantwortliche_register_screen.dart — enter a KC invite code
  (GET /onboarding/kc/:code), pick a Gemeinde, submit
  (POST /onboarding/verantwortliche); shown on the home screen to a
  logged-in Authentik user who has no membership yet.
- ui.dart — shared toast / ErrorText / SectionHeader / promptText.
KcDetailScreen now links to Wahl admin and each Gemeinde row opens Teamer
admin.

Backend: widen the wahl + files LT routes to AuthGuard(['authentik','team'])
for consistency with the other LT controllers. Rebrand web/index.html +
manifest from "kc_app" to "KC-App".

Verified against local Postgres with an isLeitungsteam team token: create
KC/Gemeinde/Wahl/Workshop, run Zuteilung, create Teamer + invite, resolve
an invite code. flutter analyze/test/build web green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:09:35 +02:00
linusandClaude Sonnet 5 d68ce42635 docs: record real-Authentik E2E verification
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:01:12 +02:00
linusandClaude Sonnet 5 edff87de5f feat(backend): tolerate Authentik users without an email + verify against real SSO
Authentik accounts don't always have an email set (the test account
`hermes` doesn't). AuthentikStrategy / verifyAuthentikClaims no longer
reject those — `authentikEmail()` falls back to a stable
`<preferred_username|sub>@no-email.authentik` handle for the local User row,
and first/last name fall back to preferred_username/name.

Set AUTHENTIK_LEITUNGSTEAM_GROUP to the real group "KC-APP-LT".

Verified end to end against the live https://sso.konfi-castle.com with a
password-grant token for a KC-APP-LT member: backend accepts the RS256
token (JWKS + trailing-slash issuer), JIT-provisions the User, maps the
`groups` claim to isLeitungsteam=true, and POST /api/kc returns 201. Only
the in-browser redirect round-trip remains untested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:00:58 +02:00
linusandClaude Sonnet 5 40b623dd79 docs: OIDC PKCE login, LT admin screens, backend-served Flutter build
Plan + both READMEs updated for the Authentik Authorization-Code + PKCE
flow, the Leitungsteam admin screen, the widened LT-admin guards, the
issuer trailing-slash normalisation, and the backend now serving the
Flutter web build (SPA fallback for /v1/auth/callback). Verification
section records the local-Postgres E2E for LT admin + onboarding approval,
and notes the OIDC browser round-trip still needs a test account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:37:10 +02:00
linusandClaude Sonnet 5 df11d8492d feat: Authentik OIDC login (PKCE) + Leitungsteam admin screens
Backend:
- AuthentikStrategy / TokenVerificationService: normalise the issuer's
  trailing slash and accept both `iss` spellings (Authentik's discovery
  issuer and token `iss` carry a trailing slash; the JWKS URL must not
  double it). Wire the real konfi-castle issuer into .env.example.
- team token path now goes through toAuthenticatedUser too, so a local
  account flagged isLeitungsteam gets the synthetic global LT membership
  regardless of token kind.
- LT-admin controllers (kc, gemeinde, onboarding, sync, teamer) accept
  ['authentik','team'] so such an account can use them. RolesGuard still
  enforces the actual LT/role check.
- app.module serves the Flutter web build from client/app/build/web (SPA
  fallback covers the OIDC redirect path /v1/auth/callback), falling back
  to the interim client/web/ if it isn't built.

Client (client/app/):
- oidc.dart: Authorization-Code + PKCE against Authentik (discovery, S256
  challenge, state, token exchange, refresh). Browser bits (sessionStorage,
  redirect, URL) behind a conditional import so `flutter test` still
  compiles on the VM.
- AppState handles the ?code= callback on bootstrap, stores access +
  refresh, refreshes an expired token on restart.
- Login screen: "Mit Konfi-Castle-ID anmelden" button (Leitungsteam /
  Verantwortliche) alongside the local Teamer password form.
- admin_screen.dart: LT-only "Verwaltung" — list/create KCs, per KC the
  Gemeinden (list/create) and pending Verantwortlichen requests
  (approve/reject). Verified end to end against local Postgres with an
  isLeitungsteam account (create KC/Gemeinde, list + approve a request).

flutter analyze/test/build web green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:24:38 +02:00
linusandClaude Sonnet 5 d5ecdcd3c4 docs: Wahl result view, live chat, and the gateway race fix
Plan + client README updated for the guest Wahl result endpoint/tab, the
WebSocket chat wiring, and the ChatGateway caller-promise fix. Section 8
now scopes the remaining Flutter work to the Authentik-dependent screens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:03:42 +02:00
linusandClaude Sonnet 5 e55faeaa95 feat: Wahl result view + live WebSocket chat in the Flutter client
Backend:
- fix(chat): ChatGateway stored the per-socket caller only after the async
  token check resolved, so a client that sent chat:join immediately on open
  raced ahead and got 4001. The caller is now stored as a promise that the
  message handlers await. Verified with a two-client send/receive E2E test
  against local Postgres.

Client (client/app/):
- Wahl screen gains a "Ergebnis" tab backed by GET /wahl/guest/results
  (PENDING / ASSIGNED with workshop + wish rank / UNASSIGNED).
- Chat channel view loads history over REST, then connects the /chat
  WebSocket (chat_socket.dart): live chat:message stream + a compose bar
  that sends chat:send. Shows the socket status.
- web_socket_channel dependency added.

flutter analyze/test/build web all green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:02:40 +02:00
linusandClaude Sonnet 5 0b588fa4b7 feat(backend): guest-facing Wahl result endpoint
GET /api/wahl/guest/results (guest JWT) returns, per Wahl the guest took
part in, their assignment: status PENDING (algorithm not run yet) /
ASSIGNED (workshopName + wunschRang) / UNASSIGNED (no capacity left).
Verified both PENDING and ASSIGNED paths against local Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:57:05 +02:00
linusandClaude Sonnet 5 138d782ca3 docs: reflect Postgres, migration, and the Flutter client
Root README + plan updated: local PostgreSQL 16 + first Prisma migration,
end-to-end guest-flow verification, and the started Flutter client
(client/app/, web target). Phase 7 marked in progress with the remaining
client work spelled out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:54:15 +02:00
linusandClaude Sonnet 5 0886424526 feat(client): Flutter app (web target) — Phase 7 start
Single Flutter codebase under client/app/ with web enabled (mobile/desktop
can be added later; lib/ is platform-agnostic). Talks to the NestJS backend
via a thin REST wrapper; API_BASE is a --dart-define (defaults to the local
backend).

Screens:
- Login: Konfi/guest (invite code), local Teamer password login, Teamer
  invite redemption. Token persisted in shared_preferences, restored on
  start; GET /auth/me drives a role-aware home.
- Workshop-Wahl (guests): loads /wahl/guest/overview, ordered pick of up to
  3 workshops, submits to /wahl/:id/teilnehmer.
- Dateien: /files/:kcId list.
- Chat: channel + message list (read-only; WS send is a follow-up).

State: AppState (ChangeNotifier) exposed via an InheritedNotifier
(AppScope) — no third-party state package. flutter analyze clean,
flutter build web --release passes, one widget smoke test.

Also: interim client/web/ HTML placeholder stays as-is (per plan it is
superseded by this Flutter web build).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:52:36 +02:00
linusandClaude Sonnet 5 7a95f4098f feat(backend): GET /api/auth/me for role-aware clients
Accepts any of the three token kinds and echoes back the identity behind
it: {kind:"guest", guestId, kcId, gemeindeId} for a Konfi token, or
{kind:"user", userId, email, memberships, isLeitungsteam} for an Authentik
or local Teamer token. Lets the client pick the right screens without
decoding the JWT itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:46:18 +02:00
linusandClaude Sonnet 5 912461751a feat(backend): guest-facing Wahl overview endpoint + dev seed
GET /api/wahl/guest/overview (guest JWT) returns the open Wahlen for the
guest's KC, each with its workshops and the guest's own current priorities
(null if not yet submitted) — everything the client needs to render the
Wahl form without any LT-only endpoint. Verified end to end against a local
Postgres (guest login -> overview -> submit -> re-fetch).

prisma/seed-dev.js: minimal dev fixture (one KC "DEV123" + Gemeinde + open
Wahl with three workshops).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:45:11 +02:00
linusandClaude Sonnet 5 4471d3a716 chore(backend): add initial Prisma migration
Generated with a real local PostgreSQL 16 and applied cleanly
(prisma migrate dev --name init). Covers the full current schema:
Kc, Gemeinde, Role/MembershipStatus enums, User (authentikSub nullable,
passwordHash, kcId, isLeitungsteam), Membership.status, GuestAccount,
TeamerInvite, Wahl/Workshop/Teilnehmer/ForceZuteilung/Zuteilung, File,
Chat*, Sync* .

Backend boots against the real DB and the smoke-tested routes (/, guest
login, onboarding invite lookup, protected /api/kc) behave correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:38:35 +02:00
linusandClaude Sonnet 5 320a41142b feat(backend): mail module + send personal Gemeinde-Teamer invites
New global mail/ module mirroring the files/storage/ provider pattern:
- MailProvider abstraction; default LogMailProvider only logs (no delivery),
  MAIL_PROVIDER=smtp switches to a nodemailer SMTP transport (SMTP_*,
  MAIL_FROM).
- MailService.sendTeamerInvite() composes the invite email with a link
  built from APP_BASE_URL.

TeamerService.createInvite() now mails personal invites (those with an
email) best-effort and returns `emailSent`; group links are unchanged.
Delivery failures are logged and swallowed, never blocking invite creation.

New env: APP_BASE_URL, MAIL_PROVIDER, MAIL_FROM, SMTP_HOST/PORT/SECURE/
USER/PASS. Tests: teamer spec covers mail-on-personal-invite,
no-mail-on-group-link, and transport-drop; npm test green at 56. Docs
updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:17:53 +02:00
linusandClaude Sonnet 5 24f8070b8a feat(backend): derive LEITUNGSTEAM from the Authentik groups claim
On every Authentik login the token's `groups` claim is compared against
AUTHENTIK_LEITUNGSTEAM_GROUP (default "Leitungsteam") and mirrored to the
new User.isLeitungsteam column. LT is global, not KC-scoped, so it lives on
the User rather than as a per-KC Membership row: toAuthenticatedUser()
synthesises a virtual global LEITUNGSTEAM membership from the flag, so
RolesGuard / visibility / TeamerService keep working unchanged.

- provision helper gains an isLeitungsteam arg and reconciles the flag both
  ways (grant on join, drop when the group is gone), capturing a User
  UPDATE to the sync log.
- verifyAuthentikClaims() now also returns isLeitungsteam; strategy, WS
  path and onboarding all funnel through the shared helper + mapper.
- new env var AUTHENTIK_LEITUNGSTEAM_GROUP.

Tests: provision-user.spec.ts extended (flag up/down, virtual membership);
npm test green at 55. Docs updated; ops note added that the Authentik
provider must emit the groups claim.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:13:20 +02:00
linusandClaude Sonnet 5 5079d48905 feat(backend): JIT-provision the local User on first Authentik login
AuthentikStrategy no longer rejects a valid token whose user has no local
row — it creates the User from the token claims (given_name/family_name/
email) via the new shared resolveOrProvisionAuthentikUser helper, which is
race-safe (P2002 -> re-read) and captures the User to the sync log. The WS
token path (TokenVerificationService.verifyAuthentik) and OnboardingService
now use the same helper, removing three copies of the lookup/create logic.

A provisioned user still has no Membership and therefore no rights: LT role
assignment from Authentik groups is the remaining gap; Verantwortliche go
through the onboarding approval flow.

Tests: provision-user.spec.ts (existing/new/race/rethrow); npm test green
at 51. Docs updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:05:18 +02:00
linusandClaude Sonnet 5 dbaafabcf4 feat(backend): self-registration for Gemeinde Verantwortliche
New onboarding/ module. A prospective Verantwortliche/r signs in with their
Konfi-Castle-ID (Authentik), looks up a KC by invite code, picks an existing
Gemeinde, and registers:

- GET  /api/onboarding/kc/:inviteCode  -> KC name + its Gemeinden (public;
  the invite code is the shared secret)
- POST /api/onboarding/verantwortliche -> verifies the raw Authentik bearer
  token's claims (no local Membership required yet via new
  TokenVerificationService.verifyAuthentikClaims), JIT-provisions the local
  User, and creates a Membership with status PENDING. Idempotent per
  (user, kc, gemeinde).
- GET  /api/onboarding/requests?kcId=            (LT) list pending
- POST /api/onboarding/requests/:id/approve|reject (LT) approve flips to
  ACTIVE, reject deletes.

Schema: Membership gains status (enum MembershipStatus { ACTIVE, PENDING },
default ACTIVE). AuthentikStrategy / TokenVerificationService / TeamAuthService
now load only ACTIVE memberships, so a pending request grants nothing until
approved. Membership create/update/delete flow through the sync log.

Tests: onboarding.service.spec.ts (14 cases); npm test green at 46.
Docs (plan + backend README) updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:01:19 +02:00
linusandClaude Sonnet 5 891105414a feat(backend): local accounts + invites for Gemeinde Teamer
Per the updated plan, Gemeinde Teamer are no longer Authentik-backed; they
are local accounts a Gemeinde Verantwortliche/r provisions per KC.

Schema:
- User.authentikSub now nullable; add passwordHash + kcId (cascade from Kc)
  so one User model covers Authentik members and local Teamer.
- new TeamerInvite model: shareable group link (email null, maxUses null)
  or personal invite (email pinned, single use), with expiry + revoke.
- sync log now also replicates User / Membership / TeamerInvite.

Auth:
- TeamAuthService: bcrypt password login (POST /auth/team-login) and invite
  redemption (POST /auth/teamer/register) issuing a JWT signed with
  TEAM_JWT_SECRET, payload typ:"team".
- TeamJwtStrategy (AuthGuard('team')) resolves it to the same
  AuthenticatedUser shape as AuthentikStrategy.
- TokenVerificationService.verifyEither() also accepts team tokens (WS).
- files + chat read endpoints accept 'team' tokens; Teamer see non-Konfi
  files and can use chat / start DMs.

Teamer admin (teamer/ module, under /gemeinde/:gemeindeId):
- POST/GET teamer, DELETE teamer/:userId
- POST/GET teamer-invites, DELETE teamer-invites/:inviteId
- LT may manage any Gemeinde; a Verantwortliche/r only their own
  (checked in TeamerService, since RolesGuard only scopes by kcId).

Tests: TeamAuthService + TeamerService specs added (Prisma/Sync mocked),
npm test green at 32. Docs (plan + backend README) updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:50:06 +02:00
linusandClaude Sonnet 5 39f8325287 test(backend): unit-test ZuteilungService assignment algorithm
First automated tests in the backend. Fakes Prisma + SyncService in
memory and asserts on the zuteilung.createMany payload:
- Force-Zuteilung wins over participant wishes
- wish-round fallback when a workshop hits capacity
- participant left unassigned when nothing is free
- underfilled-workshop consolidation reassigns via remaining wishes
- workshop exactly meeting minTeilnehmer is kept
- one CREATE sync entry captured per resulting Zuteilung

npm test green (9 tests). Plan verification section updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:29:02 +02:00
linusandClaude Sonnet 5 100f5bc2af feat(backend): add GemeindeController for LT congregation CRUD
Fills the plan's known gap where Gemeinde existed only as a Prisma model.
GemeindeModule exposes Leitungsteam-only create/list/get/update/delete
under /api/gemeinde, each mutation captured into the sync log like the
other feature services. Unique-name-per-KC violations surface as 409.
Docs (plan + backend README) updated to drop the gap and next-step item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:25:14 +02:00
linusandClaude Sonnet 5 7aba87368d feat(backend): implement phases 0-6 (auth, kc, wahl, files, chat, sync)
Full NestJS backend for the KC-App platform:
- auth: Authentik OIDC resource-server strategy + guest invite-code JWT
  login, plus TokenVerificationService for the WS handshake path
- kc: Leitungsteam-only KC (event) creation/listing
- wahl: Wahl/Workshop admin, Force-Zuteilung overrides, ZuteilungService
  (port of the WP plugin's kc_run_zuteilung), CSV export
- files: LT-only upload with visibility tiers; list/download filtered by
  caller tier; StorageProvider abstraction (WebDAV/Nextcloud default, S3)
- chat: Gemeinde group / DM / LT-wide / broadcast channels; REST + raw ws
  gateway sharing ChatService access rules
- sync: append-only SyncLogEntry replication log + local<->cloud
  push/pull scheduler, shared-secret guarded
- common: Role enum, @Roles decorator, KC-scoped RolesGuard (LT global)
- serves client/web/ interim static web client under / (API under /api)

Typecheck, nest build and boot test pass; needs real Postgres/Authentik/
Nextcloud to run end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:23:45 +02:00
65 changed files with 7464 additions and 10949 deletions
+1
View File
@@ -0,0 +1 @@
.DS_Store
+76 -10
View File
@@ -5,22 +5,88 @@ events (KCs), replacing the WordPress plugin "Workshop-Wahlen". See
[plan-kcAppMultiTenantPlatform.prompt.md](plan-kcAppMultiTenantPlatform.prompt.md) [plan-kcAppMultiTenantPlatform.prompt.md](plan-kcAppMultiTenantPlatform.prompt.md)
for the full architecture and phased roadmap. for the full architecture and phased roadmap.
This repo holds the **Flutter clients**. The backend (NestJS API) moved to
its own repo: <https://git.konfi-castle.com/linus/KC-APP-Server>.
## Run with Docker
See the [KC-APP-Server README](https://git.konfi-castle.com/linus/KC-APP-Server)
for the backend/Docker setup. It expects a pre-built web bundle:
```bash
(cd client/app && flutter build web --release)
```
By default the server's `docker-compose.yml` mounts `../KC-APP/client/app/build/web`
(sibling checkout); override with `WEB_CLIENT_BUILD_PATH` if your layout
differs.
## Structure ## Structure
- `backend/` — NestJS API (Prisma/PostgreSQL, Authentik OIDC as resource - `client/app/` — the Flutter client (single codebase; **web** target
server, guest/Konfi local accounts, roles/permissions foundation). See enabled, mobile/desktop can be added later). Login (guest / local Teamer /
[backend/README.md](backend/README.md) for setup. invite redemption), role-aware home, guest Workshop-Wahl, file list,
- `client/` — planned Flutter app (mobile + web + desktop), not yet read-only chat. See [client/app/README.md](client/app/README.md).
scaffolded (Flutter is not installed in this environment). - `client/web/` — minimal dependency-free HTML/CSS/JS placeholder web
client, served by the backend at `/`. Superseded by the Flutter web build;
kept for now as a zero-dependency fallback.
## Status ## Status
Phase 0/1 foundation implemented: monorepo skeleton, Prisma data model (Kc, Phase 0/1 foundation implemented: monorepo skeleton, Prisma data model (Kc,
Gemeinde, User, Membership, GuestAccount, Wahl/Workshop/Teilnehmer/Zuteilung, Gemeinde, User, Membership, GuestAccount, Wahl/Workshop/Teilnehmer/Zuteilung,
File, Chat), Authentik JWT resource-server strategy, guest invite-code login, File, Chat), Authentik JWT resource-server strategy, guest invite-code login,
Role-based guard scoped per KC. Backend builds and boots cleanly Role-based guard scoped per KC.
(`npm run build`, `node dist/main.js`) but requires a real PostgreSQL
database and Authentik instance (see `backend/.env.example`) to run end to Phase 2 (Workshop-Wahl engine) implemented: Wahl/Workshop administration,
end. Remaining phases (Wahl-Engine, Dateifreigabe, Chat realtime, Lokal/Cloud guest Teilnehmer submission, Force-Zuteilung overrides, and the assignment
Sync, Flutter clients) are not yet implemented. algorithm ported from the WP plugin's `kc_run_zuteilung` (force-assignments →
wish rounds 1-3 → random fill → consolidation of underfilled workshops),
plus CSV export.
Phase 3 (Dateifreigabe) implemented: Leitungsteam-only upload tagged with a
visibility tier (alle / alle außer Konfis / nur LT), list/download for
Authentik or guest callers filtered by their allowed tiers, storage behind a
provider abstraction defaulting to Nextcloud/WebDAV (S3-compatible storage
as an alternative via `STORAGE_PROVIDER=s3`).
Phase 5 (Kommunikation) implemented: Gemeinde-Gruppenchat, 1:1-DMs, LT-
kanalübergreifende Kanäle, Broadcast (read-only für Konfis); channel/history
via REST, real-time send/receive via a raw WebSocket gateway authenticated
with the same Authentik/guest tokens as the REST API.
Phase 6 (Hybrid Lokal/Cloud-Server & Sync) implemented: an append-only
replication log (`SyncLogEntry`) captured by every feature service after its
writes; the local (on-site) server periodically pushes/pulls against the
cloud server's `/sync/ingest` + `/sync/export` endpoints (shared-secret
authenticated, not user auth). No conflict resolution needed by design - the
local server is the sole source of truth while an event is live.
Since then: local (non-Authentik) Gemeinde Teamer accounts + invites
(`teamer/`, `auth/team-login`), Gemeinde CRUD (`gemeinde/`), Gemeinde
Verantwortliche self-registration with LT approval (`onboarding/`), JIT
`User` provisioning on first Authentik login, LEITUNGSTEAM derived from the
Authentik `groups` claim, and an email module (`mail/`, log-only by default,
SMTP opt-in) that sends personal Teamer invites. First Prisma migration is
in (`backend/prisma/migrations/`); the backend has been run end to end
against a local PostgreSQL 16. `npm test` covers the assignment algorithm
and the new auth/onboarding services (56 tests).
Phase 7 (Flutter client): `client/app/` is a single Flutter codebase with
the **web** target enabled — guest / local-Teamer / invite login, the
Authentik Authorization-Code + PKCE flow (`lib/oidc.dart`) for
Leitungsteam/Verantwortliche, role-aware home, guest Workshop-Wahl (wishes +
result), file list, live WebSocket chat, FCM web-push registration, and the
Leitungsteam admin screens: KCs, Gemeinden, onboarding approvals, full
Workshop-Wahl administration (create/open/close, workshops, Force-Zuteilung,
run assignment, CSV export), Teamer accounts + invites, LT file upload, plus
the Verantwortlichen self-registration flow. `flutter build web` /
`flutter test` pass; the backend serves the build at `/` (SPA fallback
covers the OIDC redirect `/v1/auth/callback`).
Still to do: a live browser test of the OIDC round-trip; mobile/desktop
targets. Going live needs external config — the Authentik redirect + a test
account, Nextcloud/S3 credentials, SMTP, and the Firebase push secrets
(`apiKey`/`appId`/VAPID key + a service-account JSON). See
`backend/.env.example` and `client/app/web/index.html`.
-10
View File
@@ -1,10 +0,0 @@
# Postgres connection used by Prisma
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/
AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app"
# Secret used to sign guest/Konfi session tokens (local accounts only)
GUEST_JWT_SECRET="change-me"
PORT=3000
-5
View File
@@ -1,5 +0,0 @@
node_modules
dist
coverage
.env
*.log
-37
View File
@@ -1,37 +0,0 @@
# KC-App Backend
NestJS API for the KC-App platform (see repo root README + plan for
architecture context).
## Setup
```bash
npm install
cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET
npx prisma generate
npx prisma migrate dev --name init # requires a running PostgreSQL instance
npm run start:dev
```
## Auth model
- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are
provisioned in Authentik; this API acts as an OIDC **resource server**,
verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and
then resolving local `Membership` rows to determine role + KC/Gemeinde
scope. Clients perform the actual Authorization Code + PKCE flow against
Authentik directly.
- Guests/Konfis get a temporary local account (first/last name required, no
Authentik) created via `POST /auth/guest` with a KC invite code, returning
a JWT signed with `GUEST_JWT_SECRET`.
## Modules implemented so far
- `prisma/` — shared `PrismaClient` provider.
- `auth/` — Authentik resource-server strategy + guest invite-code login.
- `kc/` — KC (event) creation/listing, Leitungsteam-only.
- `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
Leitungsteam roles are global across all KCs).
Not yet implemented: Wahl/Workshop/Zuteilung engine, file sharing, chat
realtime gateway, local/cloud sync engine.
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
-10215
View File
File diff suppressed because it is too large Load Diff
-80
View File
@@ -1,80 +0,0 @@
{
"name": "backend",
"version": "0.0.1",
"description": "KC-App backend (NestJS)",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/platform-ws": "^10.4.15",
"@nestjs/websockets": "^10.4.15",
"@prisma/client": "^5.22.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"jwks-rsa": "^3.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"@types/node": "^20.17.9",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.13",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.4",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
-191
View File
@@ -1,191 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// A Konfi-Castle event; the top-level tenant. One instance manages many KCs.
model Kc {
id String @id @default(cuid())
name String
inviteCode String @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
gemeinden Gemeinde[]
memberships Membership[]
wahlen Wahl[]
files File[]
channels ChatChannel[]
guests GuestAccount[]
}
/// A local congregation/community participating in one Kc.
model Gemeinde {
id String @id @default(cuid())
name String
kcId String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[]
guests GuestAccount[]
@@unique([kcId, name])
}
enum Role {
LEITUNGSTEAM
GEMEINDE_VERANTWORTLICHER
GEMEINDE_TEAMER
}
/// Authentik-backed user (team member with elevated rights).
model User {
id String @id @default(cuid())
authentikSub String @unique
email String @unique
firstName String
lastName String
createdAt DateTime @default(now())
memberships Membership[]
messages ChatMessage[]
}
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
/// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId.
model Membership {
id String @id @default(cuid())
userId String
kcId String
gemeindeId String?
role Role
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
@@unique([userId, kcId, gemeindeId])
}
/// Local, non-Authentik account for Konfis/guests, scoped to one Kc/event.
model GuestAccount {
id String @id @default(cuid())
kcId String
gemeindeId String?
firstName String
lastName String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
messages ChatMessage[]
teilnehmer Teilnehmer[]
}
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
model Wahl {
id String @id @default(cuid())
kcId String
name String
datumsSchluessel String
teil String
isOpen Boolean @default(true)
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
workshops Workshop[]
teilnehmer Teilnehmer[]
}
model Workshop {
id String @id @default(cuid())
wahlId String
name String
kapazitaet Int
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
zuteilungen Zuteilung[]
}
/// A participant's submitted choices for a Wahl.
model Teilnehmer {
id String @id @default(cuid())
wahlId String
guestAccountId String
prioritaeten Json
createdAt DateTime @default(now())
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
zuteilung Zuteilung?
@@unique([wahlId, guestAccountId])
}
/// Result of the assignment algorithm (or a manual force-assignment) for one Teilnehmer.
model Zuteilung {
id String @id @default(cuid())
teilnehmerId String @unique
workshopId String
isForced Boolean @default(false)
createdAt DateTime @default(now())
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
}
enum FileVisibility {
ALLE
ALLE_AUSSER_KONFIS
NUR_LT
}
model File {
id String @id @default(cuid())
kcId String
storageKey String
filename String
visibility FileVisibility
uploadedById String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
}
enum ChatChannelType {
GEMEINDE_GRUPPE
DIREKT
LT_UEBERGREIFEND
BROADCAST
}
model ChatChannel {
id String @id @default(cuid())
kcId String
type ChatChannelType
gemeindeId String?
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
messages ChatMessage[]
}
model ChatMessage {
id String @id @default(cuid())
channelId String
senderUserId String?
senderGuestId String?
body String
createdAt DateTime @default(now())
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
senderUser User? @relation(fields: [senderUserId], references: [id])
senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id])
}
-15
View File
@@ -1,15 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { KcModule } from './kc/kc.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
KcModule,
],
})
export class AppModule {}
-14
View File
@@ -1,14 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
import { GuestAuthService } from './guest-auth.service';
import { CreateGuestDto } from './dto/create-guest.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly guestAuth: GuestAuthService) {}
/// Redeems a KC invite code and registers a new temporary guest/Konfi account.
@Post('guest')
createGuest(@Body() dto: CreateGuestDto) {
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
}
}
-23
View File
@@ -1,23 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { GuestAuthService } from './guest-auth.service';
import { AuthentikStrategy } from './authentik.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('GUEST_JWT_SECRET'),
signOptions: { expiresIn: '12h' },
}),
}),
],
controllers: [AuthController],
providers: [GuestAuthService, AuthentikStrategy],
})
export class AuthModule {}
-20
View File
@@ -1,20 +0,0 @@
import { Request } from 'express';
import { Role } from '../common/role.enum';
export interface AuthenticatedMembership {
kcId: string;
gemeindeId: string | null;
role: Role;
}
/// Shape attached to req.user by JwtStrategy after validating an access token.
export interface AuthenticatedUser {
userId: string;
authentikSub: string;
email: string;
memberships: AuthenticatedMembership[];
}
export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
}
-62
View File
@@ -1,62 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy } from 'passport-jwt';
import * as jwksRsa from 'jwks-rsa';
import { Request } from 'express';
import { PrismaClient } from '../prisma/prisma.module';
import { AuthenticatedUser } from './authenticated-request';
interface AuthentikJwtPayload {
sub: string;
email: string;
given_name?: string;
family_name?: string;
}
/// Validates access tokens issued by Authentik (resource-server pattern):
/// signature is checked against Authentik's JWKS, then the local Membership
/// table decides what the user may do. Authentik itself is only the identity
/// source, never asked for authorization here.
@Injectable()
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
constructor(
config: ConfigService,
private readonly prisma: PrismaClient,
) {
const issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
super({
jwtFromRequest: (req: Request) =>
req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice('Bearer '.length)
: null,
secretOrKeyProvider: jwksRsa.passportJwtSecret({
jwksUri: `${issuerUrl}/jwks/`,
cache: true,
rateLimit: true,
}),
issuer: issuerUrl,
algorithms: ['RS256'],
});
}
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
const user = await this.prisma.user.findUnique({
where: { authentikSub: payload.sub },
include: { memberships: true },
});
if (!user) {
throw new UnauthorizedException('User not provisioned locally yet');
}
return {
userId: user.id,
authentikSub: user.authentikSub,
email: user.email,
memberships: user.memberships.map((m) => ({
kcId: m.kcId,
gemeindeId: m.gemeindeId,
role: m.role,
})),
};
}
}
-15
View File
@@ -1,15 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateGuestDto {
@IsString()
@IsNotEmpty()
inviteCode!: string;
@IsString()
@IsNotEmpty()
firstName!: string;
@IsString()
@IsNotEmpty()
lastName!: string;
}
-41
View File
@@ -1,41 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaClient } from '../prisma/prisma.module';
export interface GuestJwtPayload {
guestId: string;
kcId: string;
gemeindeId: string | null;
}
/// Guest/Konfi accounts are local to this server (never Authentik-backed),
/// created via a KC invite code, and scoped to that single KC.
@Injectable()
export class GuestAuthService {
constructor(
private readonly prisma: PrismaClient,
private readonly jwt: JwtService,
) {}
async createGuest(
inviteCode: string,
firstName: string,
lastName: string,
): Promise<{ accessToken: string }> {
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
if (!kc || !kc.isActive) {
throw new NotFoundException('Unknown or inactive KC invite code');
}
const guest = await this.prisma.guestAccount.create({
data: { kcId: kc.id, firstName, lastName },
});
const payload: GuestJwtPayload = {
guestId: guest.id,
kcId: kc.id,
gemeindeId: guest.gemeindeId,
};
return { accessToken: await this.jwt.signAsync(payload) };
}
}
-4
View File
@@ -1,4 +0,0 @@
/// Re-exported from the Prisma client so guards and strategies share one
/// enum type with the database schema. Guests are not part of this enum
/// since they authenticate separately and never hold elevated rights.
export { Role } from '@prisma/client';
-7
View File
@@ -1,7 +0,0 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from './role.enum';
export const ROLES_KEY = 'roles';
/// Marks a route as requiring at least one of the given roles (scope-checked by RolesGuard).
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
-50
View File
@@ -1,50 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from './role.enum';
import { ROLES_KEY } from './roles.decorator';
import { AuthenticatedRequest } from '../auth/authenticated-request';
/// Checks the caller holds one of the required roles, scoped to the KC in the
/// request (route param `kcId`, falling back to body.kcId). LEITUNGSTEAM
/// memberships are global and satisfy any KC scope.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
if (!user) {
throw new ForbiddenException('Not authenticated');
}
const kcId = request.params?.kcId ?? request.body?.kcId;
const hasRole = user.memberships.some((membership) => {
if (!requiredRoles.includes(membership.role)) {
return false;
}
if (membership.role === Role.LEITUNGSTEAM) {
return true;
}
return kcId ? membership.kcId === kcId : true;
});
if (!hasRole) {
throw new ForbiddenException('Insufficient role for this KC');
}
return true;
}
}
-7
View File
@@ -1,7 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateKcDto {
@IsString()
@IsNotEmpty()
name!: string;
}
-26
View File
@@ -1,26 +0,0 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { KcService } from './kc.service';
import { CreateKcDto } from './dto/create-kc.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
@Controller('kc')
@UseGuards(AuthGuard('authentik'), RolesGuard)
export class KcController {
constructor(private readonly kc: KcService) {}
/// Only the Leitungsteam may create new KC events.
@Post()
@Roles(Role.LEITUNGSTEAM)
create(@Body() dto: CreateKcDto) {
return this.kc.createKc(dto.name);
}
@Get()
@Roles(Role.LEITUNGSTEAM)
list() {
return this.kc.listKcs();
}
}
-9
View File
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { KcService } from './kc.service';
import { KcController } from './kc.controller';
@Module({
providers: [KcService],
controllers: [KcController],
})
export class KcModule {}
-18
View File
@@ -1,18 +0,0 @@
import { Injectable } from '@nestjs/common';
import { randomBytes } from 'crypto';
import { PrismaClient } from '../prisma/prisma.module';
@Injectable()
export class KcService {
constructor(private readonly prisma: PrismaClient) {}
createKc(name: string) {
return this.prisma.kc.create({
data: { name, inviteCode: randomBytes(6).toString('hex') },
});
}
listKcs() {
return this.prisma.kc.findMany();
}
}
-11
View File
@@ -1,11 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.enableCors();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
-17
View File
@@ -1,17 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
/// Shared Prisma connection; injected wherever DB access is needed.
@Global()
@Module({
providers: [
{
provide: PrismaClient,
useFactory: () => new PrismaClient(),
},
],
exports: [PrismaClient],
})
export class PrismaModule {}
export { PrismaClient };
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
-22
View File
@@ -1,22 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false
}
}
+51
View File
@@ -0,0 +1,51 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/
# DevTools options
devtools_options.yaml
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "e8113bf45620cbeb8aff64947ee4c93e16adb4cf"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
base_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
- platform: web
create_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
base_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+96
View File
@@ -0,0 +1,96 @@
# KC-App client (Flutter)
Single Flutter codebase for the KC-App platform. **Web** is the only target
enabled so far (`flutter config --enable-web`); Android/iOS/desktop can be
added later with `flutter create --platforms=...` in this directory — the
`lib/` code is platform-agnostic.
## Run
The Authentik redirect URI is `http://localhost:3000/v1/auth/callback`, so
the app must be reached on `:3000` — i.e. served by the backend, not `flutter
run`'s own dev server. Build it and let NestJS serve it:
```bash
flutter pub get
flutter build web # backend serves client/app/build/web at /
# then run the backend (npm run start:dev in ../../backend) and open :3000
```
For pure UI work without the OIDC flow, `flutter run -d chrome
--dart-define=API_BASE=http://localhost:3000/api` still works (guest / local
Teamer login only).
### Dart-defines
| define | default |
|---|---|
| `API_BASE` | `http://localhost:3000/api` |
| `OIDC_ISSUER` | `https://sso.konfi-castle.com/application/o/konfi-castle-app/` |
| `OIDC_CLIENT_ID` | the konfi-castle public client id |
| `OIDC_REDIRECT_URI` | `http://localhost:3000/v1/auth/callback` |
## What's implemented
- **Login** (`lib/screens/login_screen.dart`) — three tabs:
- *Konfi / Gast*: KC invite code + first/last name → `POST /auth/guest`.
- *Leitungsteam / Verantwortliche*: "Mit Konfi-Castle-ID anmelden" starts
the Authentik **Authorization Code + PKCE** flow (`lib/oidc.dart`);
below it, the local Gemeinde-Teamer password form
(`POST /auth/team-login`).
- *Einladung*: redeem a Teamer invite token → `POST /auth/teamer/register`.
- OIDC: discovery + S256 challenge, `?code=` handled on bootstrap, access +
refresh token persisted (`shared_preferences` / localStorage), expired
access token refreshed on restart. `GET /auth/me` resolves the role.
- **Home** (`lib/screens/home_screen.dart`) — identity card + navigation.
- **Verwaltung** (`lib/screens/admin_screen.dart`, Leitungsteam only) —
list/create KCs; per KC:
- Gemeinden (list/create); each opens **Teamer-Verwaltung**
(`teamer_admin_screen.dart`): local Teamer accounts + group-link / email
invites.
- **Workshop-Wahlen** (`wahl_admin_screen.dart`): create Wahlen, open/close
them, add workshops, list participants + Force-Zuteilung, run the
assignment, view the result table, export the CSV (browser download).
- **Dateien** (`files_admin_screen.dart`): upload with a visibility tier
(native `<input type=file>`), list. Needs a configured Nextcloud/S3 on
the backend or the upload returns 500.
- pending Verantwortlichen self-registrations (approve / reject).
- **Als Verantwortliche/r registrieren**
(`verantwortliche_register_screen.dart`) — shown on the home screen to a
logged-in Authentik user without a membership: enter a KC invite code,
pick a Gemeinde, submit; a Leitungsteam member then approves.
- **Push (web)** — `web/index.html` loads the Firebase compat SDK and
`web/firebase-messaging-sw.js` handles background messages. After login
`AppState` calls `window.kcGetPushToken()` and registers the token
(`POST /push/register`). Inert until `apiKey` / `appId` / `vapidKey` are
filled into both files (see the `REPLACE_ME` placeholders).
- **Nutzungsanalysen (web)** — `web/index.html` also initialises Google
Analytics for Firebase (`firebase.analytics()`) on every page load,
independent of login/push. Automatically logs `page_view` /
`session_start` / `first_visit`; visible in the Firebase Console under
**Analytics** (data can take a few hours to first appear, and won't show
on `localhost` — Analytics filters out non-public hostnames by default).
Screen-level events inside the Flutter SPA aren't tracked without further
instrumentation, but overall reach/users/sessions are.
- **Workshop-Wahl** (`lib/screens/wahl_screen.dart`, guests) — two tabs:
*Wünsche* (`GET /wahl/guest/overview`, tap workshops in order, max 3,
`POST /wahl/:id/teilnehmer`) and *Ergebnis* (`GET /wahl/guest/results`
PENDING / ASSIGNED with workshop + wish rank / UNASSIGNED).
- **Dateien** (`lib/screens/files_screen.dart`) — `GET /files/:kcId`,
filtered server-side by the caller's visibility tier.
- **Chat** (`lib/screens/chat_screen.dart` + `lib/chat_socket.dart`) —
channel list, REST history, then a live `/chat` WebSocket connection
(`chat:join` / `chat:send` / `chat:message`) with a compose bar.
## Architecture
- `lib/api.dart``Api` (thin REST wrapper + models) and `AppState`
(`ChangeNotifier`: session, login/logout, token persistence).
- `lib/oidc.dart` — Authentik PKCE flow. Browser-only bits (sessionStorage,
redirect, `window.location`) sit behind a conditional import
(`browser.dart``browser_web.dart` / `browser_stub.dart`) so
`flutter test` compiles on the Dart VM.
- `lib/chat_socket.dart``/chat` WebSocket wrapper.
- `lib/main.dart``AppScope` (an `InheritedNotifier<AppState>`) exposes
`AppScope.of(context)`; `_AuthGate` switches Login/Home. No third-party
state-management package.
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- web/**
+836
View File
@@ -0,0 +1,836 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'browser.dart' as browser;
import 'oidc.dart';
/// Backend base URL. Override at build/run time with
/// `--dart-define=API_BASE=https://...`.
const String kApiBase = String.fromEnvironment(
'API_BASE',
defaultValue: 'http://localhost:3000/api',
);
class ApiException implements Exception {
ApiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => 'ApiException($statusCode): $message';
}
enum SessionKind { guest, user }
/// Whatever `GET /auth/me` told us about the current token.
class Identity {
Identity({
required this.kind,
this.guestId,
this.userId,
this.email,
this.kcId,
this.gemeindeId,
this.isLeitungsteam = false,
this.memberships = const [],
});
final SessionKind kind;
final String? guestId;
final String? userId;
final String? email;
final String? kcId;
final String? gemeindeId;
final bool isLeitungsteam;
final List<Membership> memberships;
factory Identity.fromJson(Map<String, dynamic> j) {
if (j['kind'] == 'guest') {
return Identity(
kind: SessionKind.guest,
guestId: j['guestId'] as String?,
kcId: j['kcId'] as String?,
gemeindeId: j['gemeindeId'] as String?,
);
}
final ms = (j['memberships'] as List<dynamic>? ?? [])
.map((m) => Membership.fromJson(m as Map<String, dynamic>))
.toList();
return Identity(
kind: SessionKind.user,
userId: j['userId'] as String?,
email: j['email'] as String?,
isLeitungsteam: j['isLeitungsteam'] as bool? ?? false,
memberships: ms,
kcId: ms.isNotEmpty ? ms.first.kcId : null,
gemeindeId: ms.isNotEmpty ? ms.first.gemeindeId : null,
);
}
String get roleLabel {
if (kind == SessionKind.guest) return 'Konfi / Gast';
if (isLeitungsteam) return 'Leitungsteam';
if (memberships.any((m) => m.role == 'GEMEINDE_VERANTWORTLICHER')) {
return 'Gemeinde Verantwortliche/r';
}
if (memberships.any((m) => m.role == 'GEMEINDE_TEAMER')) {
return 'Gemeinde Teamer:in';
}
return 'Angemeldet (ohne Rolle)';
}
}
class Membership {
Membership({required this.kcId, this.gemeindeId, required this.role});
final String kcId;
final String? gemeindeId;
final String role;
factory Membership.fromJson(Map<String, dynamic> j) => Membership(
kcId: j['kcId'] as String,
gemeindeId: j['gemeindeId'] as String?,
role: j['role'] as String,
);
}
class Kc {
Kc({required this.id, required this.name, required this.inviteCode, required this.isActive});
final String id;
final String name;
final String inviteCode;
final bool isActive;
factory Kc.fromJson(Map<String, dynamic> j) => Kc(
id: j['id'] as String,
name: j['name'] as String,
inviteCode: j['inviteCode'] as String? ?? '',
isActive: j['isActive'] as bool? ?? true,
);
}
class Gemeinde {
Gemeinde({required this.id, required this.name, required this.kcId});
final String id;
final String name;
final String kcId;
factory Gemeinde.fromJson(Map<String, dynamic> j) => Gemeinde(
id: j['id'] as String,
name: j['name'] as String,
kcId: j['kcId'] as String? ?? '',
);
}
class OnboardingRequest {
OnboardingRequest({
required this.id,
required this.userName,
required this.userEmail,
required this.gemeindeName,
});
final String id;
final String userName;
final String userEmail;
final String gemeindeName;
factory OnboardingRequest.fromJson(Map<String, dynamic> j) {
final u = j['user'] as Map<String, dynamic>? ?? const {};
final g = j['gemeinde'] as Map<String, dynamic>? ?? const {};
return OnboardingRequest(
id: j['id'] as String,
userName: [u['firstName'], u['lastName']].whereType<String>().join(' ').trim(),
userEmail: u['email'] as String? ?? '',
gemeindeName: g['name'] as String? ?? '',
);
}
}
class WahlAdmin {
WahlAdmin({
required this.id,
required this.name,
required this.datumsSchluessel,
required this.teil,
required this.isOpen,
});
final String id;
final String name;
final String datumsSchluessel;
final String teil;
final bool isOpen;
factory WahlAdmin.fromJson(Map<String, dynamic> j) => WahlAdmin(
id: j['id'] as String,
name: j['name'] as String,
datumsSchluessel: j['datumsSchluessel'] as String? ?? '',
teil: j['teil'] as String? ?? '',
isOpen: j['isOpen'] as bool? ?? true,
);
}
class WorkshopAdmin {
WorkshopAdmin({
required this.id,
required this.name,
required this.kapazitaet,
required this.minTeilnehmer,
});
final String id;
final String name;
final int kapazitaet;
final int minTeilnehmer;
factory WorkshopAdmin.fromJson(Map<String, dynamic> j) => WorkshopAdmin(
id: j['id'] as String,
name: j['name'] as String,
kapazitaet: (j['kapazitaet'] as num).toInt(),
minTeilnehmer: (j['minTeilnehmer'] as num?)?.toInt() ?? 0,
);
}
class TeilnehmerRow {
TeilnehmerRow({
required this.id,
required this.name,
required this.prioritaeten,
required this.forcedWorkshopId,
});
final String id;
final String name;
final List<String> prioritaeten;
final String? forcedWorkshopId;
factory TeilnehmerRow.fromJson(Map<String, dynamic> j) => TeilnehmerRow(
id: j['id'] as String,
name: j['name'] as String? ?? '',
prioritaeten:
(j['prioritaeten'] as List<dynamic>? ?? []).map((e) => e as String).toList(),
forcedWorkshopId: j['forcedWorkshopId'] as String?,
);
}
class ZuteilungRow {
ZuteilungRow({
required this.name,
required this.workshopName,
required this.wunschRang,
required this.isForced,
});
final String name;
final String? workshopName;
final int wunschRang;
final bool isForced;
factory ZuteilungRow.fromJson(Map<String, dynamic> j) {
final ga = (j['teilnehmer'] as Map<String, dynamic>?)?['guestAccount']
as Map<String, dynamic>? ??
const {};
return ZuteilungRow(
name: [ga['firstName'], ga['lastName']].whereType<String>().join(' ').trim(),
workshopName: (j['workshop'] as Map<String, dynamic>?)?['name'] as String?,
wunschRang: (j['wunschRang'] as num?)?.toInt() ?? -1,
isForced: j['isForced'] as bool? ?? false,
);
}
}
class TeamerAccount {
TeamerAccount({required this.id, required this.email, required this.name});
final String id;
final String email;
final String name;
factory TeamerAccount.fromJson(Map<String, dynamic> j) => TeamerAccount(
id: j['id'] as String,
email: j['email'] as String? ?? '',
name: [j['firstName'], j['lastName']].whereType<String>().join(' ').trim(),
);
}
class TeamerInvite {
TeamerInvite({
required this.id,
required this.token,
required this.email,
required this.usedCount,
required this.maxUses,
required this.revoked,
});
final String id;
final String token;
final String? email;
final int usedCount;
final int? maxUses;
final bool revoked;
factory TeamerInvite.fromJson(Map<String, dynamic> j) => TeamerInvite(
id: j['id'] as String,
token: j['token'] as String,
email: j['email'] as String?,
usedCount: (j['usedCount'] as num?)?.toInt() ?? 0,
maxUses: (j['maxUses'] as num?)?.toInt(),
revoked: j['revokedAt'] != null,
);
}
class Workshop {
Workshop({required this.id, required this.name, required this.kapazitaet});
final String id;
final String name;
final int kapazitaet;
factory Workshop.fromJson(Map<String, dynamic> j) => Workshop(
id: j['id'] as String,
name: j['name'] as String,
kapazitaet: (j['kapazitaet'] as num).toInt(),
);
}
class Wahl {
Wahl({
required this.id,
required this.name,
required this.datumsSchluessel,
required this.teil,
required this.workshops,
required this.meinePrioritaeten,
});
final String id;
final String name;
final String datumsSchluessel;
final String teil;
final List<Workshop> workshops;
final List<String>? meinePrioritaeten;
factory Wahl.fromJson(Map<String, dynamic> j) => Wahl(
id: j['id'] as String,
name: j['name'] as String,
datumsSchluessel: j['datumsSchluessel'] as String,
teil: j['teil'] as String,
workshops: (j['workshops'] as List<dynamic>)
.map((w) => Workshop.fromJson(w as Map<String, dynamic>))
.toList(),
meinePrioritaeten: (j['meinePrioritaeten'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
}
class GuestOverview {
GuestOverview({required this.kcName, required this.wahlen});
final String kcName;
final List<Wahl> wahlen;
factory GuestOverview.fromJson(Map<String, dynamic> j) => GuestOverview(
kcName: (j['kc'] as Map<String, dynamic>?)?['name'] as String? ?? '',
wahlen: (j['wahlen'] as List<dynamic>)
.map((w) => Wahl.fromJson(w as Map<String, dynamic>))
.toList(),
);
}
enum WahlResultStatus { pending, assigned, unassigned }
class WahlResult {
WahlResult({
required this.wahlName,
required this.datumsSchluessel,
required this.teil,
required this.status,
required this.workshopName,
required this.wunschRang,
required this.isForced,
});
final String wahlName;
final String datumsSchluessel;
final String teil;
final WahlResultStatus status;
final String? workshopName;
final int? wunschRang;
final bool isForced;
factory WahlResult.fromJson(Map<String, dynamic> j) {
final wahl = j['wahl'] as Map<String, dynamic>;
return WahlResult(
wahlName: wahl['name'] as String,
datumsSchluessel: wahl['datumsSchluessel'] as String,
teil: wahl['teil'] as String,
status: switch (j['status'] as String?) {
'ASSIGNED' => WahlResultStatus.assigned,
'UNASSIGNED' => WahlResultStatus.unassigned,
_ => WahlResultStatus.pending,
},
workshopName: j['workshopName'] as String?,
wunschRang: (j['wunschRang'] as num?)?.toInt(),
isForced: j['isForced'] as bool? ?? false,
);
}
}
class FileEntry {
FileEntry({required this.id, required this.filename, required this.visibility});
final String id;
final String filename;
final String visibility;
factory FileEntry.fromJson(Map<String, dynamic> j) => FileEntry(
id: j['id'] as String,
filename: j['filename'] as String,
visibility: j['visibility'] as String? ?? '',
);
}
class ChatChannel {
ChatChannel({
required this.id,
required this.type,
this.name,
this.gemeindeId,
this.createdByUserId,
});
final String id;
final String type;
final String? name;
final String? gemeindeId;
final String? createdByUserId;
factory ChatChannel.fromJson(Map<String, dynamic> j) => ChatChannel(
id: j['id'] as String,
type: j['type'] as String? ?? '',
name: j['name'] as String?,
gemeindeId: j['gemeindeId'] as String?,
createdByUserId: j['createdByUserId'] as String?,
);
}
class ChatMessage {
ChatMessage({
this.id,
this.channelId,
this.senderUserId,
this.senderGuestId,
required this.body,
required this.createdAt,
});
final String? id;
final String? channelId;
final String? senderUserId;
final String? senderGuestId;
final String body;
final String createdAt;
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
id: j['id'] as String?,
channelId: j['channelId'] as String?,
senderUserId: j['senderUserId'] as String?,
senderGuestId: j['senderGuestId'] as String?,
body: j['body'] as String? ?? '',
createdAt: j['createdAt'] as String? ?? '',
);
}
/// Thin REST wrapper. Holds the bearer token for the current session.
class Api {
Api(this._client);
final http.Client _client;
String? token;
Map<String, String> get _headers => {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
Future<dynamic> _get(String path) async {
final res = await _client.get(Uri.parse('$kApiBase$path'), headers: _headers);
return _decode(res);
}
Future<dynamic> _post(String path, Object? body) async {
final res = await _client.post(
Uri.parse('$kApiBase$path'),
headers: _headers,
body: body == null ? null : jsonEncode(body),
);
return _decode(res);
}
Future<dynamic> _patch(String path, Object? body) async {
final res = await _client.patch(
Uri.parse('$kApiBase$path'),
headers: _headers,
body: body == null ? null : jsonEncode(body),
);
return _decode(res);
}
dynamic _decode(http.Response res) {
final text = res.body.isEmpty ? '{}' : res.body;
dynamic parsed;
try {
parsed = jsonDecode(text);
} catch (_) {
parsed = text;
}
if (res.statusCode >= 200 && res.statusCode < 300) return parsed;
final msg = parsed is Map && parsed['message'] != null
? (parsed['message'] is List
? (parsed['message'] as List).join(', ')
: parsed['message'].toString())
: 'HTTP ${res.statusCode}';
throw ApiException(res.statusCode, msg);
}
// --- auth ---
Future<String> guestLogin(String inviteCode, String firstName, String lastName) async {
final j = await _post('/auth/guest', {
'inviteCode': inviteCode,
'firstName': firstName,
'lastName': lastName,
});
return j['accessToken'] as String;
}
/// Logs a Gemeinde Teamer in by Gemeinde name (the normal path) or by
/// email (legacy/personal accounts) — pass exactly one of the two.
Future<String> teamLogin({String? gemeindeName, String? email, required String password}) async {
final j = await _post('/auth/team-login', {
if (gemeindeName != null && gemeindeName.isNotEmpty) 'gemeindeName': gemeindeName,
if (email != null && email.isNotEmpty) 'email': email,
'password': password,
});
return j['accessToken'] as String;
}
Future<String> redeemTeamerInvite({
required String inviteToken,
required String firstName,
required String lastName,
required String password,
String? email,
}) async {
final j = await _post('/auth/teamer/register', {
'token': inviteToken,
'firstName': firstName,
'lastName': lastName,
'password': password,
if (email != null && email.isNotEmpty) 'email': email,
});
return j['accessToken'] as String;
}
Future<Identity> me() async =>
Identity.fromJson(await _get('/auth/me') as Map<String, dynamic>);
// --- guest Wahl ---
Future<GuestOverview> guestWahlOverview() async =>
GuestOverview.fromJson(await _get('/wahl/guest/overview') as Map<String, dynamic>);
Future<void> submitPrioritaeten(String wahlId, List<String> workshopIds) async {
await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds});
}
Future<List<WahlResult>> guestWahlResults() async {
final list = await _get('/wahl/guest/results') as List<dynamic>;
return list.map((e) => WahlResult.fromJson(e as Map<String, dynamic>)).toList();
}
// --- files ---
Future<List<FileEntry>> files(String kcId) async {
final list = await _get('/files/$kcId') as List<dynamic>;
return list.map((e) => FileEntry.fromJson(e as Map<String, dynamic>)).toList();
}
String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId';
/// WebSocket endpoint for the chat gateway. It lives at `/chat` (outside the
/// `/api` prefix) and authenticates via a `?token=` query param.
Uri chatWsUri() {
final base = Uri.parse(kApiBase);
return Uri(
scheme: base.scheme == 'https' ? 'wss' : 'ws',
host: base.host,
port: base.hasPort ? base.port : null,
path: '/chat',
queryParameters: {'token': token ?? ''},
);
}
// --- LT admin ---
Future<List<Kc>> kcs() async {
final list = await _get('/kc') as List<dynamic>;
return list.map((e) => Kc.fromJson(e as Map<String, dynamic>)).toList();
}
Future<Kc> createKc(String name) async =>
Kc.fromJson(await _post('/kc', {'name': name}) as Map<String, dynamic>);
Future<List<Gemeinde>> gemeinden(String kcId) async {
final list = await _get('/gemeinde?kcId=$kcId') as List<dynamic>;
return list.map((e) => Gemeinde.fromJson(e as Map<String, dynamic>)).toList();
}
Future<Gemeinde> createGemeinde(String kcId, String name) async => Gemeinde.fromJson(
await _post('/gemeinde', {'kcId': kcId, 'name': name}) as Map<String, dynamic>);
Future<List<OnboardingRequest>> onboardingRequests(String kcId) async {
final list = await _get('/onboarding/requests?kcId=$kcId') as List<dynamic>;
return list.map((e) => OnboardingRequest.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null);
Future<void> rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null);
// --- LT Wahl administration ---
Future<List<WahlAdmin>> wahlenForKc(String kcId) async {
final list = await _get('/wahl?kcId=$kcId') as List<dynamic>;
return list.map((e) => WahlAdmin.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> createWahl(
String kcId,
String name,
String datumsSchluessel,
String teil,
) =>
_post('/wahl', {
'kcId': kcId,
'name': name,
'datumsSchluessel': datumsSchluessel,
'teil': teil,
});
Future<List<WorkshopAdmin>> workshopsForWahl(String wahlId) async {
final list = await _get('/wahl/$wahlId/workshops') as List<dynamic>;
return list.map((e) => WorkshopAdmin.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> createWorkshop(
String wahlId,
String name,
int kapazitaet,
int minTeilnehmer,
) =>
_post('/wahl/$wahlId/workshops', {
'name': name,
'kapazitaet': kapazitaet,
'minTeilnehmer': minTeilnehmer,
});
Future<void> runZuteilung(String wahlId) => _post('/wahl/$wahlId/zuteilung/run', null);
Future<List<ZuteilungRow>> zuteilungResults(String wahlId) async {
final list = await _get('/wahl/$wahlId/zuteilung') as List<dynamic>;
return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> setWahlOpen(String wahlId, bool isOpen) =>
_patch('/wahl/$wahlId', {'isOpen': isOpen});
Future<List<TeilnehmerRow>> wahlTeilnehmer(String wahlId) async {
final list = await _get('/wahl/$wahlId/teilnehmer') as List<dynamic>;
return list.map((e) => TeilnehmerRow.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> forceZuteilung(String wahlId, String teilnehmerId, String workshopId) =>
_post('/wahl/$wahlId/force-zuteilung', {
'teilnehmerId': teilnehmerId,
'workshopId': workshopId,
});
Future<String> zuteilungCsv(String wahlId) async {
final res = await _get('/wahl/$wahlId/zuteilung/csv');
return res is String ? res : res.toString();
}
Future<void> uploadFile(
String kcId,
String filename,
List<int> bytes,
String visibility,
) async {
final req = http.MultipartRequest('POST', Uri.parse('$kApiBase/files/$kcId'))
..fields['visibility'] = visibility
..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename));
if (token != null) req.headers['Authorization'] = 'Bearer $token';
final streamed = await req.send();
final res = await http.Response.fromStream(streamed);
if (res.statusCode < 200 || res.statusCode >= 300) {
_decode(res); // throws ApiException with the server message
}
}
// --- Teamer administration (LT or the responsible Verantwortliche/r) ---
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
return list.map((e) => TeamerAccount.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> createTeamer(
String gemeindeId, {
required String firstName,
required String lastName,
required String email,
required String password,
}) =>
_post('/gemeinde/$gemeindeId/teamer', {
'firstName': firstName,
'lastName': lastName,
'email': email,
'password': password,
});
Future<List<TeamerInvite>> teamerInvitesFor(String gemeindeId) async {
final list = await _get('/gemeinde/$gemeindeId/teamer-invites') as List<dynamic>;
return list.map((e) => TeamerInvite.fromJson(e as Map<String, dynamic>)).toList();
}
Future<TeamerInvite> createTeamerInvite(
String gemeindeId, {
String? email,
int? maxUses,
int? expiresInHours,
}) async =>
TeamerInvite.fromJson(await _post('/gemeinde/$gemeindeId/teamer-invites', {
if (email != null && email.isNotEmpty) 'email': email,
'maxUses': ?maxUses,
'expiresInHours': ?expiresInHours,
}) as Map<String, dynamic>);
// --- Verantwortlichen self-registration ---
Future<Map<String, dynamic>> resolveInvite(String inviteCode) async =>
await _get('/onboarding/kc/$inviteCode') as Map<String, dynamic>;
Future<Map<String, dynamic>> registerVerantwortliche(
String inviteCode,
String gemeindeId,
) async =>
await _post('/onboarding/verantwortliche', {
'inviteCode': inviteCode,
'gemeindeId': gemeindeId,
}) as Map<String, dynamic>;
// --- push ---
Future<void> registerDevice(String token, {String platform = 'web'}) =>
_post('/push/register', {'token': token, 'platform': platform});
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
Future<List<ChatChannel>> channels(String kcId) async {
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
return list.map((e) => ChatChannel.fromJson(e as Map<String, dynamic>)).toList();
}
Future<List<ChatMessage>> messages(String channelId) async {
final list = await _get('/chat/channels/$channelId/messages') as List<dynamic>;
return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList();
}
}
/// App-wide session + auth actions. Persists the token in shared_preferences
/// (localStorage on web).
class AppState extends ChangeNotifier {
AppState(this._api, {OidcClient? oidc}) : _oidc = oidc ?? OidcClient(http.Client());
final Api _api;
final OidcClient _oidc;
static const _tokenKey = 'kc_token';
static const _refreshKey = 'kc_refresh';
Identity? _identity;
Identity? get identity => _identity;
bool _loading = true;
bool get loading => _loading;
bool get isLoggedIn => _identity != null;
String? _authError;
String? get authError => _authError;
Api get api => _api;
Future<void> bootstrap() async {
final prefs = await SharedPreferences.getInstance();
// 1. Are we landing on the OIDC redirect (?code=…)?
try {
final tokens = await _oidc.completeIfCallback();
if (tokens != null) {
await _establish(tokens.accessToken, refreshToken: tokens.refreshToken);
_loading = false;
notifyListeners();
return;
}
} catch (e) {
_authError = '$e';
}
// 2. Restore a stored session, refreshing an expired Authentik token.
final saved = prefs.getString(_tokenKey);
if (saved != null) {
_api.token = saved;
try {
_identity = await _api.me();
} catch (_) {
final refresh = prefs.getString(_refreshKey);
if (refresh != null) {
try {
final t = await _oidc.refresh(refresh);
await _establish(t.accessToken, refreshToken: t.refreshToken ?? refresh);
} catch (_) {
await _clear(prefs);
}
} else {
await _clear(prefs);
}
}
}
_loading = false;
notifyListeners();
}
Future<void> beginOidcLogin() => _oidc.beginLogin();
Future<void> _establish(String token, {String? refreshToken}) async {
_api.token = token;
_identity = await _api.me();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
if (refreshToken != null) {
await prefs.setString(_refreshKey, refreshToken);
}
_authError = null;
notifyListeners();
_registerForPush(); // best-effort, fire and forget
}
Future<void> _registerForPush() async {
try {
final pushToken = await browser.getPushToken();
if (pushToken != null) await _api.registerDevice(pushToken);
} catch (_) {
// push is optional
}
}
Future<void> _clear(SharedPreferences prefs) async {
_api.token = null;
await prefs.remove(_tokenKey);
await prefs.remove(_refreshKey);
}
Future<void> guestLogin(String code, String first, String last) =>
_api.guestLogin(code, first, last).then(_establish);
Future<void> teamLogin({String? gemeindeName, String? email, required String password}) =>
_api.teamLogin(gemeindeName: gemeindeName, email: email, password: password).then(_establish);
Future<void> redeemInvite({
required String token,
required String first,
required String last,
required String password,
String? email,
}) =>
_api
.redeemTeamerInvite(
inviteToken: token,
firstName: first,
lastName: last,
password: password,
email: email,
)
.then(_establish);
Future<void> logout() async {
_identity = null;
_authError = null;
await _clear(await SharedPreferences.getInstance());
notifyListeners();
}
}
+3
View File
@@ -0,0 +1,3 @@
// Picks the real browser implementation on web, a throwing stub elsewhere
// (so `flutter test` on the Dart VM still compiles).
export 'browser_stub.dart' if (dart.library.js_interop) 'browser_web.dart';
+17
View File
@@ -0,0 +1,17 @@
// Non-web fallback: the OIDC redirect flow only runs in a browser.
const _msg = 'Browser-only: OIDC login is not available on this platform.';
void setSession(String key, String value) => throw UnsupportedError(_msg);
String? getSession(String key) => throw UnsupportedError(_msg);
void removeSession(String key) => throw UnsupportedError(_msg);
Never redirect(String url) => throw UnsupportedError(_msg);
Map<String, String> currentQueryParameters() => const {};
void clearQuery() {}
Future<({String name, List<int> bytes})?> pickFile() async =>
throw UnsupportedError(_msg);
void downloadText(String filename, String content, {String mime = 'text/plain'}) =>
throw UnsupportedError(_msg);
/// No push on non-web platforms in this build.
Future<String?> getPushToken() async => null;
+77
View File
@@ -0,0 +1,77 @@
import 'dart:async';
import 'dart:js_interop';
import 'package:web/web.dart' as web;
/// Provided by the inline Firebase bootstrap in web/index.html. Returns an FCM
/// registration token, or null if push isn't configured / permission denied.
@JS('kcGetPushToken')
external JSPromise<JSString?> _kcGetPushToken();
Future<String?> getPushToken() async {
try {
final result = await _kcGetPushToken().toDart;
return result?.toDart;
} catch (_) {
return null;
}
}
/// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab
/// closes), and the little bit of `window` access the OIDC redirect needs.
void setSession(String key, String value) =>
web.window.sessionStorage.setItem(key, value);
String? getSession(String key) => web.window.sessionStorage.getItem(key);
void removeSession(String key) => web.window.sessionStorage.removeItem(key);
void redirect(String url) => web.window.location.assign(url);
Map<String, String> currentQueryParameters() =>
Uri.parse(web.window.location.href).queryParameters;
/// Drop the OIDC callback path + `?code=…&state=…` from the address bar
/// without reloading (back to the app root).
void clearQuery() {
web.window.history.replaceState(null, '', '/');
}
/// Opens the OS file picker and reads the chosen file's bytes.
Future<({String name, List<int> bytes})?> pickFile() {
final completer = Completer<({String name, List<int> bytes})?>();
final input = web.HTMLInputElement()..type = 'file';
input.onchange = ((web.Event _) {
final files = input.files;
if (files == null || files.length == 0) {
completer.complete(null);
return;
}
final file = files.item(0)!;
final reader = web.FileReader();
reader.onload = ((web.Event _) {
final buffer = (reader.result as JSArrayBuffer).toDart;
completer.complete((name: file.name, bytes: buffer.asUint8List()));
}).toJS;
reader.onerror = ((web.Event _) => completer.complete(null)).toJS;
reader.readAsArrayBuffer(file);
}).toJS;
input.click();
return completer.future;
}
/// Triggers a browser download of an in-memory string (e.g. the CSV export).
void downloadText(
String filename,
String content, {
String mime = 'text/csv;charset=utf-8',
}) {
final blob = web.Blob([content.toJS].toJS, web.BlobPropertyBag(type: mime));
final url = web.URL.createObjectURL(blob);
web.HTMLAnchorElement()
..href = url
..download = filename
..click();
web.URL.revokeObjectURL(url);
}
+57
View File
@@ -0,0 +1,57 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'api.dart';
/// Thin wrapper over the raw `ws` chat gateway. The NestJS `WsAdapter`
/// expects `{"event": ..., "data": ...}` frames in both directions.
class ChatSocket {
ChatSocket(this._uri);
final Uri _uri;
WebSocketChannel? _channel;
final _messages = StreamController<ChatMessage>.broadcast();
final _status = StreamController<String>.broadcast();
/// Incoming `chat:message` frames.
Stream<ChatMessage> get messages => _messages.stream;
/// "connected" / "closed" / "error: ..." for a small status line.
Stream<String> get status => _status.stream;
void connect(String channelId) {
_channel = WebSocketChannel.connect(_uri);
_channel!.stream.listen(
(raw) {
_status.add('connected');
try {
final frame = jsonDecode(raw as String) as Map<String, dynamic>;
if (frame['event'] == 'chat:message') {
_messages.add(ChatMessage.fromJson(frame['data'] as Map<String, dynamic>));
}
} catch (_) {
// ignore frames we don't model
}
},
onError: (Object e) => _status.add('error: $e'),
onDone: () => _status.add('closed'),
);
_send('chat:join', {'channelId': channelId});
}
void sendMessage(String channelId, String body) {
_send('chat:send', {'channelId': channelId, 'body': body});
}
void _send(String event, Map<String, dynamic> data) {
_channel?.sink.add(jsonEncode({'event': event, 'data': data}));
}
void dispose() {
_channel?.sink.close();
_messages.close();
_status.close();
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'api.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'theme.dart';
void main() {
final state = AppState(Api(http.Client()))..bootstrap();
runApp(KcApp(state: state));
}
/// Minimal InheritedNotifier so screens can read `AppState.of(context)` and
/// rebuild on change — no third-party state management.
class AppScope extends InheritedNotifier<AppState> {
const AppScope({super.key, required AppState state, required super.child})
: super(notifier: state);
static AppState of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'AppScope missing above this widget');
return scope!.notifier!;
}
}
class KcApp extends StatelessWidget {
const KcApp({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) {
return AppScope(
state: state,
child: MaterialApp(
title: 'KC-App',
debugShowCheckedModeBanner: false,
theme: buildKcTheme(),
home: const _AuthGate(),
),
);
}
}
class _AuthGate extends StatelessWidget {
const _AuthGate();
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
if (state.loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return state.isLoggedIn ? const HomeScreen() : const LoginScreen();
}
}
+151
View File
@@ -0,0 +1,151 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'browser.dart' as browser;
/// Authentik OIDC config. Overridable with --dart-define; defaults are the
/// konfi-castle production values (a *public* client — PKCE, no secret).
const kOidcIssuer = String.fromEnvironment(
'OIDC_ISSUER',
defaultValue: 'https://sso.konfi-castle.com/application/o/konfi-castle-app/',
);
const kOidcClientId = String.fromEnvironment(
'OIDC_CLIENT_ID',
defaultValue: 'K7f9mn6bP6jSjZDMYuiZCXMeVmVcqFcYNj0blJk9',
);
const kOidcRedirectUri = String.fromEnvironment(
'OIDC_REDIRECT_URI',
defaultValue: 'http://localhost:3000/v1/auth/callback',
);
const _scope = 'openid profile email groups offline_access';
const _verifierKey = 'oidc_verifier';
const _stateKey = 'oidc_state';
class OidcTokens {
OidcTokens({required this.accessToken, this.refreshToken, this.expiresIn});
final String accessToken;
final String? refreshToken;
final int? expiresIn;
factory OidcTokens.fromJson(Map<String, dynamic> j) => OidcTokens(
accessToken: j['access_token'] as String,
refreshToken: j['refresh_token'] as String?,
expiresIn: (j['expires_in'] as num?)?.toInt(),
);
}
class OidcException implements Exception {
OidcException(this.message);
final String message;
@override
String toString() => 'OidcException: $message';
}
/// Authorization-Code + PKCE against Authentik, for the browser. The backend
/// only validates the resulting access token (resource-server pattern).
class OidcClient {
OidcClient(this._http);
final http.Client _http;
Map<String, dynamic>? _discovery;
Future<Map<String, dynamic>> _disc() async {
if (_discovery != null) return _discovery!;
final base = kOidcIssuer.endsWith('/') ? kOidcIssuer : '$kOidcIssuer/';
final res = await _http.get(Uri.parse('$base.well-known/openid-configuration'));
if (res.statusCode != 200) {
throw OidcException('Discovery failed (${res.statusCode})');
}
return _discovery = jsonDecode(res.body) as Map<String, dynamic>;
}
/// Kicks off the redirect to Authentik. Does not return (page navigates).
Future<void> beginLogin() async {
final d = await _disc();
final verifier = _randomUrlToken(64);
final state = _randomUrlToken(24);
final challenge = base64UrlEncode(sha256.convert(ascii.encode(verifier)).bytes)
.replaceAll('=', '');
browser.setSession(_verifierKey, verifier);
browser.setSession(_stateKey, state);
final authUri = Uri.parse(d['authorization_endpoint'] as String).replace(
queryParameters: {
'response_type': 'code',
'client_id': kOidcClientId,
'redirect_uri': kOidcRedirectUri,
'scope': _scope,
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
},
);
browser.redirect(authUri.toString());
}
/// If the current URL carries `?code=…`, exchanges it for tokens and scrubs
/// the query. Returns null when this isn't a callback load.
Future<OidcTokens?> completeIfCallback() async {
final params = browser.currentQueryParameters();
final code = params['code'];
if (code == null || code.isEmpty) {
if (params['error'] != null) {
browser.clearQuery();
throw OidcException('Authentik: ${params['error_description'] ?? params['error']}');
}
return null;
}
final expectedState = browser.getSession(_stateKey);
final verifier = browser.getSession(_verifierKey);
browser.removeSession(_stateKey);
browser.removeSession(_verifierKey);
browser.clearQuery();
if (verifier == null || params['state'] != expectedState) {
throw OidcException('State mismatch — please retry the login.');
}
final d = await _disc();
final res = await _http.post(
Uri.parse(d['token_endpoint'] as String),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': kOidcRedirectUri,
'client_id': kOidcClientId,
'code_verifier': verifier,
},
);
if (res.statusCode != 200) {
throw OidcException('Token exchange failed (${res.statusCode}): ${res.body}');
}
return OidcTokens.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
}
Future<OidcTokens> refresh(String refreshToken) async {
final d = await _disc();
final res = await _http.post(
Uri.parse(d['token_endpoint'] as String),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'client_id': kOidcClientId,
},
);
if (res.statusCode != 200) {
throw OidcException('Refresh failed (${res.statusCode})');
}
return OidcTokens.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
}
static String _randomUrlToken(int length) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final rnd = Random.secure();
return List.generate(length, (_) => chars[rnd.nextInt(chars.length)]).join();
}
}
+284
View File
@@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'files_admin_screen.dart';
import 'teamer_admin_screen.dart';
import 'ui.dart';
import 'wahl_admin_screen.dart';
/// Leitungsteam admin: KCs, their Gemeinden, and pending self-registrations.
class AdminScreen extends StatefulWidget {
const AdminScreen({super.key});
@override
State<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends State<AdminScreen> {
Future<List<Kc>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.kcs();
}
void _reload() => setState(() => _future = AppScope.of(context).api.kcs());
Future<void> _createKc() async {
final api = AppScope.of(context).api;
final name = await promptText(context, 'Neues KC', 'Name');
if (name == null || name.isEmpty || !mounted) return;
try {
await api.createKc(name);
if (mounted) _reload();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Verwaltung')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _createKc,
icon: const Icon(Icons.add),
label: const Text('KC'),
),
body: FutureBuilder<List<Kc>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${snap.error}', textAlign: TextAlign.center),
),
);
}
final kcs = snap.data!;
if (kcs.isEmpty) {
return const Center(child: Text('Noch keine KCs. Unten anlegen.'));
}
return ListView(
children: [
for (final kc in kcs)
ListTile(
leading: const Icon(Icons.festival),
title: Text(kc.name),
subtitle: Text('Code ${kc.inviteCode}'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => KcDetailScreen(kc: kc)),
),
),
],
);
},
),
);
}
}
class KcDetailScreen extends StatefulWidget {
const KcDetailScreen({super.key, required this.kc});
final Kc kc;
@override
State<KcDetailScreen> createState() => _KcDetailScreenState();
}
class _KcDetailScreenState extends State<KcDetailScreen> {
Future<List<Gemeinde>>? _gemeinden;
Future<List<OnboardingRequest>>? _requests;
Api get _api => AppScope.of(context).api;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_gemeinden ??= _api.gemeinden(widget.kc.id);
_requests ??= _api.onboardingRequests(widget.kc.id);
}
void _reloadGemeinden() =>
setState(() => _gemeinden = _api.gemeinden(widget.kc.id));
void _reloadRequests() =>
setState(() => _requests = _api.onboardingRequests(widget.kc.id));
Future<void> _addGemeinde() async {
final api = _api;
final name = await promptText(context, 'Neue Gemeinde', 'Name');
if (name == null || name.isEmpty || !mounted) return;
try {
await api.createGemeinde(widget.kc.id, name);
if (mounted) _reloadGemeinden();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.kc.name)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: ListTile(
title: const Text('Einladungscode'),
subtitle: Text(widget.kc.inviteCode),
trailing: const Icon(Icons.qr_code_2),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.how_to_vote),
title: const Text('Workshop-Wahlen'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => WahlAdminScreen(kcId: widget.kc.id)),
),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.folder_shared),
title: const Text('Dateien'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => FilesAdminScreen(kcId: widget.kc.id)),
),
),
),
const SizedBox(height: 16),
SectionHeader('Gemeinden', action: TextButton.icon(
onPressed: _addGemeinde,
icon: const Icon(Icons.add),
label: const Text('Hinzufügen'),
)),
_GemeindeList(future: _gemeinden!, onRetry: _reloadGemeinden),
const Divider(height: 40),
Text('Offene Verantwortlichen-Anfragen',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
_RequestList(
future: _requests!,
onAction: (id, approve) async {
try {
approve
? await _api.approveOnboarding(id)
: await _api.rejectOnboarding(id);
_reloadRequests();
} catch (e) {
if (context.mounted) toast(context, '$e');
}
},
),
],
),
);
}
}
class _GemeindeList extends StatelessWidget {
const _GemeindeList({required this.future, required this.onRetry});
final Future<List<Gemeinde>> future;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Gemeinde>>(
future: future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Padding(
padding: EdgeInsets.all(12),
child: Center(child: CircularProgressIndicator()),
);
}
if (snap.hasError) {
return TextButton(onPressed: onRetry, child: Text('Fehler: ${snap.error}'));
}
final gemeinden = snap.data!;
if (gemeinden.isEmpty) return const Text('Noch keine Gemeinden.');
return Column(
children: [
for (final g in gemeinden)
ListTile(
dense: true,
leading: const Icon(Icons.church),
title: Text(g.name),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TeamerAdminScreen(gemeindeId: g.id, gemeindeName: g.name),
),
),
),
],
);
},
);
}
}
class _RequestList extends StatelessWidget {
const _RequestList({required this.future, required this.onAction});
final Future<List<OnboardingRequest>> future;
final Future<void> Function(String id, bool approve) onAction;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<OnboardingRequest>>(
future: future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Padding(
padding: EdgeInsets.all(12),
child: Center(child: CircularProgressIndicator()),
);
}
if (snap.hasError) {
return Text('Fehler: ${snap.error}');
}
final requests = snap.data!;
if (requests.isEmpty) return const Text('Keine offenen Anfragen.');
return Column(
children: [
for (final r in requests)
Card(
child: ListTile(
title: Text(r.userName.isEmpty ? r.userEmail : r.userName),
subtitle: Text('${r.userEmail}\nGemeinde: ${r.gemeindeName}'),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Genehmigen',
icon: const Icon(Icons.check, color: Colors.green),
onPressed: () => onAction(r.id, true),
),
IconButton(
tooltip: 'Ablehnen',
icon: const Icon(Icons.close, color: Colors.red),
onPressed: () => onAction(r.id, false),
),
],
),
),
),
],
);
},
);
}
}
+849
View File
@@ -0,0 +1,849 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../api.dart';
import '../chat_socket.dart';
import '../main.dart';
/// WhatsApp brand colors
abstract final class WhatsAppColors {
static const primary = Color(0xFF008069); // WhatsApp Header Green
static const primaryDark = Color(0xFF075E54); // Classic WhatsApp Dark Green
static const accent = Color(0xFF00A884); // WhatsApp Bright Accent Green
static const chatBackground = Color(0xFFEFEAE2); // WhatsApp Chat Wallpaper BG
static const outgoingBubble = Color(0xFFE7FFDB); // WhatsApp Light Green Bubble
static const incomingBubble = Color(0xFFFFFFFF); // WhatsApp White Bubble
static const textPrimary = Color(0xFF111B21); // WhatsApp Main Text Color
static const textSecondary = Color(0xFF667781); // WhatsApp Muted / Timestamp Color
static const checkmarkBlue = Color(0xFF53BDEB); // WhatsApp Blue Double Check
static const dateBadgeBg = Color(0xEEFFFFFF); // WhatsApp Date Header BG
static const dateBadgeText = Color(0xFF54656F); // WhatsApp Date Header Text
static const composerBg = Color(0xFFF0F2F5); // WhatsApp Composer Bar BG
static const iconMuted = Color(0xFF54656F);
}
/// Chat channel overview screen with WhatsApp look and feel.
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.kcId});
final String kcId;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
Future<List<ChatChannel>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.channels(widget.kcId);
}
static const _typeLabels = {
'GEMEINDE_GRUPPE': 'Gemeinde-Gruppe',
'DIREKT': 'Direktnachricht',
'LT_UEBERGREIFEND': 'Leitungsteam',
'BROADCAST': 'Ankündigungen',
'GRUPPE': 'Gruppenchat',
};
static const _typeIcons = {
'GEMEINDE_GRUPPE': Icons.people_alt_rounded,
'DIREKT': Icons.person_rounded,
'LT_UEBERGREIFEND': Icons.shield_rounded,
'BROADCAST': Icons.campaign_rounded,
'GRUPPE': Icons.groups_rounded,
};
static const _typeColors = {
'GEMEINDE_GRUPPE': Color(0xFF008069),
'DIREKT': Color(0xFF2F7CFF),
'LT_UEBERGREIFEND': Color(0xFF6C5CE7),
'BROADCAST': Color(0xFFF17C20),
'GRUPPE': Color(0xFF0984E3),
};
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
appBarTheme: const AppBarTheme(
backgroundColor: WhatsAppColors.primary,
foregroundColor: Colors.white,
elevation: 1,
iconTheme: IconThemeData(color: Colors.white),
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
),
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('Chats'),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Suchen',
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.more_vert),
tooltip: 'Optionen',
onPressed: () {},
),
],
),
body: FutureBuilder<List<ChatChannel>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(
child: CircularProgressIndicator(color: WhatsAppColors.primary),
);
}
if (snap.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
const SizedBox(height: 12),
Text('${snap.error}', textAlign: TextAlign.center),
],
),
),
);
}
final channels = snap.data!;
if (channels.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline, size: 56, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
'Keine Kanäle sichtbar',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
],
),
);
}
return ListView.separated(
itemCount: channels.length,
separatorBuilder: (_, __) => const Divider(
indent: 80,
endIndent: 16,
height: 1,
thickness: 0.8,
color: Color(0xFFF0F2F5),
),
itemBuilder: (context, i) {
final c = channels[i];
final label = c.name?.isNotEmpty == true ? c.name! : (_typeLabels[c.type] ?? c.type);
final icon = _typeIcons[c.type] ?? Icons.chat_rounded;
final color = _typeColors[c.type] ?? WhatsAppColors.primary;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: CircleAvatar(
radius: 25,
backgroundColor: color.withValues(alpha: 0.15),
child: Icon(icon, color: color, size: 28),
),
title: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: WhatsAppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
subtitle: Text(
_typeLabels[c.type] ?? c.type,
style: const TextStyle(
fontSize: 13.5,
color: WhatsAppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _ChannelMessages(
channelId: c.id,
title: label,
subtitle: _typeLabels[c.type] ?? c.type,
icon: icon,
iconColor: color,
),
),
),
);
},
);
},
),
),
);
}
}
/// WhatsApp-style chat message screen.
class _ChannelMessages extends StatefulWidget {
const _ChannelMessages({
required this.channelId,
required this.title,
required this.subtitle,
required this.icon,
required this.iconColor,
});
final String channelId;
final String title;
final String subtitle;
final IconData icon;
final Color iconColor;
@override
State<_ChannelMessages> createState() => _ChannelMessagesState();
}
class _ChannelMessagesState extends State<_ChannelMessages> {
final List<ChatMessage> _messages = [];
final _composer = TextEditingController();
final _scroll = ScrollController();
ChatSocket? _socket;
bool _loading = true;
String? _loadError;
String _wsStatus = 'verbinde…';
bool _hasText = false;
@override
void initState() {
super.initState();
_composer.addListener(() {
final hasText = _composer.text.trim().isNotEmpty;
if (hasText != _hasText) {
setState(() => _hasText = hasText);
}
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_socket != null) return;
final api = AppScope.of(context).api;
_load(api);
_socket = ChatSocket(api.chatWsUri())
..connect(widget.channelId)
..messages.listen(_onIncoming)
..status.listen((s) => mounted ? setState(() => _wsStatus = s) : null);
}
Future<void> _load(Api api) async {
try {
final history = await api.messages(widget.channelId);
if (!mounted) return;
setState(() {
_messages
..clear()
..addAll(history);
_loading = false;
});
_jump();
} catch (e) {
if (mounted) setState(() { _loadError = '$e'; _loading = false; });
}
}
void _onIncoming(ChatMessage m) {
if (!mounted) return;
setState(() => _messages.add(m));
_jump();
}
void _jump() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
});
}
void _send() {
final text = _composer.text.trim();
if (text.isEmpty) return;
_socket?.sendMessage(widget.channelId, text);
_composer.clear();
}
@override
void dispose() {
_socket?.dispose();
_composer.dispose();
_scroll.dispose();
super.dispose();
}
String _formatTime(String rawDate) {
final dt = DateTime.tryParse(rawDate)?.toLocal();
if (dt == null) return rawDate;
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
String _formatDateHeader(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final messageDate = DateTime(date.year, date.month, date.day);
if (messageDate == today) {
return 'HEUTE';
} else if (messageDate == today.subtract(const Duration(days: 1))) {
return 'GESTERN';
} else {
final d = date.day.toString().padLeft(2, '0');
final m = date.month.toString().padLeft(2, '0');
return '$d.$m.${date.year}';
}
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
Color _getSenderColor(String id) {
final colors = [
const Color(0xFF1E88E5),
const Color(0xFFE53935),
const Color(0xFF8E24AA),
const Color(0xFF3949AB),
const Color(0xFF00897B),
const Color(0xFFD81B60),
const Color(0xFFFB8C00),
const Color(0xFF43A047),
];
var hash = 0;
for (var i = 0; i < id.length; i++) {
hash = (hash * 31 + id.codeUnitAt(i)) & 0x7FFFFFFF;
}
return colors[hash % colors.length];
}
@override
Widget build(BuildContext context) {
final identity = AppScope.of(context).identity;
return Theme(
data: Theme.of(context).copyWith(
appBarTheme: const AppBarTheme(
backgroundColor: WhatsAppColors.primary,
foregroundColor: Colors.white,
elevation: 1,
iconTheme: IconThemeData(color: Colors.white),
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
child: Scaffold(
backgroundColor: WhatsAppColors.chatBackground,
appBar: AppBar(
titleSpacing: 0,
title: Row(
children: [
CircleAvatar(
radius: 19,
backgroundColor: Colors.white.withValues(alpha: 0.2),
child: Icon(widget.icon, color: Colors.white, size: 22),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title,
style: const TextStyle(
fontSize: 16.5,
fontWeight: FontWeight.w600,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 1),
Text(
_wsStatus == 'verbunden' || _wsStatus == 'connected'
? 'online'
: _wsStatus,
style: TextStyle(
fontSize: 11.5,
color: Colors.white.withValues(alpha: 0.85),
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Videoanruf',
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.call_rounded),
tooltip: 'Anruf',
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.more_vert),
tooltip: 'Optionen',
onPressed: () {},
),
],
),
body: Stack(
children: [
// WhatsApp Doodle Pattern Background
Positioned.fill(
child: CustomPaint(
painter: _WhatsAppDoodlePainter(),
),
),
// Chat Content
Column(
children: [
Expanded(child: _buildMessagesList(context, identity)),
_buildComposer(context),
],
),
],
),
),
);
}
Widget _buildMessagesList(BuildContext context, Identity? identity) {
if (_loading) {
return const Center(
child: CircularProgressIndicator(color: WhatsAppColors.primary),
);
}
if (_loadError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
const SizedBox(height: 12),
Text(_loadError!, textAlign: TextAlign.center),
],
),
),
);
}
if (_messages.isEmpty) {
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: WhatsAppColors.dateBadgeBg,
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 3,
offset: Offset(0, 1),
),
],
),
child: const Text(
'Nachrichten sind durch End-to-End-Verschlüsselung geschützt.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: WhatsAppColors.dateBadgeText,
fontWeight: FontWeight.w500,
),
),
),
);
}
final items = <Widget>[];
DateTime? lastDate;
for (var i = 0; i < _messages.length; i++) {
final m = _messages[i];
final msgDate = DateTime.tryParse(m.createdAt)?.toLocal();
// Insert date divider if day changed
if (msgDate != null && (lastDate == null || !_isSameDay(lastDate, msgDate))) {
items.add(_buildDateHeader(_formatDateHeader(msgDate)));
lastDate = msgDate;
}
final isMe = (identity?.kind == SessionKind.user &&
m.senderUserId != null &&
m.senderUserId == identity?.userId) ||
(identity?.kind == SessionKind.guest &&
m.senderGuestId != null &&
m.senderGuestId == identity?.guestId);
items.add(_buildMessageBubble(m, isMe));
}
return ListView(
controller: _scroll,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
children: items,
);
}
Widget _buildDateHeader(String text) {
return Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: WhatsAppColors.dateBadgeBg,
borderRadius: BorderRadius.circular(7.5),
boxShadow: const [
BoxShadow(
color: Color(0x10000000),
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
child: Text(
text,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
color: WhatsAppColors.dateBadgeText,
),
),
),
);
}
Widget _buildMessageBubble(ChatMessage m, bool isMe) {
final timeStr = _formatTime(m.createdAt);
final senderId = m.senderUserId ?? m.senderGuestId;
final showSender = !isMe && senderId != null;
final senderColor = showSender ? _getSenderColor(senderId) : Colors.black;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.78,
minWidth: 80,
),
child: Container(
margin: const EdgeInsets.only(bottom: 4, top: 2),
decoration: BoxDecoration(
color: isMe ? WhatsAppColors.outgoingBubble : WhatsAppColors.incomingBubble,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(12),
topRight: const Radius.circular(12),
bottomLeft: isMe ? const Radius.circular(12) : const Radius.circular(2),
bottomRight: isMe ? const Radius.circular(2) : const Radius.circular(12),
),
boxShadow: const [
BoxShadow(
color: Color(0x18000000),
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10, top: 6, bottom: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (showSender) ...[
Text(
m.senderGuestId != null ? 'Konfi / Gast' : 'Teamer / Leitung',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: senderColor,
),
),
const SizedBox(height: 2),
],
Wrap(
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.bottom,
spacing: 8,
runSpacing: 2,
children: [
Text(
m.body,
style: const TextStyle(
fontSize: 15,
color: WhatsAppColors.textPrimary,
height: 1.25,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
timeStr,
style: const TextStyle(
fontSize: 10.5,
color: WhatsAppColors.textSecondary,
),
),
if (isMe) ...[
const SizedBox(width: 3),
const Icon(
Icons.done_all_rounded,
size: 15,
color: WhatsAppColors.checkmarkBlue,
),
],
],
),
),
],
),
],
),
),
),
),
);
}
Widget _buildComposer(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 3,
offset: Offset(0, 1),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.emoji_emotions_outlined),
color: WhatsAppColors.iconMuted,
splashRadius: 20,
onPressed: () {},
),
Expanded(
child: TextField(
controller: _composer,
minLines: 1,
maxLines: 5,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'Nachricht',
hintStyle: TextStyle(
color: WhatsAppColors.textSecondary,
fontSize: 15.5,
),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 11, horizontal: 4),
),
onSubmitted: (_) => _send(),
),
),
IconButton(
icon: const Icon(Icons.attach_file_rounded),
color: WhatsAppColors.iconMuted,
splashRadius: 20,
onPressed: () {},
),
if (!_hasText)
IconButton(
icon: const Icon(Icons.camera_alt_rounded),
color: WhatsAppColors.iconMuted,
splashRadius: 20,
onPressed: () {},
),
],
),
),
),
const SizedBox(width: 6),
Container(
decoration: const BoxDecoration(
color: WhatsAppColors.accent,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Color(0x28000000),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: IconButton(
icon: Icon(_hasText ? Icons.send_rounded : Icons.mic_rounded),
color: Colors.white,
splashRadius: 24,
onPressed: _send,
),
),
],
),
),
);
}
}
/// Custom painter for the iconic subtle WhatsApp background doodle canvas.
class _WhatsAppDoodlePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = const Color(0xFF4A6B82).withValues(alpha: 0.05)
..style = PaintingStyle.stroke
..strokeWidth = 1.3;
final fillPaint = Paint()
..color = const Color(0xFF4A6B82).withValues(alpha: 0.035)
..style = PaintingStyle.fill;
const spacing = 75.0;
final rows = (size.height / spacing).ceil() + 1;
final cols = (size.width / spacing).ceil() + 1;
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
final x = c * spacing + ((r % 2 == 1) ? spacing / 2 : 0);
final y = r * spacing;
final type = (r * 7 + c * 11) % 6;
canvas.save();
canvas.translate(x, y);
switch (type) {
case 0: // Chat bubble doodle
final rrect = RRect.fromRectAndRadius(
const Rect.fromLTWH(-10, -8, 20, 16),
const Radius.circular(5),
);
canvas.drawRRect(rrect, fillPaint);
canvas.drawRRect(rrect, paint);
break;
case 1: // Small Star
final path = Path();
for (var i = 0; i < 5; i++) {
final angle = i * 4 * math.pi / 5 - math.pi / 2;
final px = 8 * math.cos(angle);
final py = 8 * math.sin(angle);
if (i == 0) {
path.moveTo(px, py);
} else {
path.lineTo(px, py);
}
}
path.close();
canvas.drawPath(path, fillPaint);
canvas.drawPath(path, paint);
break;
case 2: // Heart doodle
final path = Path();
path.moveTo(0, 4);
path.cubicTo(-6, -2, -10, -8, 0, -10);
path.cubicTo(10, -8, 6, -2, 0, 4);
canvas.drawPath(path, fillPaint);
canvas.drawPath(path, paint);
break;
case 3: // Musical note
canvas.drawCircle(const Offset(-4, 4), 3, fillPaint);
canvas.drawCircle(const Offset(-4, 4), 3, paint);
canvas.drawLine(const Offset(-1, 4), const Offset(-1, -6), paint);
canvas.drawLine(const Offset(-1, -6), const Offset(5, -4), paint);
break;
case 4: // Coffee / cup
final rrect = RRect.fromRectAndRadius(
const Rect.fromLTWH(-7, -5, 14, 12),
const Radius.circular(3),
);
canvas.drawRRect(rrect, fillPaint);
canvas.drawRRect(rrect, paint);
canvas.drawArc(
const Rect.fromLTWH(4, -3, 6, 6),
-math.pi / 2,
math.pi,
false,
paint,
);
break;
case 5: // Clock / circle
canvas.drawCircle(Offset.zero, 7, fillPaint);
canvas.drawCircle(Offset.zero, 7, paint);
canvas.drawLine(Offset.zero, const Offset(0, -4), paint);
canvas.drawLine(Offset.zero, const Offset(3, 0), paint);
break;
}
canvas.restore();
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../browser.dart' as browser;
import '../main.dart';
import 'ui.dart';
/// LT file management for one KC: upload with a visibility tier + list.
class FilesAdminScreen extends StatefulWidget {
const FilesAdminScreen({super.key, required this.kcId});
final String kcId;
@override
State<FilesAdminScreen> createState() => _FilesAdminScreenState();
}
class _FilesAdminScreenState extends State<FilesAdminScreen> {
Future<List<FileEntry>>? _files;
bool _uploading = false;
Api get _api => AppScope.of(context).api;
static const _visibilities = {
'ALLE': 'Alle (inkl. Konfis)',
'ALLE_AUSSER_KONFIS': 'Team (ohne Konfis)',
'NUR_LT': 'Nur Leitungsteam',
};
@override
void didChangeDependencies() {
super.didChangeDependencies();
_files ??= _api.files(widget.kcId);
}
void _reload() => setState(() => _files = _api.files(widget.kcId));
Future<void> _upload() async {
final api = _api;
final picked = await browser.pickFile();
if (picked == null || !mounted) return;
final visibility = await showDialog<String>(
context: context,
builder: (_) => SimpleDialog(
title: Text('Sichtbarkeit für „${picked.name}'),
children: [
for (final e in _visibilities.entries)
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop(e.key),
child: Text(e.value),
),
],
),
);
if (visibility == null || !mounted) return;
setState(() => _uploading = true);
try {
await api.uploadFile(widget.kcId, picked.name, picked.bytes, visibility);
if (mounted) _reload();
} catch (e) {
if (mounted) toast(context, '$e');
} finally {
if (mounted) setState(() => _uploading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dateien (LT)')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _uploading ? null : _upload,
icon: _uploading
? const SizedBox(
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.upload_file),
label: const Text('Hochladen'),
),
body: FutureBuilder<List<FileEntry>>(
future: _files,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) return ErrorText('${snap.error}', onRetry: _reload);
final files = snap.data!;
if (files.isEmpty) {
return const Center(child: Text('Noch keine Dateien.'));
}
return ListView(
children: [
for (final f in files)
ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: Text(f.filename),
subtitle: Text(_visibilities[f.visibility] ?? f.visibility),
),
],
);
},
),
);
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
class FilesScreen extends StatefulWidget {
const FilesScreen({super.key, required this.kcId});
final String kcId;
@override
State<FilesScreen> createState() => _FilesScreenState();
}
class _FilesScreenState extends State<FilesScreen> {
Future<List<FileEntry>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.files(widget.kcId);
}
static const _visibilityLabels = {
'ALLE': 'Alle',
'ALLE_AUSSER_KONFIS': 'Team (ohne Konfis)',
'NUR_LT': 'Nur Leitungsteam',
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dateien')),
body: FutureBuilder<List<FileEntry>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${snap.error}', textAlign: TextAlign.center),
),
);
}
final files = snap.data!;
if (files.isEmpty) {
return const Center(child: Text('Keine Dateien freigegeben.'));
}
return ListView.separated(
itemCount: files.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, i) {
final f = files[i];
return ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: Text(f.filename),
subtitle: Text(_visibilityLabels[f.visibility] ?? f.visibility),
trailing: const Icon(Icons.download),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Download-URL: ${AppScope.of(context).api.fileDownloadUrl(f.id)}',
),
),
);
},
);
},
);
},
),
);
}
}
+207
View File
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import '../theme.dart';
import 'admin_screen.dart';
import 'chat_screen.dart';
import 'files_screen.dart';
import 'verantwortliche_register_screen.dart';
import 'wahl_screen.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final id = state.identity!;
final kcId = id.kcId;
final needsVerantwRegistration = id.kind == SessionKind.user &&
!id.isLeitungsteam &&
id.memberships.isEmpty;
final tiles = <Widget>[
if (id.isLeitungsteam)
_NavTile(
icon: Icons.admin_panel_settings,
title: 'Verwaltung',
subtitle: 'KCs, Gemeinden, Onboarding-Freigaben',
onTap: () => _open(context, const AdminScreen()),
),
if (needsVerantwRegistration)
_NavTile(
icon: Icons.how_to_reg,
title: 'Als Verantwortliche/r registrieren',
subtitle: 'KC-Code eingeben, Gemeinde wählen, Freigabe abwarten',
onTap: () => _open(context, const VerantwortlicheRegisterScreen()),
),
if (id.kind == SessionKind.guest)
_NavTile(
icon: Icons.how_to_vote,
title: 'Workshop-Wahl',
subtitle: 'Deine Wünsche abgeben',
onTap: () => _open(context, const WahlScreen()),
),
if (kcId != null)
_NavTile(
icon: Icons.folder_shared,
title: 'Dateien',
subtitle: 'Freigegebene Dateien ansehen',
onTap: () => _open(context, FilesScreen(kcId: kcId)),
),
if (kcId != null)
_NavTile(
icon: Icons.forum,
title: 'Chat',
subtitle: 'Kanäle, Verlauf & Live-Nachrichten',
onTap: () => _open(context, ChatScreen(kcId: kcId)),
),
];
return Scaffold(
appBar: AppBar(
title: const Text('KC-App'),
actions: [
IconButton(
tooltip: 'Abmelden',
onPressed: state.logout,
icon: const Icon(Icons.logout),
),
],
),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
_IdentityCard(id: id),
const SizedBox(height: 16),
...tiles,
if (tiles.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 24),
child: Text(
'Für diesen Account gibt es hier noch keine Ansichten. '
'Sobald dir eine Gemeinde/ein KC zugeordnet ist, erscheinen '
'Dateien und Chat.',
),
),
],
),
),
),
),
);
}
void _open(BuildContext context, Widget screen) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => screen));
}
}
class _IdentityCard extends StatelessWidget {
const _IdentityCard({required this.id});
final Identity id;
@override
Widget build(BuildContext context) {
final lines = <String>[
if (id.email != null) id.email!,
if (id.isLeitungsteam)
'Leitungsteam-Rechte gelten KC-übergreifend.'
else if (id.memberships.length > 1)
'${id.memberships.length} Zuordnungen',
];
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [KcColors.blue, KcColors.teal],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
),
child: const Icon(Icons.person, color: Colors.white),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(id.roleLabel, style: Theme.of(context).textTheme.titleMedium),
for (final l in lines) ...[
const SizedBox(height: 2),
Text(l, style: Theme.of(context).textTheme.bodySmall),
],
],
),
),
],
),
),
);
}
}
class _NavTile extends StatelessWidget {
const _NavTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: KcColors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: KcColors.blue),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 2),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
Icon(Icons.chevron_right, color: KcColors.slate.withValues(alpha: 0.6)),
],
),
),
),
);
}
}
+374
View File
@@ -0,0 +1,374 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import '../theme.dart';
/// A single login screen — one card, no tabs, no role switcher. The KC-Code
/// field drives Konfi vs. Leitungsteam: a plain code reveals the Konfi name
/// fields; appending "LT" to the code (e.g. "ABC123LT") reveals the
/// Leitungsteam Authentik button instead. Gemeinde Teamer:in has its own
/// section below, logging in with the Gemeinde name instead of an email.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _Brand(),
const SizedBox(height: 28),
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
_KonfiOrLeitungsteamSection(),
Divider(height: 40),
_TeamerSection(),
],
),
),
),
],
),
),
),
),
),
);
}
}
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [KcColors.blue, KcColors.teal],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.castle_outlined, color: Colors.white, size: 32),
),
const SizedBox(height: 16),
const Text(
'KC-App',
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800, color: KcColors.navy),
),
const SizedBox(height: 4),
Text('Konfi-Castle Events', style: TextStyle(fontSize: 14, color: KcColors.slate)),
],
);
}
}
/// Shared submit-button + error handling.
class _FormShell extends StatefulWidget {
const _FormShell({required this.fields, required this.onSubmit, this.submitLabel = 'Anmelden'});
final List<Widget> fields;
final Future<void> Function() onSubmit;
final String submitLabel;
@override
State<_FormShell> createState() => _FormShellState();
}
class _FormShellState extends State<_FormShell> {
bool _busy = false;
String? _error;
Future<void> _run() async {
setState(() {
_busy = true;
_error = null;
});
try {
await widget.onSubmit();
} on ApiException catch (e) {
setState(() => _error = e.message);
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...widget.fields,
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _busy ? null : _run,
child: _busy
? const SizedBox(
height: 18, width: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: Text(widget.submitLabel),
),
],
);
}
}
TextField _field(TextEditingController c, String label,
{bool obscure = false, IconData? icon, ValueChanged<String>? onChanged}) =>
TextField(
controller: c,
obscureText: obscure,
onChanged: onChanged,
decoration: InputDecoration(
labelText: label,
prefixIcon: icon != null ? Icon(icon, size: 20) : null,
),
);
const _fieldGap = SizedBox(height: 12);
/// One code field drives two different logins: a plain KC-Code reveals the
/// Konfi name fields; a code ending in "LT" (e.g. "ABC123LT") reveals the
/// Leitungsteam Authentik button instead — no separate role picker needed.
class _KonfiOrLeitungsteamSection extends StatefulWidget {
const _KonfiOrLeitungsteamSection();
@override
State<_KonfiOrLeitungsteamSection> createState() => _KonfiOrLeitungsteamSectionState();
}
class _KonfiOrLeitungsteamSectionState extends State<_KonfiOrLeitungsteamSection> {
final _code = TextEditingController();
final _first = TextEditingController();
final _last = TextEditingController();
bool get _isLeitungsteamCode {
final c = _code.text.trim();
final upper = c.toUpperCase();
final lower = c.toLowerCase();
return upper == 'LT' || (c.length > 2 && upper.endsWith('LT')) || lower == 'login' || lower == 'sso';
}
/// The KC-Code with a trailing "LT" trigger stripped back off, so
/// "ABC123LT" still resolves to the real invite code "ABC123".
String get _plainCode {
final c = _code.text.trim();
if (c.toUpperCase() == 'LT' || c.toLowerCase() == 'login' || c.toLowerCase() == 'sso') return '';
return (c.length > 2 && c.toUpperCase().endsWith('LT'))
? c.substring(0, c.length - 2)
: c;
}
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_field(
_code,
'KC-Code',
icon: Icons.confirmation_number_outlined,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 4),
Text(
'Konfi: gib deinen KC-Code ein. Leitungsteam: gib "LT" ein oder hänge "LT" an den '
'Code an (z. B. "LT" oder "ABC123LT").',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: _code.text.trim().isEmpty
? const SizedBox.shrink(key: ValueKey('empty'))
: _isLeitungsteamCode
? _LeitungsteamLogin(key: const ValueKey('lt'), state: state)
: Column(
key: const ValueKey('konfi'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_field(_first, 'Vorname', icon: Icons.badge_outlined),
_fieldGap,
_field(_last, 'Nachname'),
const SizedBox(height: 4),
_FormShell(
submitLabel: 'Los geht\'s',
fields: const [],
onSubmit: () => state.guestLogin(
_plainCode,
_first.text.trim(),
_last.text.trim(),
),
),
const SizedBox(height: 4),
Text(
'Meldest du dich erneut mit demselben Code und Namen '
'an, kommst du in deinen bestehenden Account zurück.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
],
);
}
}
class _LeitungsteamLogin extends StatelessWidget {
const _LeitungsteamLogin({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (state.authError != null) ...[
Text(state.authError!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 12),
],
FilledButton.icon(
onPressed: () => state.beginOidcLogin(),
icon: const Icon(Icons.login, size: 20),
label: const Text('Mit Konfi-Castle-ID anmelden'),
),
const SizedBox(height: 8),
Text(
'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte und '
'Gemeinde-Zuordnungen kommen automatisch aus deinem Account.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
);
}
}
/// Gemeinde Teamer:in login — Gemeinde name instead of email, since that's
/// what a Teamer actually thinks of as "their" login. Invite redemption for
/// a first-time account is folded in underneath.
class _TeamerSection extends StatefulWidget {
const _TeamerSection();
@override
State<_TeamerSection> createState() => _TeamerSectionState();
}
class _TeamerSectionState extends State<_TeamerSection> {
final _gemeinde = TextEditingController();
final _password = TextEditingController();
bool _showInvite = false;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Gemeinde Teamer:in', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
_FormShell(
fields: [
_field(_gemeinde, 'Gemeinde', icon: Icons.groups_outlined),
_fieldGap,
_field(_password, 'Passwort', obscure: true, icon: Icons.lock_outline),
],
onSubmit: () => state.teamLogin(
gemeindeName: _gemeinde.text.trim(),
password: _password.text,
),
),
const SizedBox(height: 4),
Center(
child: TextButton(
onPressed: () => setState(() => _showInvite = !_showInvite),
child: Text(_showInvite
? 'Einladung ausblenden'
: 'Noch kein Konto? Einladung einlösen'),
),
),
if (_showInvite) ...[
const Divider(height: 28),
const _InviteForm(),
],
],
);
}
}
class _InviteForm extends StatefulWidget {
const _InviteForm();
@override
State<_InviteForm> createState() => _InviteFormState();
}
class _InviteFormState extends State<_InviteForm> {
final _token = TextEditingController();
final _first = TextEditingController();
final _last = TextEditingController();
final _email = TextEditingController();
final _password = TextEditingController();
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Teamer:in-Einladung einlösen',
style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center),
const SizedBox(height: 16),
_FormShell(
submitLabel: 'Konto anlegen',
fields: [
_field(_token, 'Einladungscode / Token'),
_fieldGap,
_field(_first, 'Vorname'),
_fieldGap,
_field(_last, 'Nachname'),
_fieldGap,
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
_fieldGap,
_field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true),
],
onSubmit: () => state.redeemInvite(
token: _token.text.trim(),
first: _first.text.trim(),
last: _last.text.trim(),
password: _password.text,
email: _email.text.trim(),
),
),
],
);
}
}
@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'ui.dart';
/// Teamer administration for one Gemeinde — usable by the Leitungsteam or the
/// responsible Gemeinde Verantwortliche/r.
class TeamerAdminScreen extends StatefulWidget {
const TeamerAdminScreen({
super.key,
required this.gemeindeId,
required this.gemeindeName,
});
final String gemeindeId;
final String gemeindeName;
@override
State<TeamerAdminScreen> createState() => _TeamerAdminScreenState();
}
class _TeamerAdminScreenState extends State<TeamerAdminScreen> {
Future<List<TeamerAccount>>? _teamer;
Future<List<TeamerInvite>>? _invites;
Api get _api => AppScope.of(context).api;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_teamer ??= _api.teamerFor(widget.gemeindeId);
_invites ??= _api.teamerInvitesFor(widget.gemeindeId);
}
void _reloadTeamer() =>
setState(() => _teamer = _api.teamerFor(widget.gemeindeId));
void _reloadInvites() =>
setState(() => _invites = _api.teamerInvitesFor(widget.gemeindeId));
Future<void> _addTeamer() async {
final api = _api;
final v = await showDialog<(String, String, String, String)>(
context: context,
builder: (_) => const _NewTeamerDialog(),
);
if (v == null || !mounted) return;
try {
await api.createTeamer(
widget.gemeindeId,
firstName: v.$1,
lastName: v.$2,
email: v.$3,
password: v.$4,
);
if (mounted) _reloadTeamer();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
Future<void> _addInvite({required bool personal}) async {
final api = _api;
String? email;
if (personal) {
email = await promptText(context, 'E-Mail-Invite', 'E-Mail-Adresse');
if (email == null || email.isEmpty || !mounted) return;
}
try {
final inv = await api.createTeamerInvite(widget.gemeindeId, email: email);
if (!mounted) return;
_reloadInvites();
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Invite erstellt'),
content: SelectableText(
personal
? 'E-Mail an ${inv.email} ausgelöst.\n\nToken: ${inv.token}'
: 'Gruppen-Link-Token (mehrfach nutzbar):\n\n${inv.token}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
} catch (e) {
if (mounted) toast(context, '$e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Teamer:innen · ${widget.gemeindeName}')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
SectionHeader('Konten', action: TextButton.icon(
onPressed: _addTeamer,
icon: const Icon(Icons.person_add),
label: const Text('Anlegen'),
)),
FutureBuilder<List<TeamerAccount>>(
future: _teamer,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final list = snap.data!;
if (list.isEmpty) return const Text('Noch keine Teamer:innen.');
return Column(
children: [
for (final t in list)
ListTile(
dense: true,
leading: const Icon(Icons.person),
title: Text(t.name.isEmpty ? t.email : t.name),
subtitle: Text(t.email),
),
],
);
},
),
const Divider(height: 40),
SectionHeader('Einladungen', action: Wrap(
spacing: 4,
children: [
TextButton(
onPressed: () => _addInvite(personal: false),
child: const Text('Gruppen-Link'),
),
TextButton(
onPressed: () => _addInvite(personal: true),
child: const Text('per E-Mail'),
),
],
)),
FutureBuilder<List<TeamerInvite>>(
future: _invites,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final list = snap.data!;
if (list.isEmpty) return const Text('Keine Einladungen.');
return Column(
children: [
for (final i in list)
ListTile(
dense: true,
leading: Icon(i.revoked
? Icons.block
: i.email != null
? Icons.mail
: Icons.link),
title: Text(i.email ?? 'Gruppen-Link'),
subtitle: Text(
'${i.usedCount}${i.maxUses != null ? '/${i.maxUses}' : ''} genutzt'
'${i.revoked ? ' · widerrufen' : ''}',
),
trailing: SelectableText(
i.token.substring(0, 8),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
);
},
),
],
),
);
}
}
class _NewTeamerDialog extends StatefulWidget {
const _NewTeamerDialog();
@override
State<_NewTeamerDialog> createState() => _NewTeamerDialogState();
}
class _NewTeamerDialogState extends State<_NewTeamerDialog> {
final _first = TextEditingController();
final _last = TextEditingController();
final _email = TextEditingController();
final _password = TextEditingController();
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Teamer:in anlegen'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: _first, decoration: const InputDecoration(labelText: 'Vorname')),
TextField(controller: _last, decoration: const InputDecoration(labelText: 'Nachname')),
TextField(controller: _email, decoration: const InputDecoration(labelText: 'E-Mail')),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Passwort (min. 8)'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.of(context).pop((
_first.text.trim(),
_last.text.trim(),
_email.text.trim(),
_password.text,
)),
child: const Text('Anlegen'),
),
],
);
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
/// Small shared widgets/helpers used across the admin screens.
void toast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
class ErrorText extends StatelessWidget {
const ErrorText(this.message, {super.key, this.onRetry});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
if (onRetry != null) ...[
const SizedBox(height: 12),
OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')),
],
],
),
),
);
}
}
class SectionHeader extends StatelessWidget {
const SectionHeader(this.title, {super.key, this.action});
final String title;
final Widget? action;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
?action,
],
);
}
}
/// Single-line text prompt dialog. Returns the trimmed value or null.
Future<String?> promptText(BuildContext context, String title, String label) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(labelText: label),
onSubmitted: (v) => Navigator.of(context).pop(v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: const Text('OK'),
),
],
),
);
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
/// Self-registration as a Gemeinde Verantwortliche/r: enter the KC invite
/// code, pick your Gemeinde, send the request. The result is a PENDING
/// membership a Leitungsteam member has to approve.
class VerantwortlicheRegisterScreen extends StatefulWidget {
const VerantwortlicheRegisterScreen({super.key});
@override
State<VerantwortlicheRegisterScreen> createState() =>
_VerantwortlicheRegisterScreenState();
}
class _VerantwortlicheRegisterScreenState
extends State<VerantwortlicheRegisterScreen> {
final _code = TextEditingController();
String? _kcName;
List<(String id, String name)> _gemeinden = [];
String? _selectedGemeinde;
bool _busy = false;
String? _error;
String? _done;
Api get _api => AppScope.of(context).api;
Future<void> _resolve() async {
setState(() {
_busy = true;
_error = null;
_kcName = null;
_gemeinden = [];
});
try {
final res = await _api.resolveInvite(_code.text.trim());
setState(() {
_kcName = res['kcName'] as String?;
_gemeinden = ((res['gemeinden'] as List<dynamic>?) ?? [])
.map((g) => (g['id'] as String, g['name'] as String))
.toList();
});
} catch (e) {
setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _submit() async {
if (_selectedGemeinde == null) return;
setState(() {
_busy = true;
_error = null;
});
try {
final res =
await _api.registerVerantwortliche(_code.text.trim(), _selectedGemeinde!);
setState(() => _done =
'Anfrage gesendet (Status: ${res['status']}). Ein Leitungsteam-Mitglied '
'muss dich noch freischalten.');
} catch (e) {
setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Als Verantwortliche/r registrieren')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: ListView(
padding: const EdgeInsets.all(24),
children: [
if (_done != null) ...[
const Icon(Icons.check_circle, color: Colors.green, size: 48),
const SizedBox(height: 12),
Text(_done!, textAlign: TextAlign.center),
const SizedBox(height: 20),
FilledButton(
onPressed: () => AppScope.of(context).logout(),
child: const Text('Abmelden'),
),
] else ...[
TextField(
controller: _code,
decoration: const InputDecoration(
labelText: 'KC-Einladungscode',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _busy ? null : _resolve,
child: const Text('KC suchen'),
),
if (_kcName != null) ...[
const SizedBox(height: 20),
Text('KC: $_kcName',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: _selectedGemeinde,
decoration: const InputDecoration(
labelText: 'Deine Gemeinde',
border: OutlineInputBorder(),
),
items: [
for (final g in _gemeinden)
DropdownMenuItem(value: g.$1, child: Text(g.$2)),
],
onChanged: (v) => setState(() => _selectedGemeinde = v),
),
const SizedBox(height: 16),
FilledButton(
onPressed: (_busy || _selectedGemeinde == null) ? null : _submit,
child: const Text('Anfrage senden'),
),
],
if (_error != null) ...[
const SizedBox(height: 16),
Text(_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
],
],
),
),
),
);
}
}
@@ -0,0 +1,415 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../browser.dart' as browser;
import '../main.dart';
import 'ui.dart';
/// LT: manage the Wahlen of one KC — create, add workshops, run the
/// assignment algorithm, view the result.
class WahlAdminScreen extends StatefulWidget {
const WahlAdminScreen({super.key, required this.kcId});
final String kcId;
@override
State<WahlAdminScreen> createState() => _WahlAdminScreenState();
}
class _WahlAdminScreenState extends State<WahlAdminScreen> {
Future<List<WahlAdmin>>? _future;
Api get _api => AppScope.of(context).api;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= _api.wahlenForKc(widget.kcId);
}
void _reload() => setState(() => _future = _api.wahlenForKc(widget.kcId));
Future<void> _create() async {
final api = _api;
final v = await showDialog<(String, String, String)>(
context: context,
builder: (_) => const _NewWahlDialog(),
);
if (v == null || !mounted) return;
try {
await api.createWahl(widget.kcId, v.$1, v.$2, v.$3);
if (mounted) _reload();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Wahlen')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _create,
icon: const Icon(Icons.add),
label: const Text('Wahl'),
),
body: FutureBuilder<List<WahlAdmin>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) return ErrorText('${snap.error}', onRetry: _reload);
final wahlen = snap.data!;
if (wahlen.isEmpty) {
return const Center(child: Text('Noch keine Wahlen. Unten anlegen.'));
}
return ListView(
children: [
for (final w in wahlen)
ListTile(
leading: Icon(w.isOpen ? Icons.lock_open : Icons.lock),
title: Text(w.name),
subtitle: Text('${w.datumsSchluessel} · Teil ${w.teil}'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => WahlDetailScreen(wahl: w)),
),
),
],
);
},
),
);
}
}
class WahlDetailScreen extends StatefulWidget {
const WahlDetailScreen({super.key, required this.wahl});
final WahlAdmin wahl;
@override
State<WahlDetailScreen> createState() => _WahlDetailScreenState();
}
class _WahlDetailScreenState extends State<WahlDetailScreen> {
Future<List<WorkshopAdmin>>? _workshops;
Future<List<ZuteilungRow>>? _results;
Future<List<TeilnehmerRow>>? _teilnehmer;
late bool _isOpen = widget.wahl.isOpen;
List<WorkshopAdmin> _workshopCache = const [];
bool _running = false;
Api get _api => AppScope.of(context).api;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_workshops ??= _api.workshopsForWahl(widget.wahl.id).then((w) {
_workshopCache = w;
return w;
});
_results ??= _api.zuteilungResults(widget.wahl.id);
_teilnehmer ??= _api.wahlTeilnehmer(widget.wahl.id);
}
void _reloadWorkshops() => setState(() {
_workshops = _api.workshopsForWahl(widget.wahl.id).then((w) {
_workshopCache = w;
return w;
});
});
void _reloadResults() =>
setState(() => _results = _api.zuteilungResults(widget.wahl.id));
void _reloadTeilnehmer() =>
setState(() => _teilnehmer = _api.wahlTeilnehmer(widget.wahl.id));
Future<void> _toggleOpen(bool value) async {
final api = _api;
setState(() => _isOpen = value);
try {
await api.setWahlOpen(widget.wahl.id, value);
} catch (e) {
if (mounted) {
setState(() => _isOpen = !value);
toast(context, '$e');
}
}
}
Future<void> _exportCsv() async {
final api = _api;
try {
final csv = await api.zuteilungCsv(widget.wahl.id);
browser.downloadText('zuteilung-${widget.wahl.name}.csv', csv);
} catch (e) {
if (mounted) toast(context, '$e');
}
}
Future<void> _forceFor(TeilnehmerRow t) async {
final api = _api;
final workshopId = await showDialog<String>(
context: context,
builder: (_) => SimpleDialog(
title: Text('Zuteilung für ${t.name}'),
children: [
for (final w in _workshopCache)
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop(w.id),
child: Text(w.name),
),
],
),
);
if (workshopId == null || !mounted) return;
try {
await api.forceZuteilung(widget.wahl.id, t.id, workshopId);
if (mounted) _reloadTeilnehmer();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
Future<void> _addWorkshop() async {
final api = _api;
final v = await showDialog<(String, int, int)>(
context: context,
builder: (_) => const _NewWorkshopDialog(),
);
if (v == null || !mounted) return;
try {
await api.createWorkshop(widget.wahl.id, v.$1, v.$2, v.$3);
if (mounted) _reloadWorkshops();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
Future<void> _run() async {
final api = _api;
setState(() => _running = true);
try {
await api.runZuteilung(widget.wahl.id);
if (mounted) _reloadResults();
} catch (e) {
if (mounted) toast(context, '$e');
} finally {
if (mounted) setState(() => _running = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.wahl.name)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: SwitchListTile(
title: const Text('Wahl geöffnet'),
subtitle: Text(_isOpen
? 'Konfis können Wünsche abgeben'
: 'Geschlossen — keine neuen Einreichungen'),
value: _isOpen,
onChanged: _toggleOpen,
),
),
const SizedBox(height: 8),
SectionHeader('Workshops', action: TextButton.icon(
onPressed: _addWorkshop,
icon: const Icon(Icons.add),
label: const Text('Hinzufügen'),
)),
FutureBuilder<List<WorkshopAdmin>>(
future: _workshops,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final ws = snap.data!;
if (ws.isEmpty) return const Text('Noch keine Workshops.');
return Column(
children: [
for (final w in ws)
ListTile(
dense: true,
leading: const Icon(Icons.groups),
title: Text(w.name),
subtitle: Text('Kapazität ${w.kapazitaet} · min. ${w.minTeilnehmer}'),
),
],
);
},
),
const Divider(height: 40),
SectionHeader('Teilnehmer:innen'),
const SizedBox(height: 4),
FutureBuilder<List<TeilnehmerRow>>(
future: _teilnehmer,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final rows = snap.data!;
if (rows.isEmpty) return const Text('Noch keine Einreichungen.');
return Column(
children: [
for (final t in rows)
ListTile(
dense: true,
leading: const Icon(Icons.person),
title: Text(t.name),
subtitle: Text('Wünsche: ${t.prioritaeten.length}'
'${t.forcedWorkshopId != null ? ' · fest zugeteilt' : ''}'),
trailing: TextButton(
onPressed: () => _forceFor(t),
child: const Text('Zuteilen'),
),
),
],
);
},
),
const Divider(height: 40),
Row(
children: [
Expanded(
child: Text('Zuteilung',
style: Theme.of(context).textTheme.titleMedium),
),
IconButton(
tooltip: 'CSV exportieren',
onPressed: _exportCsv,
icon: const Icon(Icons.download),
),
FilledButton.icon(
onPressed: _running ? null : _run,
icon: _running
? const SizedBox(
height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: const Text('Ausführen'),
),
],
),
const SizedBox(height: 8),
FutureBuilder<List<ZuteilungRow>>(
future: _results,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final rows = snap.data!;
if (rows.isEmpty) {
return const Text('Noch keine Zuteilung berechnet.');
}
return Column(
children: [
for (final r in rows)
ListTile(
dense: true,
title: Text(r.name),
subtitle: Text(r.workshopName ?? 'UNZUGETEILT'),
trailing: Text(
r.isForced
? 'fest'
: r.wunschRang > 0
? 'Wunsch ${r.wunschRang}'
: '',
),
),
],
);
},
),
],
),
);
}
}
class _NewWahlDialog extends StatefulWidget {
const _NewWahlDialog();
@override
State<_NewWahlDialog> createState() => _NewWahlDialogState();
}
class _NewWahlDialogState extends State<_NewWahlDialog> {
final _name = TextEditingController();
final _datum = TextEditingController();
final _teil = TextEditingController(text: '1');
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Neue Wahl'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
TextField(
controller: _datum,
decoration: const InputDecoration(labelText: 'Datumsschlüssel (z. B. 2026-06-13)')),
TextField(controller: _teil, decoration: const InputDecoration(labelText: 'Teil')),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.of(context).pop(
(_name.text.trim(), _datum.text.trim(), _teil.text.trim()),
),
child: const Text('Anlegen'),
),
],
);
}
}
class _NewWorkshopDialog extends StatefulWidget {
const _NewWorkshopDialog();
@override
State<_NewWorkshopDialog> createState() => _NewWorkshopDialogState();
}
class _NewWorkshopDialogState extends State<_NewWorkshopDialog> {
final _name = TextEditingController();
final _kap = TextEditingController(text: '12');
final _min = TextEditingController(text: '0');
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Neuer Workshop'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
TextField(
controller: _kap,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Kapazität')),
TextField(
controller: _min,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Mindestteilnehmer')),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.of(context).pop((
_name.text.trim(),
int.tryParse(_kap.text) ?? 0,
int.tryParse(_min.text) ?? 0,
)),
child: const Text('Anlegen'),
),
],
);
}
}
+318
View File
@@ -0,0 +1,318 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
class WahlScreen extends StatefulWidget {
const WahlScreen({super.key});
@override
State<WahlScreen> createState() => _WahlScreenState();
}
class _WahlScreenState extends State<WahlScreen> {
Future<GuestOverview>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.guestWahlOverview();
}
void _reload() {
setState(() {
_future = AppScope.of(context).api.guestWahlOverview();
});
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Workshop-Wahl'),
bottom: const TabBar(tabs: [Tab(text: 'Wünsche'), Tab(text: 'Ergebnis')]),
),
body: TabBarView(
children: [
FutureBuilder<GuestOverview>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return _ErrorView(message: '${snap.error}', onRetry: _reload);
}
final data = snap.data!;
if (data.wahlen.isEmpty) {
return const Center(child: Text('Aktuell ist keine Wahl geöffnet.'));
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(data.kcName, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
for (final w in data.wahlen)
_WahlCard(wahl: w, onSubmitted: _reload),
],
);
},
),
const _ErgebnisTab(),
],
),
),
);
}
}
class _ErgebnisTab extends StatefulWidget {
const _ErgebnisTab();
@override
State<_ErgebnisTab> createState() => _ErgebnisTabState();
}
class _ErgebnisTabState extends State<_ErgebnisTab> {
Future<List<WahlResult>>? _future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.of(context).api.guestWahlResults();
}
void _reload() {
setState(() => _future = AppScope.of(context).api.guestWahlResults());
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<WahlResult>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return _ErrorView(message: '${snap.error}', onRetry: _reload);
}
final results = snap.data!;
if (results.isEmpty) {
return const Center(child: Text('Noch keine Teilnahme an einer Wahl.'));
}
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [for (final r in results) _ResultCard(result: r)],
),
);
},
);
}
}
class _ResultCard extends StatelessWidget {
const _ResultCard({required this.result});
final WahlResult result;
@override
Widget build(BuildContext context) {
final (label, color, detail) = switch (result.status) {
WahlResultStatus.assigned => (
result.workshopName ?? 'Zugeteilt',
Colors.green,
result.isForced
? 'Fest zugeteilt (Leitungsteam)'
: 'Wunsch ${result.wunschRang ?? '?'}',
),
WahlResultStatus.unassigned => (
'Kein Platz frei',
Theme.of(context).colorScheme.error,
'Bitte beim Leitungsteam melden.',
),
WahlResultStatus.pending => (
'Noch nicht zugeteilt',
Theme.of(context).colorScheme.outline,
'Die Zuteilung läuft noch.',
),
};
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: Icon(Icons.emoji_events, color: color),
title: Text('${result.wahlName} · ${result.datumsSchluessel} Teil ${result.teil}',
style: Theme.of(context).textTheme.bodySmall),
subtitle: Text(label, style: Theme.of(context).textTheme.titleMedium),
trailing: Text(detail, textAlign: TextAlign.end),
),
);
}
}
class _WahlCard extends StatefulWidget {
const _WahlCard({required this.wahl, required this.onSubmitted});
final Wahl wahl;
final VoidCallback onSubmitted;
@override
State<_WahlCard> createState() => _WahlCardState();
}
class _WahlCardState extends State<_WahlCard> {
late final List<String> _picked = [...?widget.wahl.meinePrioritaeten];
bool _busy = false;
String? _error;
bool _done = false;
static const _maxPicks = 3;
void _toggle(String workshopId) {
setState(() {
if (_picked.contains(workshopId)) {
_picked.remove(workshopId);
} else if (_picked.length < _maxPicks) {
_picked.add(workshopId);
}
_done = false;
});
}
Future<void> _submit() async {
setState(() {
_busy = true;
_error = null;
});
try {
await AppScope.of(context).api.submitPrioritaeten(widget.wahl.id, _picked);
setState(() => _done = true);
widget.onSubmitted();
} on ApiException catch (e) {
setState(() => _error = e.message);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final w = widget.wahl;
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(w.name, style: Theme.of(context).textTheme.titleLarge),
Text('${w.datumsSchluessel} · Teil ${w.teil}',
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 4),
Text(
'Tippe deine Wünsche in Reihenfolge an (max. $_maxPicks). '
'Die Zahl zeigt den Rang.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
for (final ws in w.workshops)
_WorkshopRow(
workshop: ws,
rank: _picked.indexOf(ws.id),
enabled: !_busy,
onTap: () => _toggle(ws.id),
),
const SizedBox(height: 12),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
Row(
children: [
FilledButton(
onPressed: (_busy || _picked.isEmpty) ? null : _submit,
child: _busy
? const SizedBox(
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Wünsche absenden'),
),
const SizedBox(width: 12),
if (_done)
Row(
children: const [
Icon(Icons.check_circle, color: Colors.green, size: 20),
SizedBox(width: 4),
Text('Gespeichert'),
],
),
],
),
],
),
),
);
}
}
class _WorkshopRow extends StatelessWidget {
const _WorkshopRow({
required this.workshop,
required this.rank,
required this.enabled,
required this.onTap,
});
final Workshop workshop;
final int rank;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final selected = rank >= 0;
return ListTile(
dense: true,
enabled: enabled,
onTap: onTap,
leading: CircleAvatar(
radius: 14,
backgroundColor: selected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Text(
selected ? '${rank + 1}' : '',
style: TextStyle(
fontSize: 13,
color: selected ? Theme.of(context).colorScheme.onPrimary : null,
),
),
),
title: Text(workshop.name),
subtitle: Text('Kapazität ${workshop.kapazitaet}'),
trailing: Icon(selected ? Icons.check_box : Icons.check_box_outline_blank),
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')),
],
),
),
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
/// Brand palette lifted from konfi-castle.com (Kubio theme CSS custom
/// properties: --kubio-color-1..6) so the app's look leans on the same
/// identity as the marketing site instead of Flutter's default Material
/// purple.
abstract final class KcColors {
static const blue = Color(0xFF2F7CFF); // --kubio-color-1
static const orange = Color(0xFFF17C20); // --kubio-color-2
static const teal = Color(0xFF4EBA9A); // --kubio-color-3
static const slate = Color(0xFF69768B); // --kubio-color-4
static const navy = Color(0xFF2B2D42); // --kubio-color-6 (headings/text)
static const surface = Color(0xFFF7F9FC);
}
ThemeData buildKcTheme() {
final colorScheme = ColorScheme.fromSeed(
seedColor: KcColors.blue,
brightness: Brightness.light,
).copyWith(
primary: KcColors.blue,
secondary: KcColors.orange,
tertiary: KcColors.teal,
surface: KcColors.surface,
onSurface: KcColors.navy,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: KcColors.surface,
appBarTheme: AppBarTheme(
backgroundColor: KcColors.surface,
foregroundColor: KcColors.navy,
elevation: 0,
centerTitle: false,
titleTextStyle: const TextStyle(
color: KcColors.navy,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: Colors.white,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: KcColors.navy.withValues(alpha: 0.06)),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: KcColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: KcColors.blue, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: KcColors.blue,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(50),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: KcColors.blue,
side: const BorderSide(color: KcColors.blue),
minimumSize: const Size.fromHeight(46),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
selectedBackgroundColor: KcColors.blue,
selectedForegroundColor: Colors.white,
foregroundColor: KcColors.navy,
side: BorderSide(color: KcColors.navy.withValues(alpha: 0.15)),
),
),
textTheme: const TextTheme(
titleLarge: TextStyle(color: KcColors.navy, fontWeight: FontWeight.w700),
titleMedium: TextStyle(color: KcColors.navy, fontWeight: FontWeight.w600),
bodyMedium: TextStyle(color: KcColors.navy),
bodySmall: TextStyle(color: KcColors.slate),
labelMedium: TextStyle(color: KcColors.slate),
),
dividerTheme: DividerThemeData(color: KcColors.navy.withValues(alpha: 0.08)),
);
}
+386
View File
@@ -0,0 +1,386 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.3"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
url: "https://pub.dev"
source: hosted
version: "1.18.3"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
url: "https://pub.dev"
source: hosted
version: "3.2.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
url: "https://pub.dev"
source: hosted
version: "2.4.28"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.dev"
source: hosted
version: "2.5.7"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.dev"
source: hosted
version: "15.3.0"
web:
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks:
dart: ">=3.13.3 <4.0.0"
flutter: ">=3.44.0"
+23
View File
@@ -0,0 +1,23 @@
name: kc_app
description: "KC-App client — multi-tenant event, election and communication platform for Konfi-Castle events."
publish_to: 'none'
version: 0.1.0+1
environment:
sdk: ^3.13.3
dependencies:
flutter:
sdk: flutter
http: ^1.2.2
shared_preferences: ^2.3.2
web_socket_channel: ^3.0.1
crypto: ^3.0.6
web: ^1.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:kc_app/api.dart';
import 'package:kc_app/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
testWidgets('shows the login screen when there is no stored token', (tester) async {
SharedPreferences.setMockInitialValues({});
final state = AppState(Api(http.Client()));
await state.bootstrap(); // no stored token -> resolves immediately, no network
await tester.pumpWidget(KcApp(state: state));
await tester.pumpAndSettle();
expect(find.text('Konfi / Gast'), findsOneWidget);
expect(find.text('Team-Login'), findsOneWidget);
expect(find.text('Einladung'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Weiter'), findsWidgets);
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

+22
View File
@@ -0,0 +1,22 @@
// Background handler for FCM web push. Keep the config in sync with
// window.KC_FIREBASE in index.html (a service worker can't read window).
// Analytics is NOT initialised here — it only makes sense on visible pages
// with a real navigator context; the main index.html handles it.
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js');
firebase.initializeApp({
apiKey: 'AIzaSyDxBpdmW8lUHSuix--3AsWJScGQy9o3G_M',
projectId: 'konfi-castle-app',
messagingSenderId: '307226979593',
appId: '1:307226979593:web:66898b2c78b2d110ef77d8',
});
firebase.messaging().onBackgroundMessage(function (payload) {
const n = payload.notification || {};
self.registration.showNotification(n.title || 'KC-App', {
body: n.body || '',
icon: '/icons/Icon-192.png',
data: payload.data || {},
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+95
View File
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="Konfi-Castle App">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="KC-App">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>KC-App</title>
<link rel="manifest" href="manifest.json">
<!-- Firebase Cloud Messaging (web push) + Google Analytics for Firebase
(usage analytics, visible under Firebase Console > Analytics).
`vapidKey` is the *public* half of the Web Push certificate key pair;
the private half stays in Firebase. Backend push sending still needs a
service-account JSON + PUSH_PROVIDER=fcm. -->
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-analytics-compat.js"></script>
<script>
window.KC_FIREBASE = {
apiKey: "«reda...…»",
authDomain: "konfi-castle-app.firebaseapp.com",
projectId: "konfi-castle-app",
storageBucket: "konfi-castle-app.firebasestorage.app",
messagingSenderId: "307226979593",
appId: "1:307226979593:web:66898b2c78b2d110ef77d8",
measurementId: "G-NK8K5VV40D",
vapidKey: "BEAHrnIzkTBGSEws1J_HRvsTtc6nqvmW4MvFMTfljmjuunqo4yiJoUcq8_jKIt_hbm4diOt0czG28l-dpvGK8T8"
};
// Initialise Firebase + Analytics immediately (every page load, not just
// after login) so usage shows up in the Firebase Console. Automatically
// logs page_view/session_start/first_visit; screen navigation inside the
// Flutter app isn't tracked without extra instrumentation, but overall
// reach (users, sessions, retention) is.
(function () {
try {
if (!firebase.apps.length) firebase.initializeApp(window.KC_FIREBASE);
firebase.analytics();
} catch (e) {
console.warn("[analytics] init failed", e);
}
})();
window.kcGetPushToken = async function () {
try {
var cfg = window.KC_FIREBASE;
if (!cfg.vapidKey || cfg.vapidKey === "REPLACE_ME" || !("Notification" in window)) return null;
if (!firebase.apps.length) firebase.initializeApp(cfg);
var permission = await Notification.requestPermission();
if (permission !== "granted") return null;
var registration = await navigator.serviceWorker.register("firebase-messaging-sw.js");
var messaging = firebase.messaging();
return await messaging.getToken({ vapidKey: cfg.vapidKey, serviceWorkerRegistration: registration });
} catch (e) {
console.warn("[push] init failed", e);
return null;
}
};
</script>
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "KC-App",
"short_name": "KC-App",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Konfi-Castle Event-, Wahl- und Kommunikationsplattform",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+1078
View File
File diff suppressed because it is too large Load Diff
+335
View File
@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1E2032" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="KC-App" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700;800&family=Mulish:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=3" />
<title>KC-App</title>
</head>
<body>
<div id="app-root">
<!-- ═══════════ LOGIN SCREEN ═══════════ -->
<section id="login-screen" class="login-wrap">
<div class="login-logo">KC<span>-App</span></div>
<p class="login-sub">Konfi-Castle Events &middot; Web-Client</p>
<div class="login-card">
<form id="code-form" class="form-grid">
<label>
<span>Code</span>
<input id="login-code" name="code" placeholder="Einladungscode, 'LT', Gemeinde-Name, E-Mail…" autocomplete="off" autofocus />
</label>
<button type="submit" class="btn btn-primary btn-full">Weiter</button>
</form>
<!-- Follow-up forms, shown depending on what the code resolved to -->
<form id="guest-form" class="form-grid" hidden>
<p class="card-hint" id="guest-form-hint">Konfi-/Gast-Zugang</p>
<label>
<span>Vorname</span>
<input id="first-name" name="firstName" required />
</label>
<label>
<span>Nachname</span>
<input id="last-name" name="lastName" required />
</label>
<button type="submit" class="btn btn-orange btn-full">Beitreten</button>
<button type="button" class="btn btn-ghost btn-full back-btn">Anderer Code</button>
</form>
<form id="team-form" class="form-grid" hidden>
<p class="card-hint" id="team-form-hint">Team-Login</p>
<label>
<span>Passwort</span>
<input id="team-password" name="password" type="password" required />
</label>
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
<button type="button" class="btn btn-ghost btn-full back-btn">Anderer Code</button>
</form>
<form id="teamer-invite-form" class="form-grid" hidden>
<p class="card-hint" id="teamer-invite-hint">Einladung als Gemeinde-Teamer</p>
<label>
<span>Vorname</span>
<input id="ti-first-name" required />
</label>
<label>
<span>Nachname</span>
<input id="ti-last-name" required />
</label>
<label id="ti-email-label">
<span>E-Mail</span>
<input id="ti-email" type="email" />
</label>
<label>
<span>Passwort (min. 8 Zeichen)</span>
<input id="ti-password" type="password" minlength="8" required />
</label>
<button type="submit" class="btn btn-orange btn-full">Konto erstellen</button>
<button type="button" class="btn btn-ghost btn-full back-btn">Anderer Code</button>
</form>
<div id="sso-panel" class="form-grid" hidden>
<p class="card-hint">Anmeldung mit deiner Konfi-Castle-ID (Single Sign-On).</p>
<button type="button" id="authentik-login-btn" class="btn btn-primary btn-full">Mit Konfi-Castle-ID anmelden</button>
<button type="button" class="btn btn-ghost btn-full back-btn">Anderer Code</button>
</div>
<div id="verantw-invite-panel" class="form-grid" hidden>
<p class="card-hint" id="verantw-invite-hint">Einladung als Gemeinde-Verantwortliche/r</p>
<p class="card-hint">Anmeldung mit deiner Konfi-Castle-ID (Single Sign-On), die Einladung wird danach automatisch eingelöst.</p>
<button type="button" id="verantw-invite-login-btn" class="btn btn-primary btn-full">Mit Konfi-Castle-ID anmelden &amp; einlösen</button>
<button type="button" class="btn btn-ghost btn-full back-btn">Anderer Code</button>
</div>
<p id="login-status" class="status"></p>
</div>
</section>
<!-- ═══════════ APP SHELL ═══════════ -->
<div id="shell" class="shell" hidden>
<header class="hdr">
<div>
<div class="hdr-logo">KC<span>-App</span></div>
<div class="hdr-sub" id="hdr-sub">&nbsp;</div>
</div>
<div class="hdr-space"></div>
<span id="conn-badge" class="hdr-badge">offline</span>
<div class="hdr-avatar" id="hdr-avatar" title="Profil">?</div>
</header>
<div class="content" id="content">
<!-- ---------- HOME TAB ---------- -->
<section class="screen tab-screen" data-screen="home">
<div class="hero">
<div class="hero-kicker">Willkommen</div>
<div class="hero-title" id="home-greeting">Hallo!</div>
<div class="hero-chips">
<span class="hchip" id="home-role-chip">&ndash;</span>
<span class="hchip" id="home-kc-chip">&ndash;</span>
</div>
</div>
<div class="sh">Übersicht</div>
<div class="card card-blue">
<div class="card-header row">
<div>
<h2>Workshop-Wahl</h2>
<p class="card-hint">Offene Wahlen für dich</p>
</div>
</div>
<div id="home-wahl-summary" class="status">Noch nicht geladen.</div>
</div>
<div class="card card-orange">
<div class="card-header row">
<div>
<h2>Meine Chats</h2>
<p class="card-hint">Ungelesene Unterhaltungen</p>
</div>
</div>
<div id="home-chat-summary" class="status">Noch nicht geladen.</div>
</div>
<div class="card card-green">
<div class="card-header row">
<div>
<h2>Dateien</h2>
<p class="card-hint">Für dich freigegeben</p>
</div>
</div>
<div id="home-files-summary" class="status">Noch nicht geladen.</div>
</div>
</section>
<!-- ---------- WAHL TAB ---------- -->
<section class="screen tab-screen" data-screen="wahl" hidden>
<div class="sh">Workshop-Wahl</div>
<div id="wahl-guest-view">
<div id="wahl-list"></div>
</div>
<div id="wahl-lt-view" hidden>
<div class="card">
<div class="card-header">
<h2>Wahl-Verwaltung (Leitungsteam)</h2>
<p class="card-hint">Wahl-ID eingeben um Teilnehmer/Ergebnisse zu sehen.</p>
</div>
<div class="form-grid form-inline">
<label>
<span>Wahl-ID</span>
<input id="lt-wahl-id" placeholder="Wahl-ID" />
</label>
<button id="lt-load-teilnehmer" class="btn btn-primary">Laden</button>
<button id="lt-run-zuteilung" class="btn btn-orange">Zuteilung starten</button>
</div>
<ul id="lt-teilnehmer-list" class="list">
<li class="list-empty">Noch nicht geladen.</li>
</ul>
<p id="wahl-lt-status" class="status"></p>
</div>
</div>
</section>
<!-- ---------- CHAT TAB ---------- -->
<section class="screen tab-screen" data-screen="chat" hidden>
<div class="sh">Chats</div>
<div id="gruppen-card" class="card card-blue" hidden>
<div class="card-header row">
<div>
<h2>Gruppenchat erstellen</h2>
<p class="card-hint">Team &amp; Konfis frei wählen, KC-weit.</p>
</div>
<button id="load-candidates" class="btn btn-ghost btn-sm">Kandidaten</button>
</div>
<div class="form-grid">
<label>
<span>Name der Gruppe</span>
<input id="gruppe-name" placeholder="z. B. Ausflugsplanung" />
</label>
</div>
<div class="two-col">
<div>
<p class="card-hint">Team-Mitglieder</p>
<ul id="candidate-users" class="list list-picker">
<li class="list-empty">Noch nicht geladen.</li>
</ul>
</div>
<div>
<p class="card-hint">Konfis</p>
<ul id="candidate-guests" class="list list-picker">
<li class="list-empty">Noch nicht geladen.</li>
</ul>
</div>
</div>
<button id="create-gruppe" class="btn btn-primary btn-full">Gruppenchat erstellen</button>
<p id="gruppe-status" class="status"></p>
</div>
<div class="card">
<div class="card-header row">
<h2 style="margin:0">Meine Chats</h2>
<button id="load-channels" class="btn btn-ghost btn-sm">Aktualisieren</button>
</div>
<ul id="channel-list" class="list list-channels">
<li class="list-empty">Noch keine Chats geladen.</li>
</ul>
</div>
<div id="chat-thread-card" class="card" hidden>
<div class="card-header row">
<h2 id="chat-thread-title" style="margin:0">Chat</h2>
<button id="manage-participants-btn" class="btn btn-ghost btn-sm" hidden>Teilnehmer</button>
</div>
<ul id="chat-log" class="list list-chat">
<li class="list-empty">Noch keine Nachrichten.</li>
</ul>
<div class="form-grid form-inline" style="margin-top:10px">
<label style="flex:3">
<span>Nachricht</span>
<input id="chat-message" placeholder="Nachricht schreiben…" />
</label>
<button id="send-message" class="btn btn-primary">Senden</button>
</div>
</div>
<div id="manage-card" class="card" hidden>
<div class="card-header">
<h2>Teilnehmer verwalten</h2>
<p class="card-hint">Für den aktuell geöffneten Gruppenchat.</p>
</div>
<div class="form-grid form-inline">
<label>
<span>User-ID</span>
<input id="manage-user-id" placeholder="optional" />
</label>
<label>
<span>Gast-ID</span>
<input id="manage-guest-id" placeholder="optional" />
</label>
</div>
<div class="row" style="margin-top:8px">
<button id="add-participant" class="btn btn-primary btn-sm">Hinzufügen</button>
<button id="remove-participant" class="btn btn-ghost btn-sm">Entfernen</button>
</div>
<p id="manage-status" class="status"></p>
</div>
</section>
<!-- ---------- FILES TAB ---------- -->
<section class="screen tab-screen" data-screen="files" hidden>
<div class="sh">Dateien</div>
<div class="card">
<div class="card-header row">
<h2 style="margin:0">Freigegebene Dokumente</h2>
<button id="load-files" class="btn btn-ghost btn-sm">Aktualisieren</button>
</div>
<ul id="file-list" class="list list-files">
<li class="list-empty">Noch keine Dateien geladen.</li>
</ul>
</div>
</section>
<!-- ---------- PROFILE TAB ---------- -->
<section class="screen tab-screen" data-screen="profile" hidden>
<div class="prof-hero">
<div class="prof-av" id="profile-av">?</div>
<div class="prof-name" id="profile-name">&ndash;</div>
<div class="prof-role" id="profile-role">&ndash;</div>
</div>
<div class="si" id="profile-kc-row">
<span>KC-ID</span>
<span id="profile-kc-id" class="chip chip-blue">&ndash;</span>
</div>
<div class="si" id="profile-server-row">
<span>Server</span>
<span class="chip chip-green">verbunden</span>
</div>
<button id="logout-btn" class="btn btn-danger btn-full" style="margin-top:16px">Abmelden</button>
</section>
</div>
<nav class="bnav">
<button class="nb on" data-screen="home">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 12l9-9 9 9M5 10v10a1 1 0 001 1h4v-6h4v6h4a1 1 0 001-1V10"/></svg>
Home
</button>
<button class="nb" data-screen="wahl">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Wahl
</button>
<button class="nb" data-screen="chat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
Chat
</button>
<button class="nb" data-screen="files">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/></svg>
Dateien
</button>
<button class="nb" data-screen="profile">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
Profil
</button>
</nav>
<div id="toast" class="toast" hidden></div>
</div>
</div>
<script src="app.js?v=2"></script>
</body>
</html>
+717
View File
@@ -0,0 +1,717 @@
:root {
--blue: #2F7CFF;
--blue-dark: #1F52A9;
--orange: #F17C20;
--orange-dark: #C8600F;
--green: #4EBA9A;
--green-dark: #2E7C64;
--grey: #69768B;
--dark: #2B2D42;
--dark-2: #1E2032;
--cream: #F6F7FB;
--card: #FFFFFF;
--card-border: #E7EAF3;
--text: #2B2D42;
--text-muted: #69768B;
--danger: #E24C4C;
--radius: 16px;
--sh: 0 10px 24px rgba(43, 45, 66, 0.08);
color-scheme: light;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* The `hidden` attribute must always win over any `display` set by a class
on the same element (e.g. `.form-grid { display: flex }` on a form that's
also `hidden`) — otherwise every login follow-up form renders at once. */
[hidden] {
display: none !important;
}
html, body, #app-root {
height: 100%;
}
body {
font-family: 'Mulish', 'Inter', system-ui, -apple-system, sans-serif;
background: #d9deee;
color: var(--text);
overflow: hidden;
overscroll-behavior: none;
-webkit-tap-highlight-color: transparent;
}
h1, h2, h3, .headline {
font-family: 'Poppins', 'Mulish', sans-serif;
}
/* ─── APP SHELL ─────────────────────────────────────────── */
.shell {
display: flex;
flex-direction: column;
min-height: 100dvh;
height: 100dvh;
max-width: 440px;
margin: 0 auto;
background: var(--cream);
box-shadow: 0 0 60px rgba(30, 32, 50, 0.25);
position: relative;
overflow: hidden;
}
.content {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding-bottom: env(safe-area-inset-bottom);
}
.screen {
padding: 16px 16px 24px;
}
/* ─── HEADER ────────────────────────────────────────────── */
.hdr {
background: linear-gradient(120deg, var(--dark), var(--dark-2));
border-bottom: 2px solid #33365a;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
position: sticky;
top: 0;
z-index: 50;
}
.hdr-logo {
font-family: 'Poppins', sans-serif;
font-size: 1.1rem;
font-weight: 800;
color: white;
letter-spacing: -0.02em;
}
.hdr-logo span { color: var(--orange); }
.hdr-sub {
font-size: 0.68rem;
color: #9aa3c4;
margin-top: 1px;
}
.hdr-space { flex: 1; }
.hdr-badge {
font-size: 0.68rem;
font-weight: 700;
padding: 0.3rem 0.65rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.18);
color: #d7dcf2;
background: rgba(255, 255, 255, 0.06);
white-space: nowrap;
}
.hdr-badge.online {
color: #7ce7c4;
border-color: rgba(78, 186, 154, 0.5);
background: rgba(78, 186, 154, 0.15);
}
.hdr-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--orange);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 0.85rem;
color: white;
cursor: pointer;
border: 2px solid var(--blue);
flex-shrink: 0;
}
/* ─── BOTTOM NAV ────────────────────────────────────────── */
.bnav {
background: linear-gradient(120deg, var(--dark), var(--dark-2));
display: flex;
border-top: 1px solid #33365a;
position: sticky;
bottom: 0;
z-index: 50;
}
.nb {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 9px 2px 11px;
border: none;
background: transparent;
color: #8b93b3;
font-family: 'Mulish', sans-serif;
font-size: 0.64rem;
font-weight: 700;
cursor: pointer;
gap: 3px;
transition: all 0.15s;
min-width: 0;
}
.nb.on {
color: var(--orange);
background: rgba(241, 124, 32, 0.1);
}
.nb svg { width: 20px; height: 20px; }
/* ─── HERO ──────────────────────────────────────────────── */
.hero {
background: linear-gradient(140deg, var(--dark) 0%, #262a4a 55%, var(--blue-dark) 100%);
border-bottom: 3px solid #262a4a;
padding: 22px 16px 18px;
margin: -16px -16px 16px;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: -44px;
right: -44px;
width: 190px;
height: 190px;
border-radius: 50%;
background: var(--orange);
opacity: 0.18;
}
.hero::after {
content: '';
position: absolute;
bottom: -70px;
left: -30px;
width: 160px;
height: 160px;
border-radius: 50%;
background: var(--green);
opacity: 0.14;
}
.hero-kicker {
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--orange);
margin-bottom: 6px;
position: relative;
}
.hero-title {
font-family: 'Poppins', sans-serif;
font-size: 1.5rem;
font-weight: 800;
color: white;
line-height: 1.18;
margin-bottom: 10px;
position: relative;
}
.hero-title span { color: var(--orange); }
.hero-chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
position: relative;
}
.hchip {
display: flex;
align-items: center;
gap: 5px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 20px;
padding: 4px 12px;
font-size: 0.73rem;
color: white;
}
/* ─── CARDS ─────────────────────────────────────────────── */
.card {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--sh);
padding: 16px;
margin-bottom: 12px;
}
.card-accent { border-left: 4px solid var(--orange); }
.card-orange { border-left: 4px solid var(--orange); }
.card-blue { border-left: 4px solid var(--blue); }
.card-green { border-left: 4px solid var(--green); }
.card-header {
margin-bottom: 1.1rem;
}
.card-header.row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.card-header h2 {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 700;
}
.card-hint {
margin: 0;
color: var(--text-muted);
font-size: 0.82rem;
}
.sh {
font-family: 'Poppins', sans-serif;
font-size: 0.92rem;
font-weight: 800;
color: var(--dark);
margin: 18px 0 10px;
}
.sh:first-child { margin-top: 0; }
/* ─── FORMS ─────────────────────────────────────────────── */
.form-grid {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.form-inline {
flex-direction: row;
align-items: flex-end;
flex-wrap: wrap;
}
.form-inline label { flex: 1; min-width: 140px; }
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.78rem;
color: var(--text-muted);
font-weight: 600;
}
input, select {
width: 100%;
padding: 0.65rem 0.75rem;
font-size: 0.9rem;
border-radius: 10px;
border: 1.5px solid var(--card-border);
background: #fbfcfe;
color: var(--text);
outline: none;
font-family: 'Mulish', sans-serif;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input::placeholder { color: #a3aac2; }
input:focus, select:focus {
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(47, 124, 255, 0.15);
}
/* ─── BUTTONS ───────────────────────────────────────────── */
.btn {
appearance: none;
border: none;
border-radius: 12px;
padding: 0.7rem 1.1rem;
font-size: 0.88rem;
font-weight: 700;
cursor: pointer;
font-family: 'Mulish', sans-serif;
transition: transform 0.08s ease, opacity 0.15s ease, box-shadow 0.15s ease;
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: linear-gradient(120deg, var(--blue), var(--blue-dark));
color: white;
box-shadow: 0 8px 20px rgba(47, 124, 255, 0.3);
}
.btn-primary:hover { opacity: 0.92; }
.btn-orange {
background: linear-gradient(120deg, var(--orange), var(--orange-dark));
color: white;
box-shadow: 0 8px 20px rgba(241, 124, 32, 0.3);
}
.btn-green {
background: linear-gradient(120deg, var(--green), var(--green-dark));
color: white;
}
.btn-ghost {
background: rgba(43, 45, 66, 0.05);
color: var(--text);
border: 1px solid var(--card-border);
padding: 0.5rem 0.85rem;
font-size: 0.8rem;
}
.btn-ghost:hover { background: rgba(43, 45, 66, 0.1); }
.btn-danger {
background: var(--danger);
color: white;
}
.btn-sm { padding: 0.4rem 0.7rem; font-size: 0.76rem; border-radius: 9px; }
.btn-full { width: 100%; }
/* ─── STATUS TEXT ───────────────────────────────────────── */
.status {
min-height: 1.1rem;
margin: 0.75rem 0 0;
font-size: 0.82rem;
color: var(--text-muted);
}
.status.status-ok { color: var(--green-dark); }
.status.status-error { color: var(--danger); }
/* ─── LISTS ─────────────────────────────────────────────── */
.list {
list-style: none;
border: 1px solid var(--card-border);
border-radius: 12px;
max-height: 240px;
overflow-y: auto;
background: #fbfcfe;
}
.list li {
padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--card-border);
font-size: 0.86rem;
}
.list li:last-child { border-bottom: none; }
.list-empty {
color: var(--text-muted);
font-style: italic;
}
.list-files li {
display: flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
}
.list-files li::before { content: "📄"; }
.list-files li:hover { background: rgba(47, 124, 255, 0.05); }
.list-chat {
background: #efeae2;
border-radius: 12px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 420px;
overflow-y: auto;
border: 1px solid var(--card-border);
}
.list-chat li {
display: flex;
flex-direction: column;
gap: 0.15rem;
max-width: 80%;
align-self: flex-start;
background: #ffffff;
border-radius: 0 12px 12px 12px;
padding: 8px 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
border-bottom: none;
}
.list-chat li.own {
align-self: flex-end;
background: #e7ffdb;
border-radius: 12px 0 12px 12px;
}
.list-chat li .chat-meta {
font-size: 0.68rem;
color: #667781;
text-align: right;
margin-top: 2px;
}
.list-picker li {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.list-picker li input { padding: 0; width: auto; }
.list-picker li.picked { background: rgba(47, 124, 255, 0.08); }
.list-channels li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
cursor: pointer;
}
.list-channels li:hover { background: rgba(47, 124, 255, 0.05); }
.list-channels li.active-channel { background: rgba(241, 124, 32, 0.08); border-left: 3px solid var(--orange); }
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 0.75rem 0 1rem;
}
@media (max-width: 380px) {
.two-col { grid-template-columns: 1fr; }
}
.chip {
font-size: 0.66rem;
font-weight: 700;
padding: 0.15rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--card-border);
color: var(--text-muted);
white-space: nowrap;
}
.chip-orange { background: rgba(241, 124, 32, 0.1); color: var(--orange-dark); border-color: transparent; }
.chip-blue { background: rgba(47, 124, 255, 0.1); color: var(--blue-dark); border-color: transparent; }
.chip-green { background: rgba(78, 186, 154, 0.15); color: var(--green-dark); border-color: transparent; }
/* ─── TABS ──────────────────────────────────────────────── */
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.tab-btn {
appearance: none;
border: 1px solid var(--card-border);
background: #fff;
color: var(--text-muted);
border-radius: 999px;
padding: 0.4rem 0.9rem;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
font-family: 'Mulish', sans-serif;
}
.tab-btn.active {
color: white;
background: linear-gradient(120deg, var(--blue), var(--blue-dark));
border-color: transparent;
}
/* ─── LOGIN ─────────────────────────────────────────────── */
.login-wrap {
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
background: linear-gradient(140deg, var(--dark) 0%, #262a4a 55%, var(--blue-dark) 100%);
}
.login-logo {
font-family: 'Poppins', sans-serif;
font-size: 2rem;
font-weight: 800;
color: white;
text-align: center;
}
.login-logo span { color: var(--orange); }
.login-sub {
color: #a3aad0;
font-size: 0.85rem;
text-align: center;
margin-bottom: 26px;
margin-top: 4px;
}
.login-card {
background: white;
border-radius: var(--radius);
padding: 22px;
width: 100%;
max-width: 360px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
/* ─── PROFILE ───────────────────────────────────────────── */
.prof-hero {
background: linear-gradient(120deg, var(--dark), var(--dark-2));
color: white;
border-radius: var(--radius);
padding: 22px;
text-align: center;
margin-bottom: 12px;
border: 1px solid #33365a;
}
.prof-av {
width: 62px;
height: 62px;
border-radius: 50%;
background: var(--orange);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
font-weight: 800;
margin: 0 auto 8px;
border: 3px solid var(--blue);
}
.prof-name {
font-family: 'Poppins', sans-serif;
font-size: 1rem;
font-weight: 800;
}
.prof-role {
font-size: 0.78rem;
color: #a3aad0;
margin-top: 2px;
}
.si {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 12px;
margin-bottom: 8px;
font-size: 0.86rem;
font-weight: 700;
box-shadow: 0 2px 6px rgba(43, 45, 66, 0.05);
}
/* ─── WORKSHOP PICK ─────────────────────────────────────── */
.workshop-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: var(--card);
border: 1.5px solid var(--card-border);
border-radius: 12px;
padding: 11px 13px;
margin-bottom: 8px;
cursor: grab;
}
.workshop-item.picked {
border-color: var(--blue);
background: rgba(47, 124, 255, 0.05);
}
.workshop-rank {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--blue);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 0.78rem;
flex-shrink: 0;
}
.workshop-rank.empty {
background: transparent;
border: 1.5px dashed var(--card-border);
color: transparent;
}
/* ─── EMPTY STATE ───────────────────────────────────────── */
.empty {
text-align: center;
padding: 30px 16px;
color: var(--text-muted);
}
.empty-icon { font-size: 2.2rem; margin-bottom: 8px; }
/* ─── TOAST ─────────────────────────────────────────────── */
.toast {
position: absolute;
bottom: 78px;
left: 50%;
transform: translateX(-50%);
background: var(--dark);
color: white;
padding: 10px 20px;
border-radius: 20px;
font-size: 0.82rem;
font-weight: 700;
z-index: 300;
white-space: nowrap;
animation: toastUp 0.3s;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
@keyframes toastUp {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
}
.dvd { height: 1px; background: var(--card-border); margin: 14px 0; }
.row { display: flex; align-items: center; gap: 8px; }
.spacer { flex: 1; }
@media (max-width: 380px) {
.screen { padding: 14px 12px 20px; }
}
+131 -28
View File
@@ -1,37 +1,140 @@
# Plan: KC-App Multi-Tenant Event-, Wahl- und Kommunikationsplattform # Plan: KC-App Multi-Tenant Event-, Wahl- und Kommunikationsplattform
Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/Teilnehmer/Zuteilungslogik) ablöst und um Rollen-/Rechteverwaltung via Authentik, gestaffelte Dateifreigabe, mehrstufigen Chat und eine Hybrid-Server-Architektur (online + lokal mit Sync) erweitert. Backend: **NestJS + PostgreSQL + Prisma**. Client: **Flutter** (eine Codebase für Mobile, Web, Desktop). Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/Teilnehmer/Zuteilungslogik) ablöst und um Rollen-/Rechteverwaltung via Authentik, gestaffelte Dateifreigabe, mehrstufigen Chat und eine Hybrid-Server-Architektur (online + lokal mit Sync) erweitert. Backend: **NestJS 10 + PostgreSQL + Prisma 5**. Client: **Flutter** (eine Codebase für Mobile, Web, Desktop) — bis Flutter verfügbar ist, liefert das Backend selbst einen minimalen Platzhalter-Web-Client aus.
**Domänenmodell** > Status: **Backend-Phasen 06 komplett** plus lokale Teamer-Accounts/Invites, Gemeinde-CRUD, Verantwortlichen-Selbstregistrierung (`onboarding/`), Authentik-JIT + LT-aus-`groups`, `mail/`-Modul. Erste Prisma-Migration vorhanden, Backend lief Ende-zu-Ende gegen lokales PostgreSQL 16 (`npm test`: 56). **Phase 7 (Flutter) begonnen**: `client/app/` — eine Codebase, Web-Target aktiv, Login/Home/Wahl/Dateien/Chat(read-only); `flutter build web` + `flutter test` grün. Offen: Mobile/Desktop-Targets, Authentik-Auth-Code-Flow im Client, WS-Chat-Senden, LT-Admin-Screens, Push, echte Authentik/Nextcloud-Infra.
- **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel.
- Rollen: **Leitungsteam** (global über alle KCs, Authentik-Gruppe) > **Gemeinde Verantwortliche** (pro Gemeinde/KC, Authentik, verwalten nur eigene Teamer) > **Gemeinde Teamer** (von Verantwortlichen angelegt, Authentik) > **Guest/Konfi** (optionaler lokaler Account auf dem Server, Vor-/Nachname Pflicht, temporär pro KC, kein Authentik).
- Einstieg über KC-Code/QR: gewährt Guest-Zugang oder Vorregistrierung als Verantwortlicher/Teamer einer Gemeinde.
- Wahlen werden vom LT pro KC angelegt (Name mit Datumsschlüssel + "Teil").
- Dateien: Sichtbarkeitsstufen alle / alle außer Konfis / nur LT.
- Chat: Gruppenchat pro Gemeinde, 1:1-DMs, LT-übergreifende Kanäle, Broadcast (read-only für Konfis), Push via FCM/APNs.
- Server grundsätzlich online (Cloud); zusätzlich lokaler On-Site-Server pro Event, wird von Clients automatisch bevorzugt wenn im lokalen Netz erreichbar, ist während des Events alleinige Quelle der Wahrheit, synchronisiert danach mit Cloud (keine echten Schreibkonflikte durch dieses Design).
**Phasen** (jede unabhängig verifizierbar, Reihenfolge = Abhängigkeit; Phase 6 kann parallel zu 25 starten, sobald API-Verträge aus Phase 0/1 stehen) ---
1. **Fundament** Monorepo-Skeleton (backend/, client/, shared contracts), Datenmodell (KC, Gemeinde, User, Membership, Wahl, Workshop, Teilnehmer/Zuteilung, ChatChannel/Message, File+Visibility, InviteCode/QR), Authentik-OIDC-Integration + Authentik-Admin-API-Client für Provisionierung. ## 1. Domänenmodell
2. **Multi-Tenancy & Auth** Invite/QR-Code-Fluss (KC-Key → Guest oder Vorregistrierung), Permission-Guards je Rolle/Scope, Guest-Login (Name-Pflicht, temporär).
3. **Workshop-Wahl-Engine** Portierung von Wahlen/Workshops/Teilnehmer/Zuteilungslogik (inkl. Force-Zuteilung, Kapazitätsprüfung, CSV-Export) aus dem WP-Plugin; LT-Verwaltung pro KC; Konfi-Formular + Ergebnisanzeige im Client. *depends on 12* - **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel. Felder: `name`, `inviteCode` (eindeutig, Basis für QR/Code-Einstieg), `isActive`.
4. **Dateifreigabe** Speicher-Abstraktion über Nextcloud/S3, Sichtbarkeitsstufen, LT-Upload-Verwaltung. *depends on 12, parallel mit 3* - **Gemeinde**: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (`kcId` + `name`, eindeutig pro KC).
5. **Kommunikation** Gruppenchat/DM/LT-Kanäle/Broadcast, WebSocket-Transport, Push-Integration. *depends on 12, parallel mit 34* - **Rollenmodell** (Enum `Role`, Authentik-gestützt):
6. **Hybrid Lokal/Cloud-Server & Sync** gleiche Backend-Software als Cloud- oder Vor-Ort-Instanz deploybar, Client-seitige Auto-Discovery des lokalen Servers, Append-only-Change-Log-Sync, lokaler Server = alleinige Quelle der Wahrheit während Live-Events. *depends on 15 stabil* - **Leitungsteam (LT)** global über alle KCs hinweg. Wird bei **jedem** Authentik-Login aus dem `groups`-Claim des Tokens abgeglichen (Gruppenname aus `AUTHENTIK_LEITUNGSTEAM_GROUP`) und als `User.isLeitungsteam` gespeichert; die Auth-Schicht synthetisiert daraus eine virtuelle globale LT-`Membership`. Kein `Membership`-Row nötig. Fällt die Gruppenmitgliedschaft weg, ist man beim nächsten Login kein LT mehr.
7. **Flutter-Clients** gemeinsame Codebase; Screens: Invite/Login, rollenspezifisches Dashboard, Wahl-Formular/Ergebnis, Dateien, Chat, Admin/Nutzerverwaltung. *iterativ parallel zu 36, sobald jeweilige API-Verträge stehen* - **Gemeinde Verantwortliche** pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer.
- **Gemeinde Teamer** von Verantwortlichen angelegt, ebenfalls Lokaler Account.
- **Guest/Konfi** optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events).
- **Membership**: verknüpft `User``Kc` (+ optional `Gemeinde`) ⇄ `Role`, mit `status` (`ACTIVE`/`PENDING`). Nur für `GEMEINDE_VERANTWORTLICHER`/`GEMEINDE_TEAMER` — LT läuft über `User.isLeitungsteam` (s. o.). `PENDING` (aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nur `ACTIVE`-Memberships.
- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab registrieren:
- **Gemeinde Verantwortliche/r**: `onboarding/`-Modul — mit Konfi-Castle-ID (Authentik) einloggen, KC-Code + bestehende Gemeinde wählen → `User` wird JIT angelegt, `Membership` als `PENDING`; LT genehmigt.
- **Gemeinde Teamer**: `teamer/`-Modul — von einer Verantwortliche/r direkt angelegt oder per Invite-Link/E-Mail-Invite selbst registriert (lokaler Account, sofort `ACTIVE`).
- **Wahl** (Workshop-Wahl): von LT pro KC angelegt; Name trägt `datumsSchluessel` + `teil` (Bewusste Vereinfachung ggü. Original-Plugin: dort gibt es mehrere "Phasen" *innerhalb* einer Wahl via `Teilnehmer.phase`; hier ist stattdessen **eine Wahl = ein Teil/Phase**, gemäß expliziter Nutzer-Klarstellung).
- **Workshop**: `kapazitaet`, `minTeilnehmer` (für Konsolidierung unterbesetzter Workshops).
- **Teilnehmer**: Guest übermittelt `prioritaeten` (geordnete Workshop-ID-Liste, max. 3 entspricht wunsch1..wunsch3 im Original).
- **ForceZuteilung**: manuelle LT-Override vor Algorithmus-Lauf, hat Vorrang.
- **Zuteilung**: Ergebnis pro Teilnehmer (`workshopId` nullable = unzugeteilt, `wunschRang`, `isForced`).
- **Datei-Sichtbarkeit** (Enum `FileVisibility`): `ALLE` / `ALLE_AUSSER_KONFIS` / `NUR_LT`. Dateien werden vom LT hochgeladen, teilbar je nach KC-übergreifend/eingeschränkt gemäß Sichtbarkeitsstufe.
- **Chat** (Enum `ChatChannelType`): `GEMEINDE_GRUPPE`, `DIREKT` (1:1, explizite `ChatParticipant`-Zuordnung), `LT_UEBERGREIFEND`, `BROADCAST` (Konfis nur lesend).
- **Sync-Infrastruktur**: `SyncLogEntry` (Append-only-Replikationslog: `model`, `recordId`, `operation`, `payload`, `originId`, autoincrement `sequence`) + `SyncCursor` (pro Peer: `lastPushedSequence`/`lastPulledSequence`).
Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.prisma).
---
## 2. Architekturentscheidungen
| Bereich | Entscheidung | Begründung |
|---|---|---|
| Backend | NestJS 10 + PostgreSQL + Prisma 5 | bestätigt vom Nutzer; Nest 10 statt CLI-Default (siehe unten) |
| Client | Flutter, eine Codebase Mobile/Web/Desktop — `client/app/`, aktuell nur Web-Target aktiviert | vom Nutzer delegiert; Flutter-SDK lokal via Homebrew installiert (nur Web-Toolchain, Android/iOS wegen Speicher weggelassen); `lib/` ist plattformneutral, weitere Targets per `flutter create --platforms=…` nachrüstbar |
| Web-Auslieferung | `ServeStaticModule` liefert primär den **Flutter-Web-Build** (`client/app/build/web`) auf `:3000` aus, mit SPA-Fallback (u. a. für den OIDC-Redirect `/v1/auth/callback`); fällt auf `client/web/` zurück, falls der Build fehlt. REST-API unter `/api/*`. | Ein Origin für App + API; der registrierte Authentik-Redirect zeigt auf `http://localhost:3000/v1/auth/callback` |
| Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend. Client macht **Authorization Code + PKCE (S256)** direkt gegen Authentik (`sso.konfi-castle.com`, Public Client, `oidc.dart`), Backend validiert nur das Access-Token. Issuer-Trailing-Slash wird normalisiert (beide `iss`-Schreibweisen akzeptiert). | Public Client kann kein Secret halten; PKCE genügt |
| Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik |
| Auth (Gemeinde Teamer) | Lokale Accounts: `User` mit `passwordHash`+`kcId`, `authentikSub` bleibt leer; eigenes JWT (`TEAM_JWT_SECRET`, Payload `typ:'team'`), Passwort-Login oder Invite-Redemption. Verantwortliche legen Teamer an (Direkt/Gruppen-Link/E-Mail-Invite) | Nutzervorgabe: Teamer laufen nicht über die Konfi-Castle-ID (Authentik), sondern werden pro KC lokal verwaltet (wie Guests, nur dauerhaft + mit Rolle) |
| Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` |
| E-Mail | Provider-Abstraktion (`MailProvider`), Default **log-only** (kein Versand), umschaltbar auf SMTP via `MAIL_PROVIDER=smtp` (`nodemailer`) | Spiegelt das Storage-Muster; E-Mail ist best-effort und darf den Invite-Flow nie blockieren |
| Push | Provider-Abstraktion (`PushProvider`), Default **log-only**, umschaltbar auf **FCM HTTP v1** via `PUSH_PROVIDER=fcm` (Service-Account-JWT → OAuth-Token, ohne extra Dependency). Client: Firebase-Compat-SDK im `index.html` + `firebase-messaging-sw.js`, Token via `POST /api/push/register` | Gleiches Muster wie Storage/E-Mail; Push ist best-effort und blockiert das Senden nie |
| Chat-Transport | Raw `ws`-Gateway (`@nestjs/platform-ws`) statt Socket.IO | Passt zum schlanken REST-Stack; Rollen/Sichtbarkeits-Logik zentral in `ChatService`, geteilt zwischen REST und WS |
| Sync-Richtung | **Lokaler Server initiiert immer** Push *und* Pull gegen die Cloud-URL | Cloud kann i. d. R. nicht in ein lokales Eventnetzwerk zurückwählen (NAT); lokaler Server kann aber ausgehend zur Cloud verbinden, wenn Internet verfügbar ist |
| Sync-Konflikte | Keine Konfliktauflösung nötig | Nutzer bestätigte explizit: lokaler Server ist während eines laufenden Events alleinige Quelle der Wahrheit |
| Rollen-Scope-Guard | `RolesGuard` behandelt `LEITUNGSTEAM`-Memberships als global (kcId-Check wird übersprungen) | Spiegelt die Anforderung "LT bleibt LT auf allen KCs" direkt in der Autorisierungslogik |
### Bekannte Einschränkungen / offene Punkte
- **Datei-Bytes werden nicht repliziert** nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist.
- **Push-Benachrichtigungen**: `push/`-Modul (FCM HTTP v1) + `DeviceToken`-Modell + `POST /api/push/register`/`unregister`; `ChatService.sendMessage` fächert die Nachricht best-effort an die Kanal-Zielgruppe. Default-Provider **log-only**. Für echten Versand fehlen: die Firebase-Web-Secrets (`apiKey`, `appId`, VAPID-Key) in `client/app/web/index.html` + `firebase-messaging-sw.js`, sowie backend-seitig ein Service-Account-JSON + `PUSH_PROVIDER=fcm`. APNs (natives iOS) ist nicht separat gebaut — läuft über FCM, sobald ein iOS-Target existiert.
- **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt.
- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` automatisch an (`AuthentikStrategy``resolveOrProvisionAuthentikUser`, JIT, race-sicher) **und** setzt `isLeitungsteam` aus dem `groups`-Claim. Voraussetzung: der Authentik-Provider muss den `groups`-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu `AUTHENTIK_LEITUNGSTEAM_GROUP` passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die `onboarding/`-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) Der Client-seitige PKCE-Flow (`oidc.dart`) ist gebaut, aber der volle Browser-Roundtrip ist noch nicht live getestet — dafür muss der genutzte Redirect (`http://localhost:3000/v1/auth/callback` bzw. die Prod-URL) am Authentik-Provider hinterlegt sein und ein Testaccount existieren.
- **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert.
- **E-Mail-Versand**: `mail/`-Modul mit `MailProvider`-Abstraktion. Persönliche `teamer-invites` (mit `email`) werden verschickt; Default-Provider ist **log-only** (schreibt nur ins Log), echter Versand erst mit `MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`. Onboarding-Benachrichtigungen an LT gibt es noch nicht.
---
## 3. Umgesetzte Backend-Module (Stand: alle Phasen abgeschlossen)
| Modul | Kernfunktion | Wichtige Endpunkte |
|---|---|---|
| `prisma/` | Geteilter `PrismaClient`-Provider | |
| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login + **LT-Abgleich** aus dem `groups`-Claim → `User.isLeitungsteam` → virtuelle globale LT-`Membership` (`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships; auch der Team-Token-Pfad geht durch `toAuthenticatedUser` (synthetische LT-`Membership` bei `isLeitungsteam`). LT-Admin-Controller (`kc`/`gemeinde`/`onboarding`/`sync`/`teamer`) akzeptieren `['authentik','team']`. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register`, `GET /api/auth/me` |
| `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` |
| `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` |
| `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) |
| `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite (persönliche Invites werden per `MailService` best-effort verschickt, `emailSent` im Response); nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` |
| `mail/` | Globale `MailProvider`-Abstraktion (log-only Default, SMTP via `MAIL_PROVIDER=smtp`); `MailService` baut die Invite-Mail inkl. Link aus `APP_BASE_URL` | |
| `push/` | Globale `PushProvider`-Abstraktion (log-only Default, FCM HTTP v1 via `PUSH_PROVIDER=fcm`); `PushService.notifyChannel` löst die Kanal-Zielgruppe auf → `DeviceToken`s → Versand, prunt ungültige Tokens; von `ChatService.sendMessage` best-effort ausgelöst | `POST /api/push/register`, `POST /api/push/unregister` |
| `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `GET /api/wahl/guest/overview` + `GET /api/wahl/guest/results` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` |
| `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` |
| `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` |
| `sync/` | Append-only Replikationslog + Peer-Sync (lokal ⇄ Cloud), `SyncSchedulerService` (alle 30s, wenn `SYNC_ENABLED=true`) | `POST /api/sync/ingest`, `GET /api/sync/export`, `POST /api/sync/trigger` (LT-only) |
| `common/` | `Role`-Enum, `@Roles()`-Decorator, `RolesGuard` (KC-scoped, LT global) | |
| Web-Client-Hosting | `ServeStaticModule` liefert `client/web/` aus; API unter globalem Prefix `/api` | `GET /` (index.html), `/app.js`, `/style.css` |
Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/README.md).
---
## 4. Tech-Stack-Stolpersteine (dokumentiert für Nachvollziehbarkeit)
- `npx @nestjs/cli new` mit aktuellen Defaults (Nest v12-Beta, ESM, Vitest, `@nestjs/observe`) löste einen reproduzierbaren npm-Arborist-Bug aus (`Cannot read properties of null (reading 'edgesOut')`). Workaround: `backend/package.json` wurde von Hand mit gepinnten, stabilen Versionen (Nest 10.x, Jest, CommonJS, TypeScript 5.x) erstellt statt über den CLI-Generator.
- Bei zusätzlichen offiziellen `@nestjs/*`-Paketen (`serve-static`, `schedule`) wurden die Peer-Dependencies vor der Installation geprüft (`npm view <pkg>@<version> peerDependencies`), da die jeweils neuesten Majors bereits Nest 11/12 voraussetzen und sonst mit `ERESOLVE` fehlschlagen. Gepinnt: `@nestjs/serve-static@4.0.2`, `@nestjs/schedule@4.1.1`.
- `multer` wurde von 1.x (bekannte CVEs) auf 2.x aktualisiert.
---
## 5. Phasenübersicht (Referenz, ursprüngliche Reihenfolge)
1. **Fundament** Monorepo-Skeleton, Datenmodell, Authentik-OIDC-Integration. ✅
2. **Multi-Tenancy & Auth** Invite/QR-Code-Fluss, Permission-Guards, Guest-Login. ✅
3. **Workshop-Wahl-Engine** Wahlen/Workshops/Zuteilungslogik/CSV-Export. ✅
4. **Dateifreigabe** Storage-Abstraktion, Sichtbarkeitsstufen. ✅
5. **Kommunikation** Chat (Gruppen/DM/LT/Broadcast), WebSocket. ✅ (Push-Integration noch offen)
6. **Hybrid Lokal/Cloud-Server & Sync** Replikationslog, Scheduler, Shared-Secret-Auth. ✅
7. **Flutter-Clients** gemeinsame Codebase (`client/app/`, Web-Target). ✅ Login (Guest / lokaler Teamer / Invite-Redemption, Token in `shared_preferences`, `GET /auth/me` für rollenabhängige Startseite), Guest-Wahl: **Wünsche** (`/wahl/guest/overview` → geordnete Auswahl → Absenden) + **Ergebnis** (`/wahl/guest/results`, PENDING/ASSIGNED/UNASSIGNED), Datei-Liste, **Chat** mit REST-Verlauf + Live-`chat:message` und Senden über das `/chat`-WebSocket (`chat_socket.dart`). 🔜 Mobile/Desktop-Targets, Authentik-Auth-Code-Flow, LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer/Onboarding-Freigaben).
---
## 6. Relevante Referenz
**Relevante Referenz**
- WP-Plugin als fachliche Vorlage für Zuteilungslogik: `includes/zuteilungslogik.php` (`kc_run_zuteilung`), Admin-Module `admin-wahlen.php`, `admin-workshops.php`, `admin-teilnehmer.php`, `admin-teamer.php`, `admin-zuteilungen.php`, Frontend-Shortcodes in `frontend-form.php`/`frontend-ergebnis.php` (git.konfi-castle.com/linus/Workshop-Wahlen). - WP-Plugin als fachliche Vorlage für Zuteilungslogik: `includes/zuteilungslogik.php` (`kc_run_zuteilung`), Admin-Module `admin-wahlen.php`, `admin-workshops.php`, `admin-teilnehmer.php`, `admin-teamer.php`, `admin-zuteilungen.php`, Frontend-Shortcodes in `frontend-form.php`/`frontend-ergebnis.php` (git.konfi-castle.com/linus/Workshop-Wahlen).
**Verifikation** ---
1. Nach Phase 1: Login-Flow testbar (LT via Authentik, Guest via KC-Code), Rechte-Guards per Integrationstests.
2. Nach Phase 3: Zuteilungslogik mit Testdaten gegen bekannte Ergebnisse aus dem alten Plugin validieren.
3. Nach Phase 6: Sync-Test — Änderungen am lokalen Server während simuliertem Offline-Zustand, danach Cloud-Abgleich prüfen.
4. Ende-zu-Ende: Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) manuell in allen Kernfeatures durchspielen.
**Entscheidungen** ## 7. Verifikation (durchgeführt je Phase)
- Backend: NestJS + PostgreSQL + Prisma (bestätigt).
- Client: Flutter, eine Codebase für Mobile/Web/Desktop (auf Wunsch des Nutzers von mir entschieden). 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud).
- Zuteilungen-Konflikte: kein echtes Konfliktmodell nötig, da lokaler Server während Events alleinige Quelle der Wahrheit ist. 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft).
- WP-Plugin wird vollständig abgelöst, nicht weiterverwendet (nur als fachliche Vorlage). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token).
3a. Gegen lokales **PostgreSQL 16** (Homebrew): `prisma migrate dev --name init` erzeugt/appliziert die erste Migration; Guest-Flow Ende-zu-Ende geprüft (`/auth/guest``/auth/me``/wahl/guest/overview``POST …/teilnehmer` → Re-Fetch zeigt `meinePrioritaeten`). Seed: `prisma/seed-dev.js`.
3b. Flutter-Client (`client/app/`): `flutter analyze` sauber, `flutter build web --release` erfolgreich, `flutter test` grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok.
3c. Guest-Ergebnis: `/wahl/guest/results` gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter `Zuteilung` → Workshop-Name + Wunschrang).
3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben `GEMEINDE_GRUPPE`-Kanal, `chat:send` → der andere empfängt `chat:message`). Dabei behoben: `ChatGateway` speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges `chat:join` mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise.
3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests` + PENDING-Anfrage → `approve``ACTIVE`; Wahl-Admin (`POST /api/wahl`, `POST /api/wahl/:id/workshops`, `POST .../zuteilung/run`, `GET .../zuteilung`, `PATCH /api/wahl/:id` `isOpen`, `GET /api/wahl/:id/teilnehmer`, CSV-Export); Teamer-Admin (`POST/GET /api/gemeinde/:id/teamer`, `POST /api/gemeinde/:id/teamer-invites`); `GET /api/onboarding/kc/:code`. (Datei-Upload `POST /api/files/:kcId` liefert 500 ohne konfiguriertes Nextcloud/S3 — erwartet.)
3f. **Echte Authentik verifiziert**: mit einem Password-Grant-Token für ein `KC-APP-LT`-Mitglied (`hermes`) gegen `https://sso.konfi-castle.com``GET /api/auth/me` liefert `isLeitungsteam: true` (JWKS-Prüfung, Trailing-Slash-Issuer, JIT-`User`, `groups`→LT), `POST /api/kc` → 201. Placeholder-E-Mail-Fallback, da `hermes` keine E-Mail hat. `AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT"`. **Noch offen:** nur der In-Browser-Redirect-Roundtrip (Authentik-Loginseite → Code-Tausch).
3g. Push: **echter FCM-Versand verifiziert** gegen `konfi-castle-app` (`PUSH_PROVIDER=fcm` + Service-Account-JSON): Chat-Nachricht → `PushService.notifyChannel``FcmPushProvider` → Service-Account-JWT → OAuth-Token (200) → `messages:send` erreicht die API; ein Bogus-Token bekommt `400 INVALID_ARGUMENT` und wird aus `device_token` geprunt. Client-Config komplett (`apiKey`/`appId`/`vapidKey` in `index.html` + `firebase-messaging-sw.js`). **Noch offen:** ein echter Browser muss einmal „Benachrichtigungen erlauben" und einen echten Token liefern.
4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests):
- `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl.
- `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login.
- `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, E-Mail-Versand nur bei persönlichem Invite + Best-effort bei Transport-Fehler, Löschung.
- `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject.
- `src/auth/provision-user.spec.ts`: JIT-`User`-Anlage aus Authentik-Claims, LT-Flag-Abgleich (rauf/runter) aus dem `groups`-Claim, virtuelle LT-`Membership` in `toAuthenticatedUser`, Race-Recovery (P2002 → Re-Read), Fehler-Weiterreichung.
5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud).
---
## 8. Nächste Schritte
1. Flutter-Client: ✅ Authentik-PKCE-Login (`oidc.dart`), LT-Admin (KC/Gemeinde), **Wahl-Verwaltung** (Wahlen/Workshops/Zuteilung + Ergebnis, öffnen/schließen, **Force-Zuteilung**, **CSV-Export** als Browser-Download), **Teamer-Verwaltung** (Konten + Invites), **Verantwortlichen-Selbstregistrierung**, **LT-Datei-Upload** (nativer `<input file>` + Sichtbarkeitsstufe). 🔜 Browser-OIDC-Roundtrip einmal live durchklicken; Mobile/Desktop-Targets (`flutter create --platforms=…`, Toolchains fehlen); Push.
2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), LT-Gruppe = `AUTHENTIK_LEITUNGSTEAM_GROUP`. (Ops/Config, Code ist fertig.)
3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT.
4. Push: ✅ konfiguriert & Backend-Versand verifiziert. Offen: echten Browser-Token einmal durchtesten (Notification-Permission → Zustellung).
5. Echte Authentik- + Nextcloud/S3-Infra anbinden und die in Abschnitt 7 offenen E2E-Verifikationsschritte durchführen (lokales Postgres + Migration sind erledigt).