diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/Dockerfile b/Dockerfile index c90356a..8cd3470 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,11 @@ # --- 1. Backend build --------------------------------------------------------- FROM node:20-bookworm-slim AS api-build WORKDIR /src +# Prisma detects the OpenSSL version at `generate` time to pick the matching +# query engine binary; without OpenSSL present here it silently defaults to +# openssl-1.1.x, which then fails to load in the runtime stage (openssl 3.0.x). +RUN apt-get update && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* COPY backend/package.json backend/package-lock.json ./ RUN npm ci COPY backend/ ./ diff --git a/backend/prisma/migrations/20260911100000_wahl_phasen_and_verantwortliche_invites/migration.sql b/backend/prisma/migrations/20260911100000_wahl_phasen_and_verantwortliche_invites/migration.sql new file mode 100644 index 0000000..09fd11c --- /dev/null +++ b/backend/prisma/migrations/20260911100000_wahl_phasen_and_verantwortliche_invites/migration.sql @@ -0,0 +1,43 @@ +-- DropIndex +DROP INDEX "Teilnehmer_wahlId_guestAccountId_key"; + +-- AlterTable +ALTER TABLE "Wahl" ADD COLUMN "beschreibung" TEXT, +ADD COLUMN "phasenAnzahl" INTEGER NOT NULL DEFAULT 1; + +-- AlterTable +ALTER TABLE "Workshop" ADD COLUMN "beschreibung" TEXT, +ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1; + +-- AlterTable +ALTER TABLE "Teilnehmer" ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1; + +-- CreateTable +CREATE TABLE "VerantwortlicheInvite" ( + "id" TEXT NOT NULL, + "kcId" TEXT NOT NULL, + "gemeindeId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "email" TEXT, + "maxUses" INTEGER, + "usedCount" INTEGER NOT NULL DEFAULT 0, + "expiresAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdByUserId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "VerantwortlicheInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "VerantwortlicheInvite_token_key" ON "VerantwortlicheInvite"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "Teilnehmer_wahlId_guestAccountId_phase_key" ON "Teilnehmer"("wahlId", "guestAccountId", "phase"); + +-- AddForeignKey +ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 782c69f..ee3787d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -24,6 +24,7 @@ model Kc { guests GuestAccount[] localUsers User[] teamerInvites TeamerInvite[] + verantwortlicheInvites VerantwortlicheInvite[] } /// A local congregation/community participating in one Kc. @@ -37,6 +38,7 @@ model Gemeinde { memberships Membership[] guests GuestAccount[] teamerInvites TeamerInvite[] + verantwortlicheInvites VerantwortlicheInvite[] @@unique([kcId, name]) } @@ -152,13 +154,42 @@ model TeamerInvite { gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) } +/// Invitation issued by a Leitungsteam member so a person can register as +/// Gemeinde Verantwortliche/r for a specific Gemeinde via their +/// Konfi-Castle-ID (Authentik) — skips the self-registration approval step +/// since a Leitungsteam member is vouching for them directly. A group link +/// leaves `email` null and may be redeemed up to `maxUses` times (null = +/// unlimited); a personal invite pins `email` and defaults to a single use. +model VerantwortlicheInvite { + id String @id @default(cuid()) + kcId String + gemeindeId String + token String @unique + email String? + maxUses Int? + usedCount Int @default(0) + expiresAt DateTime? + revokedAt DateTime? + createdByUserId String + createdAt DateTime @default(now()) + + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) +} + /// A workshop election, scoped to a Kc; name carries a date key + "Teil". +/// `phasenAnzahl` mirrors the WP plugin's `anzahl_einheiten`: a Wahl can run +/// several independent phases (e.g. morning/afternoon), each with its own +/// workshops, its own guest submission, and its own assignment run — a guest +/// submits once per phase, not once for the whole Wahl. model Wahl { id String @id @default(cuid()) kcId String name String datumsSchluessel String teil String + beschreibung String? + phasenAnzahl Int @default(1) isOpen Boolean @default(true) createdAt DateTime @default(now()) @@ -168,10 +199,14 @@ model Wahl { forceZuteilungen ForceZuteilung[] } +/// A workshop offered in one phase of a Wahl. `phase` is 1-based and must be +/// <= the owning Wahl's `phasenAnzahl`. model Workshop { id String @id @default(cuid()) wahlId String + phase Int @default(1) name String + beschreibung String? kapazitaet Int minTeilnehmer Int @default(0) @@ -180,10 +215,13 @@ model Workshop { forceZuteilungen ForceZuteilung[] } -/// A participant's submitted choices for a Wahl. +/// A participant's submitted choices for one phase of a Wahl. A guest submits +/// separately per phase (matching the WP plugin), so the same guest can have +/// one row per (wahlId, phase). model Teilnehmer { id String @id @default(cuid()) wahlId String + phase Int @default(1) guestAccountId String prioritaeten Json createdAt DateTime @default(now()) @@ -193,7 +231,7 @@ model Teilnehmer { zuteilung Zuteilung? forceZuteilung ForceZuteilung? - @@unique([wahlId, guestAccountId]) + @@unique([wahlId, guestAccountId, phase]) } /// Manual override set by LT before running the assignment algorithm; takes precedence. diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 1a2e2dd..d0dd059 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { GuestAuthService } from './guest-auth.service'; import { TeamAuthService } from './team-auth.service'; @@ -49,10 +49,14 @@ export class AuthController { return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName); } - /// Password login for local Gemeinde Teamer accounts. + /// Password login for local Gemeinde Teamer accounts — by Gemeinde name + /// (the normal path) or by email (legacy/personal accounts). @Post('team-login') teamLogin(@Body() dto: TeamLoginDto) { - return this.teamAuth.login(dto.email, dto.password); + if (!dto.email && !dto.gemeindeName) { + throw new BadRequestException('email or gemeindeName is required'); + } + return this.teamAuth.login({ email: dto.email, gemeindeName: dto.gemeindeName }, dto.password); } /// Self-registration for a Gemeinde Teamer via an invite token/link. diff --git a/backend/src/auth/dto/team-login.dto.ts b/backend/src/auth/dto/team-login.dto.ts index e4f31af..87ef525 100644 --- a/backend/src/auth/dto/team-login.dto.ts +++ b/backend/src/auth/dto/team-login.dto.ts @@ -1,8 +1,19 @@ -import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +/// Team login accepts EITHER an email (legacy/personal Verantwortliche +/// accounts) OR a Gemeinde name (the normal Teamer login path, since a +/// Teamer thinks of their login as "meine Gemeinde", not their email). +/// At least one of email/gemeindeName is required; enforced in the +/// controller rather than a custom validator to keep this DTO simple. export class TeamLoginDto { + @IsOptional() @IsEmail() - email!: string; + email?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + gemeindeName?: string; @IsString() @IsNotEmpty() diff --git a/backend/src/auth/guest-auth.service.ts b/backend/src/auth/guest-auth.service.ts index fc6eed6..4131c4b 100644 --- a/backend/src/auth/guest-auth.service.ts +++ b/backend/src/auth/guest-auth.service.ts @@ -20,6 +20,11 @@ export class GuestAuthService { private readonly sync: SyncService, ) {} + /// Redeems a KC invite code for a guest/Konfi session. If a guest account + /// with the same (trimmed, case-insensitive) name already exists for this + /// KC, reuses it instead of creating a new one — this is what lets a Konfi + /// "log back in" with the same code + name and keep their chat history / + /// Workshop-Wahl submission instead of losing it to a fresh blank account. async createGuest( inviteCode: string, firstName: string, @@ -30,10 +35,23 @@ export class GuestAuthService { throw new NotFoundException('Unknown or inactive KC invite code'); } - const guest = await this.prisma.guestAccount.create({ - data: { kcId: kc.id, firstName, lastName }, + const trimmedFirst = firstName.trim(); + const trimmedLast = lastName.trim(); + + let guest = await this.prisma.guestAccount.findFirst({ + where: { + kcId: kc.id, + firstName: { equals: trimmedFirst, mode: 'insensitive' }, + lastName: { equals: trimmedLast, mode: 'insensitive' }, + }, }); - await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest); + + if (!guest) { + guest = await this.prisma.guestAccount.create({ + data: { kcId: kc.id, firstName: trimmedFirst, lastName: trimmedLast }, + }); + await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest); + } const payload: GuestJwtPayload = { guestId: guest.id, diff --git a/backend/src/auth/team-auth.service.spec.ts b/backend/src/auth/team-auth.service.spec.ts index bff85e3..3b17bb7 100644 --- a/backend/src/auth/team-auth.service.spec.ts +++ b/backend/src/auth/team-auth.service.spec.ts @@ -28,6 +28,10 @@ interface InviteRow { function makeService(seed: { invites?: InviteRow[]; users?: { id: string; email: string; passwordHash: string | null }[]; + memberships?: { + gemeindeName: string; + user: { id: string; passwordHash: string | null }; + }[]; }) { const invites = [...(seed.invites ?? [])]; const users = [...(seed.users ?? [])].map((u) => ({ @@ -39,6 +43,7 @@ function makeService(seed: { memberships: [] as unknown[], ...u, })); + const memberships = seed.memberships ?? []; const prisma = { user: { @@ -64,6 +69,21 @@ function makeService(seed: { create: jest.fn(({ data }: { data: Record }) => Promise.resolve({ id: `m-1`, ...data }), ), + findMany: jest.fn( + ({ + where, + }: { + where: { gemeinde: { name: { equals: string; mode: string } } }; + }) => + Promise.resolve( + memberships + .filter( + (m) => + m.gemeindeName.toLowerCase() === where.gemeinde.name.equals.toLowerCase(), + ) + .map((m) => ({ user: m.user })), + ), + ), }, teamerInvite: { findUnique: jest.fn(({ where }: { where: { token: string } }) => @@ -195,18 +215,18 @@ describe('TeamAuthService.registerFromInvite', () => { describe('TeamAuthService.login', () => { it('rejects an unknown email', async () => { const { service } = makeService({ users: [] }); - await expect(service.login('nobody@example.org', 'x')).rejects.toBeInstanceOf( - UnauthorizedException, - ); + await expect( + service.login({ email: 'nobody@example.org' }, 'x'), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('rejects a user without a password hash (Authentik-only account)', async () => { const { service } = makeService({ users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }], }); - await expect(service.login('lt@example.org', 'x')).rejects.toBeInstanceOf( - UnauthorizedException, - ); + await expect( + service.login({ email: 'lt@example.org' }, 'x'), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('rejects a wrong password', async () => { @@ -215,18 +235,74 @@ describe('TeamAuthService.login', () => { { id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) }, ], }); - await expect(service.login('t@example.org', 'wrong')).rejects.toBeInstanceOf( - UnauthorizedException, - ); + await expect( + service.login({ email: 't@example.org' }, 'wrong'), + ).rejects.toBeInstanceOf(UnauthorizedException); }); - it('issues a token for correct credentials', async () => { + it('issues a token for correct credentials by email', async () => { const { service } = makeService({ users: [ { id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) }, ], }); - const res = await service.login('T@example.org', 'right'); + const res = await service.login({ email: 'T@example.org' }, 'right'); expect(res.accessToken).toEqual(expect.any(String)); }); + + it('rejects when neither email nor gemeindeName is given', async () => { + const { service } = makeService({}); + await expect(service.login({}, 'x')).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejects an unknown Gemeinde name', async () => { + const { service } = makeService({}); + await expect( + service.login({ gemeindeName: 'Nirgendwo' }, 'x'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('logs in by Gemeinde name, matching case-insensitively and trimmed', async () => { + const { service } = makeService({ + memberships: [ + { + gemeindeName: 'Musterstadt', + user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) }, + }, + ], + }); + const res = await service.login({ gemeindeName: ' musterstadt ' }, 'right'); + expect(res.accessToken).toEqual(expect.any(String)); + }); + + it('tries every Teamer account for a Gemeinde until one password matches', async () => { + const { service } = makeService({ + memberships: [ + { + gemeindeName: 'Musterstadt', + user: { id: 'u-1', passwordHash: bcrypt.hashSync('wrong-one', 10) }, + }, + { + gemeindeName: 'Musterstadt', + user: { id: 'u-2', passwordHash: bcrypt.hashSync('right', 10) }, + }, + ], + }); + const res = await service.login({ gemeindeName: 'Musterstadt' }, 'right'); + expect(res.accessToken).toEqual(expect.any(String)); + }); + + it('rejects a Gemeinde login when no account password matches', async () => { + const { service } = makeService({ + memberships: [ + { + gemeindeName: 'Musterstadt', + user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) }, + }, + ], + }); + await expect( + service.login({ gemeindeName: 'Musterstadt' }, 'wrong'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); }); diff --git a/backend/src/auth/team-auth.service.ts b/backend/src/auth/team-auth.service.ts index 935f781..e703619 100644 --- a/backend/src/auth/team-auth.service.ts +++ b/backend/src/auth/team-auth.service.ts @@ -38,19 +38,44 @@ export class TeamAuthService { this.secret = config.getOrThrow('TEAM_JWT_SECRET'); } - async login(email: string, password: string): Promise<{ accessToken: string }> { - const user = await this.prisma.user.findUnique({ - where: { email: email.toLowerCase() }, - include: { memberships: true }, + /// Logs a Teamer in by email (legacy) OR by Gemeinde name — the normal + /// path, since a Teamer thinks of their login as "meine Gemeinde" rather + /// than an email address. A Gemeinde can have several Teamer accounts, so + /// a name lookup tries the password against every active GEMEINDE_TEAMER + /// membership for that Gemeinde (case-insensitive, trimmed name) until one + /// matches, rather than assuming a 1:1 Gemeinde-to-account mapping. + async login( + credentials: { email?: string; gemeindeName?: string }, + password: string, + ): Promise<{ accessToken: string }> { + if (credentials.email) { + const user = await this.prisma.user.findUnique({ + where: { email: credentials.email.toLowerCase() }, + }); + if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) { + throw new UnauthorizedException('Invalid credentials'); + } + return { accessToken: this.sign(user.id) }; + } + + const gemeindeName = credentials.gemeindeName?.trim(); + if (!gemeindeName) { + throw new UnauthorizedException('Invalid credentials'); + } + const memberships = await this.prisma.membership.findMany({ + where: { + role: Role.GEMEINDE_TEAMER, + status: 'ACTIVE', + gemeinde: { name: { equals: gemeindeName, mode: 'insensitive' } }, + }, + include: { user: true }, }); - if (!user || !user.passwordHash) { - throw new UnauthorizedException('Invalid credentials'); + for (const m of memberships) { + if (m.user.passwordHash && (await bcrypt.compare(password, m.user.passwordHash))) { + return { accessToken: this.sign(m.user.id) }; + } } - const ok = await bcrypt.compare(password, user.passwordHash); - if (!ok) { - throw new UnauthorizedException('Invalid credentials'); - } - return { accessToken: this.sign(user.id) }; + throw new UnauthorizedException('Invalid credentials'); } /// Redeems an invite token and creates the local Teamer account + its diff --git a/backend/src/onboarding/onboarding.service.ts b/backend/src/onboarding/onboarding.service.ts index 0a0dad1..87158ff 100644 --- a/backend/src/onboarding/onboarding.service.ts +++ b/backend/src/onboarding/onboarding.service.ts @@ -5,16 +5,15 @@ import { UnauthorizedException, } from '@nestjs/common'; import { MembershipStatus, Role, SyncOperation } from '@prisma/client'; +import { randomBytes } from 'crypto'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { TokenVerificationService } from '../auth/token-verification.service'; import { resolveOrProvisionAuthentikUser } from '../auth/provision-user'; +import { AuthenticatedUser } from '../auth/authenticated-request'; -/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in -/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus -/// the Gemeinde they belong to; this provisions their local User (JIT) and a -/// PENDING membership that a Leitungsteam member must approve before it grants -/// any rights. +/// Self-service onboarding for Gemeinde Verantwortliche, plus the +/// Leitungsteam-initiated shortcut that skips the approval step entirely. @Injectable() export class OnboardingService { constructor( @@ -133,4 +132,145 @@ export class OnboardingService { ) { return { membershipId, status, kcName, gemeindeName }; } + + // --- Leitungsteam-issued Verantwortliche invites --- + // Skips the PENDING approval step: an LT member vouching for someone + // directly is enough, unlike self-registration which needs review. + + async createInvite( + caller: AuthenticatedUser, + gemeindeId: string, + dto: { email?: string; maxUses?: number; expiresInHours?: number }, + ) { + if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) { + throw new UnauthorizedException('Only Leitungsteam can issue this invite'); + } + const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } }); + if (!gemeinde) { + throw new NotFoundException('Gemeinde not found'); + } + const email = dto.email?.toLowerCase() ?? null; + const maxUses = dto.maxUses ?? (email ? 1 : null); + const expiresAt = dto.expiresInHours + ? new Date(Date.now() + dto.expiresInHours * 3600_000) + : null; + + const invite = await this.prisma.verantwortlicheInvite.create({ + data: { + kcId: gemeinde.kcId, + gemeindeId, + token: randomBytes(24).toString('base64url'), + email, + maxUses, + expiresAt, + createdByUserId: caller.userId, + }, + }); + await this.sync.capture('VerantwortlicheInvite', SyncOperation.CREATE, invite.id, invite); + return invite; + } + + async listInvites(caller: AuthenticatedUser, gemeindeId: string) { + if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) { + throw new UnauthorizedException('Only Leitungsteam can view this'); + } + return this.prisma.verantwortlicheInvite.findMany({ + where: { gemeindeId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) { + if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) { + throw new UnauthorizedException('Only Leitungsteam can revoke this'); + } + const invite = await this.prisma.verantwortlicheInvite.findFirst({ + where: { id: inviteId, gemeindeId }, + }); + if (!invite) { + throw new NotFoundException('Invite not found'); + } + const updated = await this.prisma.verantwortlicheInvite.update({ + where: { id: inviteId }, + data: { revokedAt: new Date() }, + }); + await this.sync.capture('VerantwortlicheInvite', SyncOperation.UPDATE, updated.id, updated); + return updated; + } + + /// Redeems an LT-issued invite: provisions/updates the caller's Authentik + /// User and grants an immediately-ACTIVE GEMEINDE_VERANTWORTLICHER + /// membership (no approval step, unlike self-registration). + async redeemInvite(token: string | undefined, inviteToken: string) { + if (!token) { + throw new UnauthorizedException('Missing Authentik bearer token'); + } + const invite = await this.prisma.verantwortlicheInvite.findUnique({ + where: { token: inviteToken }, + }); + if (!invite || invite.revokedAt) { + throw new NotFoundException('Unknown or revoked invite'); + } + if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) { + throw new BadRequestException('Invite has expired'); + } + if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) { + throw new BadRequestException('Invite has already been used up'); + } + + const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token); + if (invite.email && invite.email !== claims.email.toLowerCase()) { + throw new BadRequestException('This invite is pinned to a different account'); + } + + const user = await resolveOrProvisionAuthentikUser( + this.prisma, + this.sync, + claims, + isLeitungsteam, + ); + + const existing = await this.prisma.membership.findUnique({ + where: { + userId_kcId_gemeindeId: { + userId: user.id, + kcId: invite.kcId, + gemeindeId: invite.gemeindeId, + }, + }, + }); + const membership = existing + ? await this.prisma.membership.update({ + where: { id: existing.id }, + data: { status: MembershipStatus.ACTIVE, role: Role.GEMEINDE_VERANTWORTLICHER }, + }) + : await this.prisma.membership.create({ + data: { + userId: user.id, + kcId: invite.kcId, + gemeindeId: invite.gemeindeId, + role: Role.GEMEINDE_VERANTWORTLICHER, + status: MembershipStatus.ACTIVE, + }, + }); + await this.sync.capture( + 'Membership', + existing ? SyncOperation.UPDATE : SyncOperation.CREATE, + membership.id, + membership, + ); + + const updatedInvite = await this.prisma.verantwortlicheInvite.update({ + where: { id: invite.id }, + data: { usedCount: { increment: 1 } }, + }); + await this.sync.capture( + 'VerantwortlicheInvite', + SyncOperation.UPDATE, + updatedInvite.id, + updatedInvite, + ); + + return { membershipId: membership.id, status: membership.status }; + } } diff --git a/backend/src/sync/sync.service.ts b/backend/src/sync/sync.service.ts index 5e896a3..2fc88d0 100644 --- a/backend/src/sync/sync.service.ts +++ b/backend/src/sync/sync.service.ts @@ -9,6 +9,7 @@ const SYNCED_MODELS = [ 'User', 'Membership', 'TeamerInvite', + 'VerantwortlicheInvite', 'GuestAccount', 'Wahl', 'Workshop', diff --git a/backend/src/wahl/wahl.service.ts b/backend/src/wahl/wahl.service.ts index f1858ae..fe1f0d8 100644 --- a/backend/src/wahl/wahl.service.ts +++ b/backend/src/wahl/wahl.service.ts @@ -172,7 +172,7 @@ export class WahlService { throw new ForbiddenException('Wahl is closed'); } const teilnehmer = await this.prisma.teilnehmer.upsert({ - where: { wahlId_guestAccountId: { wahlId, guestAccountId } }, + where: { wahlId_guestAccountId_phase: { wahlId, guestAccountId, phase: 1 } }, create: { wahlId, guestAccountId, prioritaeten }, update: { prioritaeten }, }); diff --git a/client/app/README.md b/client/app/README.md index fa5ee09..616dd0b 100644 --- a/client/app/README.md +++ b/client/app/README.md @@ -64,6 +64,14 @@ Teamer login only). `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` — diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index 0305518..3f7f59e 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -450,8 +450,14 @@ class Api { return j['accessToken'] as String; } - Future teamLogin(String email, String password) async { - final j = await _post('/auth/team-login', {'email': email, 'password': password}); + /// Logs a Gemeinde Teamer in by Gemeinde name (the normal path) or by + /// email (legacy/personal accounts) — pass exactly one of the two. + Future 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; } @@ -772,8 +778,8 @@ class AppState extends ChangeNotifier { Future guestLogin(String code, String first, String last) => _api.guestLogin(code, first, last).then(_establish); - Future teamLogin(String email, String password) => - _api.teamLogin(email, password).then(_establish); + Future teamLogin({String? gemeindeName, String? email, required String password}) => + _api.teamLogin(gemeindeName: gemeindeName, email: email, password: password).then(_establish); Future redeemInvite({ required String token, diff --git a/client/app/lib/main.dart b/client/app/lib/main.dart index 811010f..cbeb1f5 100644 --- a/client/app/lib/main.dart +++ b/client/app/lib/main.dart @@ -4,6 +4,7 @@ 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(); @@ -34,10 +35,7 @@ class KcApp extends StatelessWidget { child: MaterialApp( title: 'KC-App', debugShowCheckedModeBanner: false, - theme: ThemeData( - colorSchemeSeed: const Color(0xFF3B5BA5), - useMaterial3: true, - ), + theme: buildKcTheme(), home: const _AuthGate(), ), ); diff --git a/client/app/lib/screens/home_screen.dart b/client/app/lib/screens/home_screen.dart index 0432025..d09bec3 100644 --- a/client/app/lib/screens/home_screen.dart +++ b/client/app/lib/screens/home_screen.dart @@ -2,6 +2,7 @@ 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'; @@ -109,8 +110,7 @@ class _IdentityCard extends StatelessWidget { @override Widget build(BuildContext context) { final lines = [ - 'Rolle: ${id.roleLabel}', - if (id.email != null) 'E-Mail: ${id.email}', + if (id.email != null) id.email!, if (id.isLeitungsteam) 'Leitungsteam-Rechte gelten KC-übergreifend.' else if (id.memberships.length > 1) @@ -118,13 +118,35 @@ class _IdentityCard extends StatelessWidget { ]; return Card( child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.all(20), + child: Row( children: [ - Text('Angemeldet', style: Theme.of(context).textTheme.labelMedium), - const SizedBox(height: 4), - for (final l in lines) Text(l), + 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), + ], + ], + ), + ), ], ), ), @@ -147,12 +169,38 @@ class _NavTile extends StatelessWidget { @override Widget build(BuildContext context) { return Card( - child: ListTile( - leading: Icon(icon), - title: Text(title), - subtitle: Text(subtitle), - trailing: const Icon(Icons.chevron_right), + 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)), + ], + ), + ), ), ); } diff --git a/client/app/lib/screens/login_screen.dart b/client/app/lib/screens/login_screen.dart index 803c652..44c7add 100644 --- a/client/app/lib/screens/login_screen.dart +++ b/client/app/lib/screens/login_screen.dart @@ -2,38 +2,49 @@ import 'package:flutter/material.dart'; import '../api.dart'; import '../main.dart'; +import '../theme.dart'; -class LoginScreen extends StatelessWidget { +/// 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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { @override Widget build(BuildContext context) { - return DefaultTabController( - length: 3, - child: Scaffold( - appBar: AppBar( - title: const Text('KC-App'), - bottom: const TabBar( - tabs: [ - Tab(text: 'Konfi / Gast'), - Tab(text: 'Team-Login'), - Tab(text: 'Einladung'), - ], - ), - ), - body: SafeArea( - child: Center( + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), - child: Padding( - padding: const EdgeInsets.all(24), - child: TabBarView( - children: const [ - _GuestForm(), - _TeamForm(), - _InviteForm(), - ], - ), + 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(), + ], + ), + ), + ), + ], ), ), ), @@ -43,12 +54,44 @@ class LoginScreen extends StatelessWidget { } } -/// Shared submit-button + error handling for the three little forms. +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.title, required this.fields, required this.onSubmit}); - final String title; + const _FormShell({required this.fields, required this.onSubmit, this.submitLabel = 'Anmelden'}); final List fields; final Future Function() onSubmit; + final String submitLabel; @override State<_FormShell> createState() => _FormShellState(); @@ -76,126 +119,203 @@ class _FormShellState extends State<_FormShell> { @override Widget build(BuildContext context) { - return ListView( - shrinkWrap: true, + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (widget.title.isNotEmpty) ...[ - Text(widget.title, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 16), - ], ...widget.fields, - const SizedBox(height: 20), if (_error != null) ...[ + const SizedBox(height: 8), Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), - const SizedBox(height: 12), ], + const SizedBox(height: 16), FilledButton( onPressed: _busy ? null : _run, child: _busy ? const SizedBox( - height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Weiter'), + height: 18, width: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : Text(widget.submitLabel), ), ], ); } } -TextField _field(TextEditingController c, String label, {bool obscure = false}) => TextField( +TextField _field(TextEditingController c, String label, + {bool obscure = false, IconData? icon, ValueChanged? onChanged}) => + TextField( controller: c, obscureText: obscure, - decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()), + onChanged: onChanged, + decoration: InputDecoration( + labelText: label, + prefixIcon: icon != null ? Icon(icon, size: 20) : null, + ), ); -class _GuestForm extends StatefulWidget { - const _GuestForm(); +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<_GuestForm> createState() => _GuestFormState(); + State<_KonfiOrLeitungsteamSection> createState() => _KonfiOrLeitungsteamSectionState(); } -class _GuestFormState extends State<_GuestForm> { +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 _FormShell( - title: 'Mit Einladungscode beitreten', - fields: [ - _field(_code, 'Einladungscode'), - const SizedBox(height: 12), - _field(_first, 'Vorname'), - const SizedBox(height: 12), - _field(_last, 'Nachname'), + 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, + ), + ], + ), + ), ], - onSubmit: () => state.guestLogin(_code.text.trim(), _first.text.trim(), _last.text.trim()), ); } } -class _TeamForm extends StatefulWidget { - const _TeamForm(); - @override - State<_TeamForm> createState() => _TeamFormState(); -} - -class _TeamFormState extends State<_TeamForm> { - final _email = TextEditingController(); - final _password = TextEditingController(); +class _LeitungsteamLogin extends StatelessWidget { + const _LeitungsteamLogin({super.key, required this.state}); + final AppState state; @override Widget build(BuildContext context) { - final state = AppScope.of(context); - return ListView( - shrinkWrap: true, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Leitungsteam / Verantwortliche', - style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 12), if (state.authError != null) ...[ - Text(state.authError!, - style: TextStyle(color: Theme.of(context).colorScheme.error)), + 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), + icon: const Icon(Icons.login, size: 20), label: const Text('Mit Konfi-Castle-ID anmelden'), ), const SizedBox(height: 8), - const Text( - 'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte kommen ' - 'aus deiner Authentik-Gruppe.', - style: TextStyle(fontSize: 12), + 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, ), - const Divider(height: 40), - Text('Lokaler Teamer:in-Login', - style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), - _TeamPasswordForm(email: _email, password: _password), ], ); } } -class _TeamPasswordForm extends StatelessWidget { - const _TeamPasswordForm({required this.email, required this.password}); - final TextEditingController email; - final TextEditingController password; +/// 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 _FormShell( - title: '', - fields: [ - _field(email, 'E-Mail'), - const SizedBox(height: 12), - _field(password, 'Passwort', obscure: true), + 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(), + ], ], - onSubmit: () => state.teamLogin(email.text.trim(), password.text), ); } } @@ -216,26 +336,34 @@ class _InviteFormState extends State<_InviteForm> { @override Widget build(BuildContext context) { final state = AppScope.of(context); - return _FormShell( - title: 'Teamer:in-Einladung einlösen', - fields: [ - _field(_token, 'Einladungscode / Token'), - const SizedBox(height: 12), - _field(_first, 'Vorname'), - const SizedBox(height: 12), - _field(_last, 'Nachname'), - const SizedBox(height: 12), - _field(_email, 'E-Mail (bei Gruppen-Link nötig)'), - const SizedBox(height: 12), - _field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true), + 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(), + ), + ), ], - onSubmit: () => state.redeemInvite( - token: _token.text.trim(), - first: _first.text.trim(), - last: _last.text.trim(), - password: _password.text, - email: _email.text.trim(), - ), ); } } diff --git a/client/app/lib/theme.dart b/client/app/lib/theme.dart new file mode 100644 index 0000000..3315059 --- /dev/null +++ b/client/app/lib/theme.dart @@ -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)), + ); +} diff --git a/client/app/web/firebase-messaging-sw.js b/client/app/web/firebase-messaging-sw.js index b3dbf5d..883f9d9 100644 --- a/client/app/web/firebase-messaging-sw.js +++ b/client/app/web/firebase-messaging-sw.js @@ -1,5 +1,7 @@ // 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'); diff --git a/client/app/web/index.html b/client/app/web/index.html index 24c2b97..f1ec00c 100644 --- a/client/app/web/index.html +++ b/client/app/web/index.html @@ -32,14 +32,17 @@ KC-App - + +