Merge pull request 'feat: client monorepo (Flutter app) + web fallback redesign' (#1) from feat/backend-phases-0-6 into main

This commit was merged in pull request #1.
This commit is contained in:
2026-09-12 11:27:27 +00:00
65 changed files with 5773 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/**
+807
View File
@@ -0,0 +1,807 @@
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});
final String id;
final String type;
factory ChatChannel.fromJson(Map<String, dynamic> j) => ChatChannel(
id: j['id'] as String,
type: j['type'] as String? ?? '',
);
}
class ChatMessage {
ChatMessage({required this.body, required this.createdAt});
final String body;
final String createdAt;
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
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),
),
],
),
),
),
],
);
},
);
}
}
+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../chat_socket.dart';
import '../main.dart';
/// Chat: channel list (REST) + a per-channel view that loads history over
/// REST and then streams live messages over the `/chat` WebSocket gateway
/// (`chat:join` / `chat:send` / `chat:message`).
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',
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chat')),
body: FutureBuilder<List<ChatChannel>>(
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 channels = snap.data!;
if (channels.isEmpty) {
return const Center(child: Text('Keine Kanäle sichtbar.'));
}
return ListView(
children: [
for (final c in channels)
ListTile(
leading: const Icon(Icons.tag),
title: Text(_typeLabels[c.type] ?? c.type),
subtitle: Text(c.id),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _ChannelMessages(
channelId: c.id,
title: _typeLabels[c.type] ?? c.type,
),
),
),
),
],
);
},
),
);
}
}
class _ChannelMessages extends StatefulWidget {
const _ChannelMessages({required this.channelId, required this.title});
final String channelId;
final String title;
@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…';
@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.jumpTo(_scroll.position.maxScrollExtent);
}
});
}
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();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(18),
child: Text('WebSocket: $_wsStatus', style: const TextStyle(fontSize: 11)),
),
),
body: Column(
children: [
Expanded(child: _body(context)),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _composer,
onSubmitted: (_) => _send(),
decoration: const InputDecoration(
hintText: 'Nachricht…',
border: OutlineInputBorder(),
isDense: true,
),
),
),
IconButton(icon: const Icon(Icons.send), onPressed: _send),
],
),
),
),
],
),
);
}
Widget _body(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_loadError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(_loadError!, textAlign: TextAlign.center),
),
);
}
if (_messages.isEmpty) {
return const Center(child: Text('Noch keine Nachrichten.'));
}
return ListView.builder(
controller: _scroll,
padding: const EdgeInsets.all(12),
itemCount: _messages.length,
itemBuilder: (context, i) {
final m = _messages[i];
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(m.body),
const SizedBox(height: 2),
Text(m.createdAt, style: Theme.of(context).textTheme.labelSmall),
],
),
),
);
},
);
}
}
@@ -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)),
],
),
),
),
);
}
}
+369
View File
@@ -0,0 +1,369 @@
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();
return c.length > 2 && c.toUpperCase().endsWith('LT');
}
/// 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();
return _isLeitungsteamCode ? 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: hänge "LT" an den '
'Code an (z. B. "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"
}
]
}
+481
View File
@@ -0,0 +1,481 @@
const state = {
token: null,
kcId: null,
whoami: null,
socket: null,
activeChannelId: null,
pickedUsers: new Set(),
pickedGuests: new Set(),
};
const $ = (id) => document.getElementById(id);
function decodeJwtPayload(token) {
try {
const [, payload] = token.split('.');
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return null;
}
}
function setStatus(el, message, kind) {
el.textContent = message;
el.classList.remove('status-ok', 'status-error');
if (kind === 'ok') el.classList.add('status-ok');
if (kind === 'error') el.classList.add('status-error');
}
function setConnBadge(online) {
const badge = $('conn-badge');
if (!badge) return;
badge.textContent = online ? 'angemeldet' : 'nicht angemeldet';
badge.classList.toggle('badge-online', online);
badge.classList.toggle('badge-muted', !online);
}
function clearList(listEl, emptyMessage) {
listEl.innerHTML = '';
if (emptyMessage) {
const li = document.createElement('li');
li.className = 'list-empty';
li.textContent = emptyMessage;
listEl.appendChild(li);
}
}
async function apiFetch(path, options = {}) {
const headers = Object.assign({}, options.headers);
if (state.token) headers.Authorization = `Bearer ${state.token}`;
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
return fetch(`/api${path}`, Object.assign({}, options, { headers }));
}
/// --- Tabs (Gast / Team-Login) ---
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach((p) => (p.hidden = true));
btn.classList.add('active');
const panel = document.querySelector(`[data-panel="${btn.dataset.tab}"]`);
if (panel) panel.hidden = false;
});
});
/// --- Login: Gast/Konfi ---
$('guest-form').addEventListener('submit', async (event) => {
event.preventDefault();
const inviteCode = $('invite-code').value;
const firstName = $('first-name').value;
const lastName = $('last-name').value;
setStatus($('login-status'), 'Wird geprüft…');
let res;
try {
res = await apiFetch('/auth/guest', {
method: 'POST',
body: JSON.stringify({ inviteCode, firstName, lastName }),
});
} catch (err) {
setStatus($('login-status'), 'Netzwerkfehler. Bitte erneut versuchen.', 'error');
return;
}
if (!res.ok) {
setStatus($('login-status'), `Fehler: ${res.status}`, 'error');
return;
}
const { accessToken } = await res.json();
await onLoggedIn(accessToken);
});
/// --- Login: Team (Gemeinde-Name + Passwort) ---
$('team-form').addEventListener('submit', async (event) => {
event.preventDefault();
const gemeindeName = $('team-gemeinde').value;
const password = $('team-password').value;
setStatus($('login-status'), 'Wird geprüft…');
let res;
try {
res = await apiFetch('/auth/team-login', {
method: 'POST',
body: JSON.stringify({ gemeindeName, password }),
});
} catch (err) {
setStatus($('login-status'), 'Netzwerkfehler. Bitte erneut versuchen.', 'error');
return;
}
if (!res.ok) {
setStatus($('login-status'), `Fehler: ${res.status}`, 'error');
return;
}
const { accessToken } = await res.json();
await onLoggedIn(accessToken);
});
async function onLoggedIn(accessToken) {
state.token = accessToken;
const claims = decodeJwtPayload(accessToken);
state.kcId = claims?.kcId ?? null;
// Fetch identity/role from the API so the UI can show role-appropriate
// sections (Gruppenchat creation is LT/Verantwortliche/r only).
let me = null;
try {
const res = await apiFetch('/auth/me');
if (res.ok) me = await res.json();
} catch {
// best-effort
}
state.whoami = me;
setStatus($('login-status'), 'Angemeldet.', 'ok');
setConnBadge(true);
$('login-section').hidden = true;
$('app-section').hidden = false;
renderWhoAmI();
}
function renderWhoAmI() {
const card = $('whoami-card');
const text = $('whoami-text');
const gruppenCard = $('gruppen-card');
const manageCard = $('manage-card');
if (!state.whoami) {
card.hidden = true;
gruppenCard.hidden = true;
manageCard.hidden = true;
return;
}
card.hidden = false;
if (state.whoami.kind === 'guest') {
text.textContent = `Konfi/Gast (KC ${state.whoami.kcId})`;
gruppenCard.hidden = true;
manageCard.hidden = true;
} else {
const roles = (state.whoami.memberships || []).map((m) => m.role).join(', ') || 'keine Rolle';
text.textContent = `${state.whoami.email}${roles}`;
const canManageGruppen =
state.whoami.isLeitungsteam ||
(state.whoami.memberships || []).some((m) => m.role === 'GEMEINDE_VERANTWORTLICHER');
gruppenCard.hidden = !canManageGruppen;
manageCard.hidden = !canManageGruppen;
if (state.kcId) $('gruppe-kcid').value = state.kcId;
}
}
$('logout-btn').addEventListener('click', () => {
if (state.socket) state.socket.close();
state.token = null;
state.kcId = null;
state.whoami = null;
state.activeChannelId = null;
setConnBadge(false);
$('app-section').hidden = true;
$('login-section').hidden = false;
setStatus($('login-status'), '');
});
/// --- Gruppenchat: Kandidaten laden + auswählen ---
$('load-candidates').addEventListener('click', async () => {
const kcId = $('gruppe-kcid').value.trim();
if (!kcId) {
setStatus($('gruppe-status'), 'Bitte KC-ID angeben.', 'error');
return;
}
let res;
try {
res = await apiFetch(`/chat/${kcId}/gruppen/participant-candidates`);
} catch {
setStatus($('gruppe-status'), 'Netzwerkfehler beim Laden.', 'error');
return;
}
if (!res.ok) {
setStatus($('gruppe-status'), `Fehler: ${res.status}`, 'error');
return;
}
const { users, guests } = await res.json();
state.pickedUsers = new Set();
state.pickedGuests = new Set();
renderCandidateList($('candidate-users'), users, 'userId', (u) => `${u.firstName} ${u.lastName} (${u.role})`, state.pickedUsers);
renderCandidateList($('candidate-guests'), guests, 'guestId', (g) => `${g.firstName} ${g.lastName}`, state.pickedGuests);
setStatus($('gruppe-status'), `${users.length} Team-Mitglieder, ${guests.length} Konfis geladen.`, 'ok');
});
function renderCandidateList(listEl, items, idKey, labelFn, pickedSet) {
clearList(listEl);
if (!items.length) {
clearList(listEl, 'Keine Kandidaten gefunden.');
return;
}
listEl.innerHTML = '';
for (const item of items) {
const id = item[idKey];
const li = document.createElement('li');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.addEventListener('change', () => {
if (checkbox.checked) pickedSet.add(id);
else pickedSet.delete(id);
li.classList.toggle('picked', checkbox.checked);
});
const label = document.createElement('span');
label.textContent = labelFn(item);
li.appendChild(checkbox);
li.appendChild(label);
li.addEventListener('click', (e) => {
if (e.target === checkbox) return;
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change'));
});
listEl.appendChild(li);
}
}
$('create-gruppe').addEventListener('click', async () => {
const kcId = $('gruppe-kcid').value.trim();
const name = $('gruppe-name').value.trim();
if (!kcId) {
setStatus($('gruppe-status'), 'Bitte KC-ID angeben.', 'error');
return;
}
setStatus($('gruppe-status'), 'Wird erstellt…');
let res;
try {
res = await apiFetch(`/chat/${kcId}/gruppen`, {
method: 'POST',
body: JSON.stringify({
type: 'GRUPPE',
name,
participantUserIds: [...state.pickedUsers],
participantGuestIds: [...state.pickedGuests],
}),
});
} catch {
setStatus($('gruppe-status'), 'Netzwerkfehler.', 'error');
return;
}
if (!res.ok) {
setStatus($('gruppe-status'), `Fehler: ${res.status}`, 'error');
return;
}
const channel = await res.json();
setStatus($('gruppe-status'), `Erstellt: ${channel.id}`, 'ok');
$('manage-channel-id').value = channel.id;
$('channel-id').value = channel.id;
});
/// --- Teilnehmerverwaltung ---
$('add-participant').addEventListener('click', () => manageParticipant('add'));
$('remove-participant').addEventListener('click', () => manageParticipant('remove'));
async function manageParticipant(action) {
const channelId = $('manage-channel-id').value.trim();
const userId = $('manage-user-id').value.trim();
const guestId = $('manage-guest-id').value.trim();
if (!channelId || (!userId && !guestId)) {
setStatus($('manage-status'), 'Channel-ID und User- oder Gast-ID angeben.', 'error');
return;
}
setStatus($('manage-status'), 'Wird gesendet…');
const method = action === 'add' ? 'POST' : 'DELETE';
let res;
try {
res = await apiFetch(`/chat/gruppen/${channelId}/participants`, {
method,
body: JSON.stringify({ userId: userId || undefined, guestId: guestId || undefined }),
});
} catch {
setStatus($('manage-status'), 'Netzwerkfehler.', 'error');
return;
}
if (!res.ok) {
setStatus($('manage-status'), `Fehler: ${res.status}`, 'error');
return;
}
setStatus($('manage-status'), action === 'add' ? 'Hinzugefügt.' : 'Entfernt.', 'ok');
}
/// --- Workshop-Wahl ---
$('submit-wahl').addEventListener('click', async () => {
const wahlId = $('wahl-id').value;
const prioritaeten = $('prioritaeten')
.value.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (!wahlId || prioritaeten.length === 0) {
setStatus($('wahl-status'), 'Bitte Wahl-ID und mindestens eine Priorität angeben.', 'error');
return;
}
setStatus($('wahl-status'), 'Wird gesendet…');
let res;
try {
res = await apiFetch(`/wahl/${wahlId}/teilnehmer`, {
method: 'POST',
body: JSON.stringify({ prioritaeten }),
});
} catch (err) {
setStatus($('wahl-status'), 'Netzwerkfehler. Bitte erneut versuchen.', 'error');
return;
}
if (res.ok) {
setStatus($('wahl-status'), 'Gespeichert.', 'ok');
} else {
setStatus($('wahl-status'), `Fehler: ${res.status}`, 'error');
}
});
/// --- Dateien ---
$('load-files').addEventListener('click', async () => {
if (!state.kcId) return;
let res;
try {
res = await apiFetch(`/files/${state.kcId}`);
} catch (err) {
clearList($('file-list'), 'Fehler beim Laden der Dateien.');
return;
}
const files = res.ok ? await res.json() : [];
const list = $('file-list');
if (!files.length) {
clearList(list, 'Keine Dateien verfügbar.');
return;
}
list.innerHTML = '';
for (const file of files) {
const li = document.createElement('li');
li.textContent = file.filename;
list.appendChild(li);
}
});
/// --- Chat: eigene Channels auflisten ---
$('load-channels').addEventListener('click', async () => {
if (!state.kcId) {
setStatus($('login-status'), '');
return;
}
let res;
try {
res = await apiFetch(`/chat/${state.kcId}/channels`);
} catch {
clearList($('channel-list'), 'Fehler beim Laden der Chats.');
return;
}
const channels = res.ok ? await res.json() : [];
const list = $('channel-list');
if (!channels.length) {
clearList(list, 'Keine Chats verfügbar.');
return;
}
list.innerHTML = '';
for (const channel of channels) {
const li = document.createElement('li');
const label = document.createElement('span');
label.textContent = channel.name || channel.type;
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = channel.type;
li.appendChild(label);
li.appendChild(chip);
li.addEventListener('click', () => {
$('channel-id').value = channel.id;
joinChannel(channel.id);
});
list.appendChild(li);
}
});
/// --- Chat: beitreten + live mitlesen/schreiben ---
$('join-channel').addEventListener('click', () => {
const channelId = $('channel-id').value;
if (!channelId || !state.token) return;
joinChannel(channelId);
});
function joinChannel(channelId) {
if (state.socket) state.socket.close();
state.activeChannelId = channelId;
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${protocol}://${location.host}/chat?token=${state.token}`);
state.socket = socket;
const chatLog = $('chat-log');
clearList(chatLog, 'Verbindung wird hergestellt…');
socket.addEventListener('open', () => {
socket.send(JSON.stringify({ event: 'chat:join', data: { channelId } }));
clearList(chatLog, 'Verbunden. Warte auf Nachrichten…');
});
socket.addEventListener('message', (event) => {
const { event: name, data } = JSON.parse(event.data);
if (name === 'chat:participants-changed') {
if (data.channelId === state.activeChannelId) {
setStatus($('manage-status'), 'Teilnehmerliste wurde aktualisiert.', 'ok');
}
return;
}
if (name !== 'chat:message') return;
const emptyPlaceholder = chatLog.querySelector('.list-empty');
if (emptyPlaceholder) emptyPlaceholder.remove();
const li = document.createElement('li');
const body = document.createElement('span');
body.className = 'chat-body';
body.textContent = data.body;
li.appendChild(body);
chatLog.appendChild(li);
chatLog.scrollTop = chatLog.scrollHeight;
});
socket.addEventListener('close', () => {
setConnBadge(!!state.token);
});
}
$('send-message').addEventListener('click', () => {
const input = $('chat-message');
const body = input.value.trim();
if (!body || !state.socket || !state.activeChannelId) return;
state.socket.send(
JSON.stringify({ event: 'chat:send', data: { channelId: state.activeChannelId, body } }),
);
input.value = '';
});
$('chat-message').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('send-message').click();
});
+217
View File
@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KC-App</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="bg-decor" aria-hidden="true"></div>
<header class="topbar">
<div class="brand">
<span class="brand-mark">KC</span>
<div>
<h1>KC-App</h1>
<p class="subtitle">Web-Client &middot; Konfi-Castle Events</p>
</div>
</div>
<span id="conn-badge" class="badge badge-muted">nicht angemeldet</span>
</header>
<main>
<section id="login-section" class="card">
<div class="card-header">
<h2>Anmelden</h2>
<p class="card-hint">Als Gast/Konfi mit Einladungscode oder als Team-Mitglied mit Passwort.</p>
</div>
<div class="tabs">
<button type="button" class="tab-btn active" data-tab="guest-tab">Gast / Konfi</button>
<button type="button" class="tab-btn" data-tab="team-tab">Team-Login</button>
</div>
<form id="guest-form" class="form-grid tab-panel" data-panel="guest-tab">
<label>
<span>Einladungscode</span>
<input id="invite-code" name="inviteCode" placeholder="z. B. AB12-CD34" required />
</label>
<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-primary">
<span>Beitreten</span>
</button>
</form>
<form id="team-form" class="form-grid tab-panel" data-panel="team-tab" hidden>
<label>
<span>Gemeinde-Name</span>
<input id="team-gemeinde" name="gemeindeName" placeholder="z. B. Mustergemeinde" />
</label>
<label>
<span>Passwort</span>
<input id="team-password" name="password" type="password" required />
</label>
<button type="submit" class="btn btn-primary">
<span>Anmelden</span>
</button>
</form>
<p id="login-status" class="status"></p>
</section>
<section id="app-section" class="stack" hidden>
<div class="card" id="whoami-card" hidden>
<div class="card-header row">
<div>
<h2>Angemeldet als</h2>
<p class="card-hint" id="whoami-text">&ndash;</p>
</div>
<button id="logout-btn" class="btn btn-ghost">Abmelden</button>
</div>
</div>
<div class="card" id="gruppen-card" hidden>
<div class="card-header row">
<div>
<h2>Gruppenchat erstellen</h2>
<p class="card-hint">Team-Mitglieder und Konfis frei auswählen (KC-weit, egal welche Gemeinde).</p>
</div>
<button id="load-candidates" class="btn btn-ghost">Kandidaten laden</button>
</div>
<div class="form-grid">
<label>
<span>KC-ID</span>
<input id="gruppe-kcid" placeholder="KC-ID" />
</label>
<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">Gruppenchat erstellen</button>
<p id="gruppe-status" class="status"></p>
</div>
<div class="card" id="manage-card" hidden>
<div class="card-header row">
<div>
<h2>Teilnehmer verwalten</h2>
<p class="card-hint">Für einen bestehenden Gruppenchat Teilnehmer hinzufügen/entfernen.</p>
</div>
</div>
<div class="form-grid form-inline">
<label>
<span>Channel-ID</span>
<input id="manage-channel-id" placeholder="Channel-ID des Gruppenchats" />
</label>
</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>
<button id="add-participant" class="btn btn-primary">Hinzufügen</button>
<button id="remove-participant" class="btn btn-ghost">Entfernen</button>
</div>
<p id="manage-status" class="status"></p>
</div>
<div class="card">
<div class="card-header">
<h2>Workshop-Wahl</h2>
<p class="card-hint">Wünsche in Reihenfolge deiner Priorität eintragen.</p>
</div>
<div class="form-grid">
<label>
<span>Wahl-ID</span>
<input id="wahl-id" placeholder="Wahl-ID" />
</label>
<label>
<span>Priorit&auml;ten</span>
<input id="prioritaeten" placeholder="Workshop-IDs, mit Komma getrennt" />
</label>
<button id="submit-wahl" class="btn btn-primary">
<span>Absenden</span>
</button>
</div>
<p id="wahl-status" class="status"></p>
</div>
<div class="card">
<div class="card-header row">
<div>
<h2>Dateien</h2>
<p class="card-hint">Für dich freigegebene Dokumente.</p>
</div>
<button id="load-files" class="btn btn-ghost">Aktualisieren</button>
</div>
<ul id="file-list" class="list list-files">
<li class="list-empty">Noch keine Dateien geladen.</li>
</ul>
</div>
<div class="card">
<div class="card-header row">
<div>
<h2>Chat</h2>
<p class="card-hint">Channel-ID eingeben und live mitlesen/schreiben.</p>
</div>
<button id="load-channels" class="btn btn-ghost">Meine Chats laden</button>
</div>
<ul id="channel-list" class="list list-channels">
<li class="list-empty">Noch keine Chats geladen.</li>
</ul>
<div class="form-grid form-inline">
<label>
<span>Channel-ID</span>
<input id="channel-id" placeholder="Channel-ID" />
</label>
<button id="join-channel" class="btn btn-primary">Beitreten</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">
<label>
<span>Nachricht</span>
<input id="chat-message" placeholder="Nachricht schreiben…" />
</label>
<button id="send-message" class="btn btn-primary">Senden</button>
</div>
</div>
</section>
</main>
<footer>
<p>KC-App &middot; Zero-Dependency Web-Fallback</p>
</footer>
<script src="app.js"></script>
</body>
</html>
+388
View File
@@ -0,0 +1,388 @@
:root {
--bg: #0f1220;
--bg-soft: #171b2e;
--card: #1c2138;
--card-border: #2a3050;
--text: #eef0fb;
--text-muted: #9aa1c4;
--accent: #6c8cff;
--accent-strong: #8f6cff;
--accent-text: #ffffff;
--success: #4ade80;
--danger: #f87171;
--radius: 14px;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
.bg-decor {
position: fixed;
inset: 0;
z-index: -1;
background:
radial-gradient(600px circle at 15% -10%, rgba(108, 140, 255, 0.25), transparent 60%),
radial-gradient(500px circle at 100% 10%, rgba(143, 108, 255, 0.18), transparent 55%),
var(--bg);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
max-width: 640px;
margin: 0 auto;
padding: 2rem 1.25rem 1rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-mark {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: white;
font-weight: 700;
font-size: 0.95rem;
letter-spacing: 0.02em;
box-shadow: 0 8px 20px rgba(108, 140, 255, 0.35);
}
.brand h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.subtitle {
margin: 0.1rem 0 0;
color: var(--text-muted);
font-size: 0.85rem;
}
.badge {
font-size: 0.75rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--card-border);
white-space: nowrap;
}
.badge-muted {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.03);
}
.badge-online {
color: var(--success);
background: rgba(74, 222, 128, 0.12);
border-color: rgba(74, 222, 128, 0.35);
}
main {
max-width: 640px;
margin: 0 auto;
padding: 0.5rem 1.25rem 3rem;
}
.stack {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.card {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1.25rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.stack .card {
margin-bottom: 0;
}
.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: 1.05rem;
font-weight: 650;
}
.card-hint {
margin: 0;
color: var(--text-muted);
font-size: 0.85rem;
}
.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: 160px;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.82rem;
color: var(--text-muted);
}
input {
padding: 0.65rem 0.75rem;
font-size: 0.95rem;
border-radius: 10px;
border: 1px solid var(--card-border);
background: var(--bg-soft);
color: var(--text);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input::placeholder {
color: #6b7194;
}
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(108, 140, 255, 0.2);
}
.btn {
appearance: none;
border: none;
border-radius: 10px;
padding: 0.7rem 1.1rem;
font-size: 0.92rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.08s ease, opacity 0.15s ease, box-shadow 0.15s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: var(--accent-text);
box-shadow: 0 8px 20px rgba(108, 140, 255, 0.3);
}
.btn-primary:hover {
opacity: 0.92;
}
.btn-ghost {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border: 1px solid var(--card-border);
padding: 0.5rem 0.85rem;
font-size: 0.82rem;
}
.btn-ghost:hover {
background: rgba(255, 255, 255, 0.08);
}
.status {
min-height: 1.1rem;
margin: 0.75rem 0 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.status.status-ok {
color: var(--success);
}
.status.status-error {
color: var(--danger);
}
.list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--card-border);
border-radius: 10px;
max-height: 220px;
overflow-y: auto;
background: var(--bg-soft);
}
.list li {
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--card-border);
font-size: 0.88rem;
}
.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.5rem;
}
.list-files li::before {
content: "📄";
}
.list-chat li {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.list-chat li .chat-body {
color: var(--text);
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab-btn {
appearance: none;
border: 1px solid var(--card-border);
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
border-radius: 999px;
padding: 0.4rem 0.9rem;
font-size: 0.82rem;
cursor: pointer;
}
.tab-btn.active {
color: var(--accent-text);
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
border-color: transparent;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 0.75rem 0 1rem;
}
@media (max-width: 480px) {
.two-col {
grid-template-columns: 1fr;
}
}
.list-picker {
max-height: 180px;
}
.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(108, 140, 255, 0.12);
}
.list-channels li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
cursor: pointer;
}
.list-channels li:hover {
background: rgba(255, 255, 255, 0.04);
}
.chip {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--card-border);
color: var(--text-muted);
white-space: nowrap;
}
footer {
max-width: 640px;
margin: 0 auto;
padding: 1rem 1.25rem 2rem;
text-align: center;
color: var(--text-muted);
font-size: 0.75rem;
}
@media (max-width: 480px) {
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.card {
padding: 1.1rem;
}
}
+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).