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 --------------------------------------------------------- # --- 1. Backend build ---------------------------------------------------------
FROM node:20-bookworm-slim AS api-build FROM node:20-bookworm-slim AS api-build
WORKDIR /src 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 ./ COPY backend/package.json backend/package-lock.json ./
RUN npm ci RUN npm ci
COPY backend/ ./ 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[] guests GuestAccount[]
localUsers User[] localUsers User[]
teamerInvites TeamerInvite[] teamerInvites TeamerInvite[]
verantwortlicheInvites VerantwortlicheInvite[]
} }
/// A local congregation/community participating in one Kc. /// A local congregation/community participating in one Kc.
@@ -37,6 +38,7 @@ model Gemeinde {
memberships Membership[] memberships Membership[]
guests GuestAccount[] guests GuestAccount[]
teamerInvites TeamerInvite[] teamerInvites TeamerInvite[]
verantwortlicheInvites VerantwortlicheInvite[]
@@unique([kcId, name]) @@unique([kcId, name])
} }
@@ -152,13 +154,42 @@ model TeamerInvite {
gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) 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". /// 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 { model Wahl {
id String @id @default(cuid()) id String @id @default(cuid())
kcId String kcId String
name String name String
datumsSchluessel String datumsSchluessel String
teil String teil String
beschreibung String?
phasenAnzahl Int @default(1)
isOpen Boolean @default(true) isOpen Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -168,10 +199,14 @@ model Wahl {
forceZuteilungen ForceZuteilung[] 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 { model Workshop {
id String @id @default(cuid()) id String @id @default(cuid())
wahlId String wahlId String
phase Int @default(1)
name String name String
beschreibung String?
kapazitaet Int kapazitaet Int
minTeilnehmer Int @default(0) minTeilnehmer Int @default(0)
@@ -180,10 +215,13 @@ model Workshop {
forceZuteilungen ForceZuteilung[] 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 { model Teilnehmer {
id String @id @default(cuid()) id String @id @default(cuid())
wahlId String wahlId String
phase Int @default(1)
guestAccountId String guestAccountId String
prioritaeten Json prioritaeten Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -193,7 +231,7 @@ model Teilnehmer {
zuteilung Zuteilung? zuteilung Zuteilung?
forceZuteilung ForceZuteilung? forceZuteilung ForceZuteilung?
@@unique([wahlId, guestAccountId]) @@unique([wahlId, guestAccountId, phase])
} }
/// Manual override set by LT before running the assignment algorithm; takes precedence. /// 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 { AuthGuard } from '@nestjs/passport';
import { GuestAuthService } from './guest-auth.service'; import { GuestAuthService } from './guest-auth.service';
import { TeamAuthService } from './team-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); 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') @Post('team-login')
teamLogin(@Body() dto: TeamLoginDto) { 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. /// 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 { export class TeamLoginDto {
@IsOptional()
@IsEmail() @IsEmail()
email!: string; email?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
gemeindeName?: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
+20 -2
View File
@@ -20,6 +20,11 @@ export class GuestAuthService {
private readonly sync: SyncService, 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( async createGuest(
inviteCode: string, inviteCode: string,
firstName: string, firstName: string,
@@ -30,10 +35,23 @@ export class GuestAuthService {
throw new NotFoundException('Unknown or inactive KC invite code'); throw new NotFoundException('Unknown or inactive KC invite code');
} }
const guest = await this.prisma.guestAccount.create({ const trimmedFirst = firstName.trim();
data: { kcId: kc.id, firstName, lastName }, 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' },
},
});
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); await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
}
const payload: GuestJwtPayload = { const payload: GuestJwtPayload = {
guestId: guest.id, guestId: guest.id,
+87 -11
View File
@@ -28,6 +28,10 @@ interface InviteRow {
function makeService(seed: { function makeService(seed: {
invites?: InviteRow[]; invites?: InviteRow[];
users?: { id: string; email: string; passwordHash: string | null }[]; users?: { id: string; email: string; passwordHash: string | null }[];
memberships?: {
gemeindeName: string;
user: { id: string; passwordHash: string | null };
}[];
}) { }) {
const invites = [...(seed.invites ?? [])]; const invites = [...(seed.invites ?? [])];
const users = [...(seed.users ?? [])].map((u) => ({ const users = [...(seed.users ?? [])].map((u) => ({
@@ -39,6 +43,7 @@ function makeService(seed: {
memberships: [] as unknown[], memberships: [] as unknown[],
...u, ...u,
})); }));
const memberships = seed.memberships ?? [];
const prisma = { const prisma = {
user: { user: {
@@ -64,6 +69,21 @@ function makeService(seed: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) => create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: `m-1`, ...data }), 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: { teamerInvite: {
findUnique: jest.fn(({ where }: { where: { token: string } }) => findUnique: jest.fn(({ where }: { where: { token: string } }) =>
@@ -195,18 +215,18 @@ describe('TeamAuthService.registerFromInvite', () => {
describe('TeamAuthService.login', () => { describe('TeamAuthService.login', () => {
it('rejects an unknown email', async () => { it('rejects an unknown email', async () => {
const { service } = makeService({ users: [] }); const { service } = makeService({ users: [] });
await expect(service.login('nobody@example.org', 'x')).rejects.toBeInstanceOf( await expect(
UnauthorizedException, service.login({ email: 'nobody@example.org' }, 'x'),
); ).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('rejects a user without a password hash (Authentik-only account)', async () => { it('rejects a user without a password hash (Authentik-only account)', async () => {
const { service } = makeService({ const { service } = makeService({
users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }], users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }],
}); });
await expect(service.login('lt@example.org', 'x')).rejects.toBeInstanceOf( await expect(
UnauthorizedException, service.login({ email: 'lt@example.org' }, 'x'),
); ).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('rejects a wrong password', async () => { 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) }, { id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
], ],
}); });
await expect(service.login('t@example.org', 'wrong')).rejects.toBeInstanceOf( await expect(
UnauthorizedException, 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({ const { service } = makeService({
users: [ users: [
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) }, { 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)); 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);
});
}); });
+33 -8
View File
@@ -38,21 +38,46 @@ export class TeamAuthService {
this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET'); this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET');
} }
async login(email: string, password: string): Promise<{ accessToken: string }> { /// 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({ const user = await this.prisma.user.findUnique({
where: { email: email.toLowerCase() }, where: { email: credentials.email.toLowerCase() },
include: { memberships: true },
}); });
if (!user || !user.passwordHash) { if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Invalid credentials');
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
return { accessToken: this.sign(user.id) }; 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 },
});
for (const m of memberships) {
if (m.user.passwordHash && (await bcrypt.compare(password, m.user.passwordHash))) {
return { accessToken: this.sign(m.user.id) };
}
}
throw new UnauthorizedException('Invalid credentials');
}
/// Redeems an invite token and creates the local Teamer account + its /// Redeems an invite token and creates the local Teamer account + its
/// GEMEINDE_TEAMER membership for the invite's Gemeinde. /// GEMEINDE_TEAMER membership for the invite's Gemeinde.
async registerFromInvite(input: { async registerFromInvite(input: {
+145 -5
View File
@@ -5,16 +5,15 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { MembershipStatus, Role, SyncOperation } from '@prisma/client'; import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
import { randomBytes } from 'crypto';
import { PrismaClient } from '../prisma/prisma.module'; import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { TokenVerificationService } from '../auth/token-verification.service'; import { TokenVerificationService } from '../auth/token-verification.service';
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user'; import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in /// Self-service onboarding for Gemeinde Verantwortliche, plus the
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus /// Leitungsteam-initiated shortcut that skips the approval step entirely.
/// 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.
@Injectable() @Injectable()
export class OnboardingService { export class OnboardingService {
constructor( constructor(
@@ -133,4 +132,145 @@ export class OnboardingService {
) { ) {
return { membershipId, status, kcName, gemeindeName }; 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', 'User',
'Membership', 'Membership',
'TeamerInvite', 'TeamerInvite',
'VerantwortlicheInvite',
'GuestAccount', 'GuestAccount',
'Wahl', 'Wahl',
'Workshop', 'Workshop',
+1 -1
View File
@@ -172,7 +172,7 @@ export class WahlService {
throw new ForbiddenException('Wahl is closed'); throw new ForbiddenException('Wahl is closed');
} }
const teilnehmer = await this.prisma.teilnehmer.upsert({ const teilnehmer = await this.prisma.teilnehmer.upsert({
where: { wahlId_guestAccountId: { wahlId, guestAccountId } }, where: { wahlId_guestAccountId_phase: { wahlId, guestAccountId, phase: 1 } },
create: { wahlId, guestAccountId, prioritaeten }, create: { wahlId, guestAccountId, prioritaeten },
update: { prioritaeten }, update: { prioritaeten },
}); });
+8
View File
@@ -64,6 +64,14 @@ Teamer login only).
`AppState` calls `window.kcGetPushToken()` and registers the token `AppState` calls `window.kcGetPushToken()` and registers the token
(`POST /push/register`). Inert until `apiKey` / `appId` / `vapidKey` are (`POST /push/register`). Inert until `apiKey` / `appId` / `vapidKey` are
filled into both files (see the `REPLACE_ME` placeholders). 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: - **Workshop-Wahl** (`lib/screens/wahl_screen.dart`, guests) — two tabs:
*Wünsche* (`GET /wahl/guest/overview`, tap workshops in order, max 3, *Wünsche* (`GET /wahl/guest/overview`, tap workshops in order, max 3,
`POST /wahl/:id/teilnehmer`) and *Ergebnis* (`GET /wahl/guest/results` `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; return j['accessToken'] as String;
} }
Future<String> teamLogin(String email, String password) async { /// Logs a Gemeinde Teamer in by Gemeinde name (the normal path) or by
final j = await _post('/auth/team-login', {'email': email, 'password': password}); /// 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; return j['accessToken'] as String;
} }
@@ -772,8 +778,8 @@ class AppState extends ChangeNotifier {
Future<void> guestLogin(String code, String first, String last) => Future<void> guestLogin(String code, String first, String last) =>
_api.guestLogin(code, first, last).then(_establish); _api.guestLogin(code, first, last).then(_establish);
Future<void> teamLogin(String email, String password) => Future<void> teamLogin({String? gemeindeName, String? email, required String password}) =>
_api.teamLogin(email, password).then(_establish); _api.teamLogin(gemeindeName: gemeindeName, email: email, password: password).then(_establish);
Future<void> redeemInvite({ Future<void> redeemInvite({
required String token, required String token,
+2 -4
View File
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import 'api.dart'; import 'api.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'theme.dart';
void main() { void main() {
final state = AppState(Api(http.Client()))..bootstrap(); final state = AppState(Api(http.Client()))..bootstrap();
@@ -34,10 +35,7 @@ class KcApp extends StatelessWidget {
child: MaterialApp( child: MaterialApp(
title: 'KC-App', title: 'KC-App',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: buildKcTheme(),
colorSchemeSeed: const Color(0xFF3B5BA5),
useMaterial3: true,
),
home: const _AuthGate(), home: const _AuthGate(),
), ),
); );
+59 -11
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../api.dart'; import '../api.dart';
import '../main.dart'; import '../main.dart';
import '../theme.dart';
import 'admin_screen.dart'; import 'admin_screen.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
import 'files_screen.dart'; import 'files_screen.dart';
@@ -109,8 +110,7 @@ class _IdentityCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final lines = <String>[ final lines = <String>[
'Rolle: ${id.roleLabel}', if (id.email != null) id.email!,
if (id.email != null) 'E-Mail: ${id.email}',
if (id.isLeitungsteam) if (id.isLeitungsteam)
'Leitungsteam-Rechte gelten KC-übergreifend.' 'Leitungsteam-Rechte gelten KC-übergreifend.'
else if (id.memberships.length > 1) else if (id.memberships.length > 1)
@@ -118,13 +118,35 @@ class _IdentityCard extends StatelessWidget {
]; ];
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [KcColors.blue, KcColors.teal],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
),
child: const Icon(Icons.person, color: Colors.white),
),
const SizedBox(width: 14),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Angemeldet', style: Theme.of(context).textTheme.labelMedium), Text(id.roleLabel, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4), for (final l in lines) ...[
for (final l in lines) Text(l), const SizedBox(height: 2),
Text(l, style: Theme.of(context).textTheme.bodySmall),
],
],
),
),
], ],
), ),
), ),
@@ -147,12 +169,38 @@ class _NavTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
child: ListTile( margin: const EdgeInsets.only(bottom: 12),
leading: Icon(icon), child: InkWell(
title: Text(title), borderRadius: BorderRadius.circular(20),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap, 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)),
],
),
),
), ),
); );
} }
+217 -89
View File
@@ -2,40 +2,51 @@ import 'package:flutter/material.dart';
import '../api.dart'; import '../api.dart';
import '../main.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}); const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTabController( return Scaffold(
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( body: SafeArea(
child: Center( child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420), constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _Brand(),
const SizedBox(height: 28),
Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: TabBarView( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [ children: const [
_GuestForm(), _KonfiOrLeitungsteamSection(),
_TeamForm(), Divider(height: 40),
_InviteForm(), _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 { class _FormShell extends StatefulWidget {
const _FormShell({required this.title, required this.fields, required this.onSubmit}); const _FormShell({required this.fields, required this.onSubmit, this.submitLabel = 'Anmelden'});
final String title;
final List<Widget> fields; final List<Widget> fields;
final Future<void> Function() onSubmit; final Future<void> Function() onSubmit;
final String submitLabel;
@override @override
State<_FormShell> createState() => _FormShellState(); State<_FormShell> createState() => _FormShellState();
@@ -76,126 +119,203 @@ class _FormShellState extends State<_FormShell> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return Column(
shrinkWrap: true, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (widget.title.isNotEmpty) ...[
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
],
...widget.fields, ...widget.fields,
const SizedBox(height: 20),
if (_error != null) ...[ if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 12),
], ],
const SizedBox(height: 16),
FilledButton( FilledButton(
onPressed: _busy ? null : _run, onPressed: _busy ? null : _run,
child: _busy child: _busy
? const SizedBox( ? const SizedBox(
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) height: 18, width: 18,
: const Text('Weiter'), 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, controller: c,
obscureText: obscure, 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 _fieldGap = SizedBox(height: 12);
const _GuestForm();
/// 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 @override
State<_GuestForm> createState() => _GuestFormState(); State<_KonfiOrLeitungsteamSection> createState() => _KonfiOrLeitungsteamSectionState();
} }
class _GuestFormState extends State<_GuestForm> { class _KonfiOrLeitungsteamSectionState extends State<_KonfiOrLeitungsteamSection> {
final _code = TextEditingController(); final _code = TextEditingController();
final _first = TextEditingController(); final _first = TextEditingController();
final _last = 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = AppScope.of(context); final state = AppScope.of(context);
return _FormShell( return Column(
title: 'Mit Einladungscode beitreten', crossAxisAlignment: CrossAxisAlignment.stretch,
fields: [ children: [
_field(_code, 'Einladungscode'), _field(
const SizedBox(height: 12), _code,
_field(_first, 'Vorname'), 'KC-Code',
const SizedBox(height: 12), 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'), _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 { class _LeitungsteamLogin extends StatelessWidget {
const _TeamForm(); const _LeitungsteamLogin({super.key, required this.state});
@override final AppState state;
State<_TeamForm> createState() => _TeamFormState();
}
class _TeamFormState extends State<_TeamForm> {
final _email = TextEditingController();
final _password = TextEditingController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = AppScope.of(context); return Column(
return ListView( crossAxisAlignment: CrossAxisAlignment.stretch,
shrinkWrap: true,
children: [ children: [
Text('Leitungsteam / Verantwortliche',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
if (state.authError != null) ...[ if (state.authError != null) ...[
Text(state.authError!, Text(state.authError!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
FilledButton.icon( FilledButton.icon(
onPressed: () => state.beginOidcLogin(), onPressed: () => state.beginOidcLogin(),
icon: const Icon(Icons.login), icon: const Icon(Icons.login, size: 20),
label: const Text('Mit Konfi-Castle-ID anmelden'), label: const Text('Mit Konfi-Castle-ID anmelden'),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( Text(
'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte kommen ' 'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte und '
'aus deiner Authentik-Gruppe.', 'Gemeinde-Zuordnungen kommen automatisch aus deinem Account.',
style: TextStyle(fontSize: 12), 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 { /// Gemeinde Teamer:in login — Gemeinde name instead of email, since that's
const _TeamPasswordForm({required this.email, required this.password}); /// what a Teamer actually thinks of as "their" login. Invite redemption for
final TextEditingController email; /// a first-time account is folded in underneath.
final TextEditingController password; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = AppScope.of(context); final state = AppScope.of(context);
return _FormShell( return Column(
title: '', crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Gemeinde Teamer:in', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
_FormShell(
fields: [ fields: [
_field(email, 'E-Mail'), _field(_gemeinde, 'Gemeinde', icon: Icons.groups_outlined),
const SizedBox(height: 12), _fieldGap,
_field(password, 'Passwort', obscure: true), _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,17 +336,23 @@ class _InviteFormState extends State<_InviteForm> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = AppScope.of(context); final state = AppScope.of(context);
return _FormShell( return Column(
title: 'Teamer:in-Einladung einlösen', 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: [ fields: [
_field(_token, 'Einladungscode / Token'), _field(_token, 'Einladungscode / Token'),
const SizedBox(height: 12), _fieldGap,
_field(_first, 'Vorname'), _field(_first, 'Vorname'),
const SizedBox(height: 12), _fieldGap,
_field(_last, 'Nachname'), _field(_last, 'Nachname'),
const SizedBox(height: 12), _fieldGap,
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'), _field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
const SizedBox(height: 12), _fieldGap,
_field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true), _field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true),
], ],
onSubmit: () => state.redeemInvite( onSubmit: () => state.redeemInvite(
@@ -236,6 +362,8 @@ class _InviteFormState extends State<_InviteForm> {
password: _password.text, password: _password.text,
email: _email.text.trim(), 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 // 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). // 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-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-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> <title>KC-App</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<!-- Firebase Cloud Messaging (web push). `vapidKey` is the *public* half of <!-- Firebase Cloud Messaging (web push) + Google Analytics for Firebase
the Web Push certificate key pair; the private half stays in Firebase. (usage analytics, visible under Firebase Console > Analytics).
Backend sending still needs a service-account JSON + PUSH_PROVIDER=fcm. --> `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-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-messaging-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-analytics-compat.js"></script>
<script> <script>
window.KC_FIREBASE = { window.KC_FIREBASE = {
apiKey: "AIzaSyDxBpdmW8lUHSuix--3AsWJScGQy9o3G_M", apiKey: "«reda...…»",
authDomain: "konfi-castle-app.firebaseapp.com", authDomain: "konfi-castle-app.firebaseapp.com",
projectId: "konfi-castle-app", projectId: "konfi-castle-app",
storageBucket: "konfi-castle-app.firebasestorage.app", storageBucket: "konfi-castle-app.firebasestorage.app",
@@ -48,6 +51,19 @@
measurementId: "G-NK8K5VV40D", measurementId: "G-NK8K5VV40D",
vapidKey: "BEAHrnIzkTBGSEws1J_HRvsTtc6nqvmW4MvFMTfljmjuunqo4yiJoUcq8_jKIt_hbm4diOt0czG28l-dpvGK8T8" 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 () { window.kcGetPushToken = async function () {
try { try {
var cfg = window.KC_FIREBASE; var cfg = window.KC_FIREBASE;
+4 -2
View File
@@ -7,6 +7,8 @@ services:
POSTGRES_DB: kcapp POSTGRES_DB: kcapp
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"] test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"]
interval: 5s interval: 5s
@@ -29,9 +31,9 @@ services:
DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public
PORT: "3000" PORT: "3000"
GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json
APP_BASE_URL: http://localhost:3000 APP_BASE_URL: http://localhost:3010
ports: ports:
- "3000:3000" - "3010:3000"
volumes: volumes:
# Firebase service account — kept out of the image, mounted read-only. # Firebase service account — kept out of the image, mounted read-only.
- ./backend/serviceAccount.json:/app/serviceAccount.json:ro - ./backend/serviceAccount.json:/app/serviceAccount.json:ro