feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes

- New VerantwortlicheInvite model: LT-issued invites so a person can
  register as Gemeinde Verantwortliche(r) for a specific Gemeinde,
  skipping the self-registration approval step.
- Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl,
  beschreibung), mirroring the WP plugin's multi-phase elections.
  Teilnehmer unique constraint now scoped per phase.
- Auth: team login + guest auth adjustments, spec coverage.
- sync.service.ts: register VerantwortlicheInvite as a synced model.
- wahl.service.ts: submitTeilnehmer updated for the new phase-scoped
  unique key.
- client: login/home screen rework, new theme.dart, FCM web tweaks.
- .gitignore: ignore .DS_Store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 18:00:18 +02:00
co-authored by Claude Sonnet 5
parent 72367637aa
commit 8af927c0f2
21 changed files with 844 additions and 175 deletions
+1
View File
@@ -0,0 +1 @@
.DS_Store
+5
View File
@@ -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/ ./
@@ -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;
+40 -2
View File
@@ -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.
+7 -3
View File
@@ -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.
+13 -2
View File
@@ -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()
+21 -3
View File
@@ -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,
+87 -11
View File
@@ -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<string, unknown> }) =>
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);
});
});
+36 -11
View File
@@ -38,19 +38,44 @@ export class TeamAuthService {
this.secret = config.getOrThrow<string>('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
+145 -5
View File
@@ -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 };
}
}
+1
View File
@@ -9,6 +9,7 @@ const SYNCED_MODELS = [
'User',
'Membership',
'TeamerInvite',
'VerantwortlicheInvite',
'GuestAccount',
'Wahl',
'Workshop',
+1 -1
View File
@@ -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 },
});
+8
View File
@@ -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`
+10 -4
View File
@@ -450,8 +450,14 @@ class Api {
return j['accessToken'] as String;
}
Future<String> 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<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;
}
@@ -772,8 +778,8 @@ class AppState extends ChangeNotifier {
Future<void> guestLogin(String code, String first, String last) =>
_api.guestLogin(code, first, last).then(_establish);
Future<void> teamLogin(String email, String password) =>
_api.teamLogin(email, password).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,
+2 -4
View File
@@ -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(),
),
);
+61 -13
View File
@@ -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 = <String>[
'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)),
],
),
),
),
);
}
+238 -110
View File
@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
@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<Widget> fields;
final Future<void> 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<String>? 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(),
),
);
}
}
+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)),
);
}
+2
View File
@@ -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');
+20 -4
View File
@@ -32,14 +32,17 @@
<title>KC-App</title>
<link rel="manifest" href="manifest.json">
<!-- Firebase Cloud Messaging (web push). `vapidKey` is the *public* half of
the Web Push certificate key pair; the private half stays in Firebase.
Backend sending still needs a service-account JSON + PUSH_PROVIDER=fcm. -->
<!-- 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: "AIzaSyDxBpdmW8lUHSuix--3AsWJScGQy9o3G_M",
apiKey: "«reda...…»",
authDomain: "konfi-castle-app.firebaseapp.com",
projectId: "konfi-castle-app",
storageBucket: "konfi-castle-app.firebasestorage.app",
@@ -48,6 +51,19 @@
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;
+4 -2
View File
@@ -7,6 +7,8 @@ services:
POSTGRES_DB: kcapp
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"]
interval: 5s
@@ -29,9 +31,9 @@ services:
DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public
PORT: "3000"
GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json
APP_BASE_URL: http://localhost:3000
APP_BASE_URL: http://localhost:3010
ports:
- "3000:3000"
- "3010:3000"
volumes:
# Firebase service account — kept out of the image, mounted read-only.
- ./backend/serviceAccount.json:/app/serviceAccount.json:ro