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:
@@ -0,0 +1 @@
|
|||||||
|
.DS_Store
|
||||||
@@ -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/ ./
|
||||||
|
|||||||
+43
@@ -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;
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const SYNCED_MODELS = [
|
|||||||
'User',
|
'User',
|
||||||
'Membership',
|
'Membership',
|
||||||
'TeamerInvite',
|
'TeamerInvite',
|
||||||
|
'VerantwortlicheInvite',
|
||||||
'GuestAccount',
|
'GuestAccount',
|
||||||
'Wahl',
|
'Wahl',
|
||||||
'Workshop',
|
'Workshop',
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user