feat: client monorepo (Flutter app) + web fallback redesign #1

Merged
linus merged 37 commits from feat/backend-phases-0-6 into main 2026-09-12 11:27:27 +00:00
25 changed files with 1091 additions and 37 deletions
Showing only changes of commit 891105414a - Show all commits
+3
View File
@@ -7,6 +7,9 @@ AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app"
# Secret used to sign guest/Konfi session tokens (local accounts only) # Secret used to sign guest/Konfi session tokens (local accounts only)
GUEST_JWT_SECRET="change-me" GUEST_JWT_SECRET="change-me"
# Secret used to sign local Gemeinde Teamer session tokens (password login)
TEAM_JWT_SECRET="change-me-too"
PORT=3000 PORT=3000
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to # File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
+29 -7
View File
@@ -7,7 +7,7 @@ architecture context).
```bash ```bash
npm install npm install
cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET / TEAM_JWT_SECRET
npx prisma generate npx prisma generate
npx prisma migrate dev --name init # requires a running PostgreSQL instance npx prisma migrate dev --name init # requires a running PostgreSQL instance
npm run start:dev npm run start:dev
@@ -20,12 +20,20 @@ client's host - no separate web server is needed.
## Auth model ## Auth model
- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are - Leitungsteam and Gemeinde Verantwortliche are provisioned in Authentik
provisioned in Authentik; this API acts as an OIDC **resource server**, (the "Konfi-Castle-ID"); this API acts as an OIDC **resource server**,
verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and
then resolving local `Membership` rows to determine role + KC/Gemeinde then resolving local `Membership` rows to determine role + KC/Gemeinde
scope. Clients perform the actual Authorization Code + PKCE flow against scope. Clients perform the actual Authorization Code + PKCE flow against
Authentik directly. Authentik directly.
- Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a
`passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde
Verantwortliche/r creates them directly or via a `TeamerInvite`
(shareable group link or per-email invite). Login is `POST /auth/team-login`
(email + password) or `POST /auth/teamer/register` (redeem an invite
token); both return a JWT signed with `TEAM_JWT_SECRET` and carrying
`typ: "team"`. `TeamJwtStrategy` (`AuthGuard('team')`) resolves it to the
same shape as `AuthentikStrategy`, so guards/controllers treat both alike.
- Guests/Konfis get a temporary local account (first/last name required, no - Guests/Konfis get a temporary local account (first/last name required, no
Authentik) created via `POST /auth/guest` with a KC invite code, returning Authentik) created via `POST /auth/guest` with a KC invite code, returning
a JWT signed with `GUEST_JWT_SECRET`. a JWT signed with `GUEST_JWT_SECRET`.
@@ -33,13 +41,24 @@ client's host - no separate web server is needed.
## Modules implemented so far ## Modules implemented so far
- `prisma/` — shared `PrismaClient` provider. - `prisma/` — shared `PrismaClient` provider.
- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) + - `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`),
guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`). guest invite-code login (`AuthGuard('guest')`), and local Gemeinde Teamer
auth (`AuthGuard('team')`): `POST /auth/team-login` and
`POST /auth/teamer/register` (invite redemption), bcrypt hashes, tokens
signed with `TEAM_JWT_SECRET`. `TokenVerificationService` (WS handshake)
now accepts Authentik, team, or guest tokens.
- `kc/` — KC (event) creation/listing, Leitungsteam-only. - `kc/` — KC (event) creation/listing, Leitungsteam-only.
- `gemeinde/` — Gemeinde (congregation) CRUD per KC (`POST /gemeinde`, - `gemeinde/` — Gemeinde (congregation) CRUD per KC (`POST /gemeinde`,
`GET /gemeinde?kcId=`, `GET/PATCH/DELETE /gemeinde/:id`), Leitungsteam-only. `GET /gemeinde?kcId=`, `GET/PATCH/DELETE /gemeinde/:id`), Leitungsteam-only.
Gemeinde Verantwortliche/Teamer get their own Gemeinde from their Gemeinde Verantwortliche/Teamer get their own Gemeinde from their
`Membership`, not from this endpoint. `Membership`, not from this endpoint.
- `teamer/` — local Gemeinde Teamer accounts + invites, under
`/gemeinde/:gemeindeId/...`: `POST/GET teamer`,
`DELETE teamer/:userId`, `POST/GET teamer-invites`,
`DELETE teamer-invites/:inviteId`. Callable by Leitungsteam (any Gemeinde)
or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`,
since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept
`'team'` tokens too, so Teamer see non-Konfi files and chat.
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest - `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
@@ -76,5 +95,8 @@ client's host - no separate web server is needed.
- `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped, - `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
Leitungsteam roles are global across all KCs). Leitungsteam roles are global across all KCs).
All planned backend phases are implemented; remaining work is the Flutter All planned backend phases are implemented. `npm test` runs Jest unit tests
clients (see repo root README). (`ZuteilungService`, `TeamAuthService`, `TeamerService`; Prisma mocked).
Remaining work: the Flutter clients (see repo root README), Authentik
provisioning for LT/Verantwortliche, and the first real Prisma migration
(only `schema.prisma` exists so far).
+18
View File
@@ -21,6 +21,7 @@
"@nestjs/serve-static": "^4.0.2", "@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.15", "@nestjs/websockets": "^10.4.15",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -37,6 +38,7 @@
"@nestjs/cli": "^10.4.9", "@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3", "@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15", "@nestjs/testing": "^10.4.15",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
@@ -2776,6 +2778,13 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -3923,6 +3932,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+2
View File
@@ -33,6 +33,7 @@
"@nestjs/serve-static": "^4.0.2", "@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.15", "@nestjs/websockets": "^10.4.15",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -49,6 +50,7 @@
"@nestjs/cli": "^10.4.9", "@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3", "@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15", "@nestjs/testing": "^10.4.15",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
+41 -11
View File
@@ -16,12 +16,14 @@ model Kc {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
gemeinden Gemeinde[] gemeinden Gemeinde[]
memberships Membership[] memberships Membership[]
wahlen Wahl[] wahlen Wahl[]
files File[] files File[]
channels ChatChannel[] channels ChatChannel[]
guests GuestAccount[] guests GuestAccount[]
localUsers User[]
teamerInvites TeamerInvite[]
} }
/// A local congregation/community participating in one Kc. /// A local congregation/community participating in one Kc.
@@ -31,9 +33,10 @@ model Gemeinde {
kcId String kcId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[] memberships Membership[]
guests GuestAccount[] guests GuestAccount[]
teamerInvites TeamerInvite[]
@@unique([kcId, name]) @@unique([kcId, name])
} }
@@ -44,15 +47,21 @@ enum Role {
GEMEINDE_TEAMER GEMEINDE_TEAMER
} }
/// Authentik-backed user (team member with elevated rights). /// A team member account. Leitungsteam and Gemeinde Verantwortliche are
/// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde
/// Teamer are local accounts created by a Verantwortliche/r (`passwordHash`
/// set, `authentikSub` null, `kcId` set) and, like guests, scoped to one KC.
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
authentikSub String @unique authentikSub String? @unique
email String @unique email String @unique
firstName String firstName String
lastName String lastName String
passwordHash String?
kcId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[] memberships Membership[]
messages ChatMessage[] messages ChatMessage[]
chatParticipations ChatParticipant[] chatParticipations ChatParticipant[]
@@ -90,6 +99,27 @@ model GuestAccount {
teilnehmer Teilnehmer[] teilnehmer Teilnehmer[]
} }
/// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer
/// can self-register a local account for one Gemeinde. 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 TeamerInvite {
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".
model Wahl { model Wahl {
id String @id @default(cuid()) id String @id @default(cuid())
+2
View File
@@ -6,6 +6,7 @@ import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { KcModule } from './kc/kc.module'; import { KcModule } from './kc/kc.module';
import { GemeindeModule } from './gemeinde/gemeinde.module'; import { GemeindeModule } from './gemeinde/gemeinde.module';
import { TeamerModule } from './teamer/teamer.module';
import { WahlModule } from './wahl/wahl.module'; import { WahlModule } from './wahl/wahl.module';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { ChatModule } from './chat/chat.module'; import { ChatModule } from './chat/chat.module';
@@ -25,6 +26,7 @@ import { SyncModule } from './sync/sync.module';
AuthModule, AuthModule,
KcModule, KcModule,
GemeindeModule, GemeindeModule,
TeamerModule,
WahlModule, WahlModule,
FilesModule, FilesModule,
ChatModule, ChatModule,
+19 -1
View File
@@ -1,14 +1,32 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Post } from '@nestjs/common';
import { GuestAuthService } from './guest-auth.service'; import { GuestAuthService } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
import { CreateGuestDto } from './dto/create-guest.dto'; import { CreateGuestDto } from './dto/create-guest.dto';
import { TeamLoginDto } from './dto/team-login.dto';
import { RegisterTeamerDto } from './dto/register-teamer.dto';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly guestAuth: GuestAuthService) {} constructor(
private readonly guestAuth: GuestAuthService,
private readonly teamAuth: TeamAuthService,
) {}
/// Redeems a KC invite code and registers a new temporary guest/Konfi account. /// Redeems a KC invite code and registers a new temporary guest/Konfi account.
@Post('guest') @Post('guest')
createGuest(@Body() dto: CreateGuestDto) { createGuest(@Body() dto: CreateGuestDto) {
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.
@Post('team-login')
teamLogin(@Body() dto: TeamLoginDto) {
return this.teamAuth.login(dto.email, dto.password);
}
/// Self-registration for a Gemeinde Teamer via an invite token/link.
@Post('teamer/register')
registerTeamer(@Body() dto: RegisterTeamerDto) {
return this.teamAuth.registerFromInvite(dto);
}
} }
+11 -2
View File
@@ -4,8 +4,10 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { GuestAuthService } from './guest-auth.service'; import { GuestAuthService } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
import { AuthentikStrategy } from './authentik.strategy'; import { AuthentikStrategy } from './authentik.strategy';
import { GuestJwtStrategy } from './guest-jwt.strategy'; import { GuestJwtStrategy } from './guest-jwt.strategy';
import { TeamJwtStrategy } from './team-jwt.strategy';
import { TokenVerificationService } from './token-verification.service'; import { TokenVerificationService } from './token-verification.service';
@Module({ @Module({
@@ -20,7 +22,14 @@ import { TokenVerificationService } from './token-verification.service';
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [GuestAuthService, AuthentikStrategy, GuestJwtStrategy, TokenVerificationService], providers: [
exports: [TokenVerificationService], GuestAuthService,
TeamAuthService,
AuthentikStrategy,
GuestJwtStrategy,
TeamJwtStrategy,
TokenVerificationService,
],
exports: [TokenVerificationService, TeamAuthService],
}) })
export class AuthModule {} export class AuthModule {}
+4 -2
View File
@@ -8,10 +8,12 @@ export interface AuthenticatedMembership {
role: Role; role: Role;
} }
/// Shape attached to req.user by JwtStrategy after validating an access token. /// Shape attached to req.user after validating an access token — by
/// AuthentikStrategy for Authentik-backed members, or by TeamJwtStrategy for
/// local Gemeinde Teamer (then `authentikSub` is null).
export interface AuthenticatedUser { export interface AuthenticatedUser {
userId: string; userId: string;
authentikSub: string; authentikSub: string | null;
email: string; email: string;
memberships: AuthenticatedMembership[]; memberships: AuthenticatedMembership[];
} }
@@ -0,0 +1,30 @@
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
export class RegisterTeamerDto {
@IsString()
@IsNotEmpty()
token!: string;
@IsString()
@IsNotEmpty()
firstName!: string;
@IsString()
@IsNotEmpty()
lastName!: string;
@IsString()
@MinLength(8)
password!: string;
/// Required for group-link invites; ignored/validated against a personal invite.
@IsOptional()
@IsEmail()
email?: string;
}
+10
View File
@@ -0,0 +1,10 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class TeamLoginDto {
@IsEmail()
email!: string;
@IsString()
@IsNotEmpty()
password!: string;
}
+232
View File
@@ -0,0 +1,232 @@
import {
ConflictException,
ForbiddenException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { TeamAuthService } from './team-auth.service';
/// Covers the branching in invite redemption and password login. Prisma and
/// SyncService are faked in memory; bcrypt/jsonwebtoken run for real.
const SECRET = 'test-team-secret';
interface InviteRow {
id: string;
kcId: string;
gemeindeId: string;
token: string;
email: string | null;
maxUses: number | null;
usedCount: number;
expiresAt: Date | null;
revokedAt: Date | null;
}
function makeService(seed: {
invites?: InviteRow[];
users?: { id: string; email: string; passwordHash: string | null }[];
}) {
const invites = [...(seed.invites ?? [])];
const users = [...(seed.users ?? [])].map((u) => ({
firstName: 'X',
lastName: 'Y',
authentikSub: null,
kcId: null,
createdAt: new Date(),
memberships: [] as unknown[],
...u,
}));
const prisma = {
user: {
findUnique: jest.fn(({ where }: { where: { email?: string; id?: string } }) =>
Promise.resolve(
users.find(
(u) =>
(where.email !== undefined && u.email === where.email) ||
(where.id !== undefined && u.id === where.id),
) ?? null,
),
),
findFirst: jest.fn(({ where }: { where: { id: string } }) =>
Promise.resolve(users.find((u) => u.id === where.id && u.passwordHash) ?? null),
),
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
const row = { id: `u-${users.length + 1}`, memberships: [], ...data } as never;
users.push(row);
return Promise.resolve(row);
}),
},
membership: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: `m-1`, ...data }),
),
},
teamerInvite: {
findUnique: jest.fn(({ where }: { where: { token: string } }) =>
Promise.resolve(invites.find((i) => i.token === where.token) ?? null),
),
update: jest.fn(({ where, data }: { where: { id: string }; data: { usedCount: { increment: number } } }) => {
const inv = invites.find((i) => i.id === where.id)!;
inv.usedCount += data.usedCount.increment;
return Promise.resolve(inv);
}),
},
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const config = { getOrThrow: jest.fn().mockReturnValue(SECRET) };
const service = new TeamAuthService(prisma as never, config as never, sync as never);
return { service, prisma, sync, users, invites };
}
function invite(overrides: Partial<InviteRow> = {}): InviteRow {
return {
id: 'inv-1',
kcId: 'kc-1',
gemeindeId: 'gem-1',
token: 'tok-1',
email: null,
maxUses: null,
usedCount: 0,
expiresAt: null,
revokedAt: null,
...overrides,
};
}
const base = {
token: 'tok-1',
firstName: 'Mara',
lastName: 'Klein',
password: 'supersecret',
};
describe('TeamAuthService.registerFromInvite', () => {
it('rejects an unknown token', async () => {
const { service } = makeService({ invites: [] });
await expect(
service.registerFromInvite({ ...base, email: 'm@example.org' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a revoked invite', async () => {
const { service } = makeService({ invites: [invite({ revokedAt: new Date() })] });
await expect(
service.registerFromInvite({ ...base, email: 'm@example.org' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects an expired invite', async () => {
const { service } = makeService({
invites: [invite({ expiresAt: new Date(Date.now() - 1000) })],
});
await expect(
service.registerFromInvite({ ...base, email: 'm@example.org' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects an invite that is used up', async () => {
const { service } = makeService({
invites: [invite({ maxUses: 2, usedCount: 2 })],
});
await expect(
service.registerFromInvite({ ...base, email: 'm@example.org' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('requires an email for a group-link invite', async () => {
const { service } = makeService({ invites: [invite({ email: null })] });
await expect(service.registerFromInvite({ ...base })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('rejects an email that does not match a personal invite', async () => {
const { service } = makeService({
invites: [invite({ email: 'pinned@example.org' })],
});
await expect(
service.registerFromInvite({ ...base, email: 'other@example.org' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects when an account with that email already exists', async () => {
const { service } = makeService({
invites: [invite()],
users: [{ id: 'u-x', email: 'm@example.org', passwordHash: 'h' }],
});
await expect(
service.registerFromInvite({ ...base, email: 'm@example.org' }),
).rejects.toBeInstanceOf(ConflictException);
});
it('creates a local Teamer + GEMEINDE_TEAMER membership and burns one use', async () => {
const { service, prisma, sync, invites } = makeService({ invites: [invite()] });
const res = await service.registerFromInvite({ ...base, email: 'M@Example.org' });
expect(res.accessToken).toEqual(expect.any(String));
expect(prisma.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
email: 'm@example.org',
kcId: 'kc-1',
passwordHash: expect.any(String),
}),
});
const createdHash = prisma.user.create.mock.calls[0][0].data.passwordHash as string;
expect(await bcrypt.compare('supersecret', createdHash)).toBe(true);
expect(prisma.membership.create).toHaveBeenCalledWith({
data: expect.objectContaining({
kcId: 'kc-1',
gemeindeId: 'gem-1',
role: Role.GEMEINDE_TEAMER,
}),
});
expect(invites[0].usedCount).toBe(1);
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', expect.any(String), expect.anything());
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', expect.any(String), expect.anything());
expect(sync.capture).toHaveBeenCalledWith('TeamerInvite', 'UPDATE', expect.any(String), expect.anything());
});
});
describe('TeamAuthService.login', () => {
it('rejects an unknown email', async () => {
const { service } = makeService({ users: [] });
await expect(service.login('nobody@example.org', 'x')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('rejects a user without a password hash (Authentik-only account)', async () => {
const { service } = makeService({
users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }],
});
await expect(service.login('lt@example.org', 'x')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('rejects a wrong password', async () => {
const { service } = makeService({
users: [
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
],
});
await expect(service.login('t@example.org', 'wrong')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('issues a token for correct credentials', async () => {
const { service } = makeService({
users: [
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
],
});
const res = await service.login('T@example.org', 'right');
expect(res.accessToken).toEqual(expect.any(String));
});
});
+160
View File
@@ -0,0 +1,160 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Role, SyncOperation } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import * as jwt from 'jsonwebtoken';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
export interface TeamJwtPayload {
sub: string;
typ: 'team';
}
const TOKEN_TTL = '12h';
const BCRYPT_ROUNDS = 10;
/// Local (non-Authentik) auth for Gemeinde Teamer: password login plus
/// redemption of a TeamerInvite issued by a Gemeinde Verantwortliche/r. Team
/// tokens are signed with TEAM_JWT_SECRET and carry `typ: 'team'` so they are
/// never mistaken for a guest token.
@Injectable()
export class TeamAuthService {
private readonly secret: string;
constructor(
private readonly prisma: PrismaClient,
private readonly config: ConfigService,
private readonly sync: SyncService,
) {
this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET');
}
async login(email: string, password: string): Promise<{ accessToken: string }> {
const user = await this.prisma.user.findUnique({
where: { email: email.toLowerCase() },
include: { memberships: true },
});
if (!user || !user.passwordHash) {
throw new UnauthorizedException('Invalid credentials');
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
}
return { accessToken: this.sign(user.id) };
}
/// Redeems an invite token and creates the local Teamer account + its
/// GEMEINDE_TEAMER membership for the invite's Gemeinde.
async registerFromInvite(input: {
token: string;
firstName: string;
lastName: string;
password: string;
email?: string;
}): Promise<{ accessToken: string }> {
const invite = await this.prisma.teamerInvite.findUnique({
where: { token: input.token },
});
if (!invite || invite.revokedAt) {
throw new NotFoundException('Unknown or revoked invite');
}
if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) {
throw new ForbiddenException('Invite has expired');
}
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
throw new ForbiddenException('Invite has already been used up');
}
if (
invite.email &&
input.email &&
input.email.toLowerCase() !== invite.email.toLowerCase()
) {
throw new ForbiddenException('Email does not match this invite');
}
const email = (invite.email ?? input.email ?? '').toLowerCase();
if (!email) {
throw new ConflictException('This invite requires an email address');
}
if (await this.prisma.user.findUnique({ where: { email } })) {
throw new ConflictException('An account with this email already exists');
}
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS);
const user = await this.prisma.user.create({
data: {
email,
firstName: input.firstName,
lastName: input.lastName,
passwordHash,
kcId: invite.kcId,
},
});
const membership = await this.prisma.membership.create({
data: {
userId: user.id,
kcId: invite.kcId,
gemeindeId: invite.gemeindeId,
role: Role.GEMEINDE_TEAMER,
},
});
const updatedInvite = await this.prisma.teamerInvite.update({
where: { id: invite.id },
data: { usedCount: { increment: 1 } },
});
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updatedInvite.id, updatedInvite);
return { accessToken: this.sign(user.id) };
}
private sign(userId: string): string {
const payload: TeamJwtPayload = { sub: userId, typ: 'team' };
return jwt.sign(payload, this.secret, { expiresIn: TOKEN_TTL });
}
/// Verifies a raw team token (used by the WS handshake path, outside passport).
async verify(token: string): Promise<AuthenticatedUser> {
let payload: TeamJwtPayload;
try {
payload = jwt.verify(token, this.secret) as TeamJwtPayload;
} catch {
throw new UnauthorizedException('Invalid team token');
}
if (payload.typ !== 'team' || !payload.sub) {
throw new UnauthorizedException('Not a team token');
}
return this.resolve(payload.sub);
}
async resolve(userId: string): Promise<AuthenticatedUser> {
const user = await this.prisma.user.findFirst({
where: { id: userId, passwordHash: { not: null } },
include: { memberships: true },
});
if (!user) {
throw new UnauthorizedException('Team account no longer exists');
}
return {
userId: user.id,
authentikSub: user.authentikSub,
email: user.email,
memberships: user.memberships.map((m) => ({
kcId: m.kcId,
gemeindeId: m.gemeindeId,
role: m.role,
})),
};
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthenticatedUser } from './authenticated-request';
import { TeamAuthService, TeamJwtPayload } from './team-auth.service';
/// Verifies the local JWT issued to Gemeinde Teamer by TeamAuthService and
/// resolves it to the same AuthenticatedUser shape as AuthentikStrategy, so
/// downstream RolesGuard / controllers treat both member kinds identically.
@Injectable()
export class TeamJwtStrategy extends PassportStrategy(Strategy, 'team') {
constructor(
config: ConfigService,
private readonly teamAuth: TeamAuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.getOrThrow<string>('TEAM_JWT_SECRET'),
});
}
validate(payload: TeamJwtPayload): Promise<AuthenticatedUser> {
return this.teamAuth.resolve(payload.sub);
}
}
@@ -6,6 +6,7 @@ import * as jwksRsa from 'jwks-rsa';
import { PrismaClient } from '../prisma/prisma.module'; import { PrismaClient } from '../prisma/prisma.module';
import { AuthenticatedUser } from './authenticated-request'; import { AuthenticatedUser } from './authenticated-request';
import { GuestJwtPayload } from './guest-auth.service'; import { GuestJwtPayload } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for /// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply. /// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
@@ -18,6 +19,7 @@ export class TokenVerificationService {
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly guestJwt: JwtService, private readonly guestJwt: JwtService,
private readonly teamAuth: TeamAuthService,
) { ) {
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL'); this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
@@ -61,12 +63,17 @@ export class TokenVerificationService {
return this.guestJwt.verifyAsync<GuestJwtPayload>(token); return this.guestJwt.verifyAsync<GuestJwtPayload>(token);
} }
/// Tries Authentik first (team member), then falls back to a guest token. /// Tries Authentik, then a local team (Teamer) token, then a guest token.
async verifyEither(token: string): Promise< async verifyEither(token: string): Promise<
{ kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload } { kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }
> { > {
try { try {
return { kind: 'user', user: await this.verifyAuthentik(token) }; return { kind: 'user', user: await this.verifyAuthentik(token) };
} catch {
// not an Authentik token
}
try {
return { kind: 'user', user: await this.teamAuth.verify(token) };
} catch { } catch {
return { kind: 'guest', guest: await this.verifyGuest(token) }; return { kind: 'guest', guest: await this.verifyGuest(token) };
} }
+5 -4
View File
@@ -24,21 +24,22 @@ export class ChatController {
return this.chat.createChannel(kcId, dto.type, dto.gemeindeId); return this.chat.createChannel(kcId, dto.type, dto.gemeindeId);
} }
/// Any two team members of the same KC can start a direct conversation. /// Any two team members of the same KC can start a direct conversation
/// (Authentik-backed members and local Gemeinde Teamer alike).
@Post('direct') @Post('direct')
@UseGuards(AuthGuard('authentik')) @UseGuards(AuthGuard(['authentik', 'team']))
createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) { createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) {
return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId); return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId);
} }
@Get(':kcId/channels') @Get(':kcId/channels')
@UseGuards(AuthGuard(['authentik', 'guest'])) @UseGuards(AuthGuard(['authentik', 'team', 'guest']))
listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) { listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) {
return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!)); return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!));
} }
@Get('channels/:channelId/messages') @Get('channels/:channelId/messages')
@UseGuards(AuthGuard(['authentik', 'guest'])) @UseGuards(AuthGuard(['authentik', 'team', 'guest']))
listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) { listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) {
return this.chat.listMessages(channelId, resolveChatCaller(req.user!)); return this.chat.listMessages(channelId, resolveChatCaller(req.user!));
} }
+2 -2
View File
@@ -47,7 +47,7 @@ export class FilesController {
} }
@Get(':kcId') @Get(':kcId')
@UseGuards(AuthGuard(['authentik', 'guest'])) @UseGuards(AuthGuard(['authentik', 'team', 'guest']))
list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) { list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) {
const allowed = isGuest(req.user) const allowed = isGuest(req.user)
? GUEST_ALLOWED_VISIBILITIES ? GUEST_ALLOWED_VISIBILITIES
@@ -56,7 +56,7 @@ export class FilesController {
} }
@Get('download/:fileId') @Get('download/:fileId')
@UseGuards(AuthGuard(['authentik', 'guest'])) @UseGuards(AuthGuard(['authentik', 'team', 'guest']))
async download( async download(
@Param('fileId') fileId: string, @Param('fileId') fileId: string,
@Req() req: FileCallerRequest, @Req() req: FileCallerRequest,
+3
View File
@@ -6,6 +6,9 @@ import { PrismaClient } from '../prisma/prisma.module';
const SYNCED_MODELS = [ const SYNCED_MODELS = [
'Kc', 'Kc',
'Gemeinde', 'Gemeinde',
'User',
'Membership',
'TeamerInvite',
'GuestAccount', 'GuestAccount',
'Wahl', 'Wahl',
'Workshop', 'Workshop',
@@ -0,0 +1,21 @@
import { IsEmail, IsInt, IsOptional, Min } from 'class-validator';
export class CreateTeamerInviteDto {
/// Set for a personal invite pinned to one address; omit for a shareable
/// group link.
@IsOptional()
@IsEmail()
email?: string;
/// Max redemptions. Defaults to 1 for a personal invite, unlimited for a
/// group link.
@IsOptional()
@IsInt()
@Min(1)
maxUses?: number;
@IsOptional()
@IsInt()
@Min(1)
expiresInHours?: number;
}
@@ -0,0 +1,18 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class CreateTeamerDto {
@IsString()
@IsNotEmpty()
firstName!: string;
@IsString()
@IsNotEmpty()
lastName!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}
+74
View File
@@ -0,0 +1,74 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { TeamerService } from './teamer.service';
import { CreateTeamerDto } from './dto/create-teamer.dto';
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
import { AuthenticatedRequest } from '../auth/authenticated-request';
/// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and
/// Gemeinde Verantwortliche (both Authentik-backed); TeamerService then
/// checks the caller is actually responsible for `:gemeindeId`.
@Controller('gemeinde/:gemeindeId')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER)
export class TeamerController {
constructor(private readonly teamer: TeamerService) {}
@Post('teamer')
create(
@Param('gemeindeId') gemeindeId: string,
@Body() dto: CreateTeamerDto,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.createTeamer(req.user!, gemeindeId, dto);
}
@Get('teamer')
list(@Param('gemeindeId') gemeindeId: string, @Req() req: AuthenticatedRequest) {
return this.teamer.listTeamer(req.user!, gemeindeId);
}
@Delete('teamer/:userId')
remove(
@Param('gemeindeId') gemeindeId: string,
@Param('userId') userId: string,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.removeTeamer(req.user!, gemeindeId, userId);
}
@Post('teamer-invites')
createInvite(
@Param('gemeindeId') gemeindeId: string,
@Body() dto: CreateTeamerInviteDto,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.createInvite(req.user!, gemeindeId, dto);
}
@Get('teamer-invites')
listInvites(@Param('gemeindeId') gemeindeId: string, @Req() req: AuthenticatedRequest) {
return this.teamer.listInvites(req.user!, gemeindeId);
}
@Delete('teamer-invites/:inviteId')
revokeInvite(
@Param('gemeindeId') gemeindeId: string,
@Param('inviteId') inviteId: string,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.revokeInvite(req.user!, gemeindeId, inviteId);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TeamerService } from './teamer.service';
import { TeamerController } from './teamer.controller';
@Module({
providers: [TeamerService],
controllers: [TeamerController],
})
export class TeamerModule {}
+167
View File
@@ -0,0 +1,167 @@
import {
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { TeamerService } from './teamer.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Focus: the Gemeinde-scope check (assertCanManage) and the create/invite
/// branching. Prisma + SyncService faked in memory.
const GEMEINDE = { id: 'gem-1', name: 'Nord', kcId: 'kc-1', createdAt: new Date() };
function caller(memberships: AuthenticatedUser['memberships']): AuthenticatedUser {
return { userId: 'caller-1', authentikSub: 'sub-1', email: 'c@example.org', memberships };
}
const LT = caller([{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]);
const VERANTW_GEM1 = caller([
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
]);
const VERANTW_GEM2 = caller([
{ kcId: 'kc-1', gemeindeId: 'gem-2', role: Role.GEMEINDE_VERANTWORTLICHER },
]);
function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?: string[] } = {}) {
const gemeinde = opts.gemeinde === undefined ? GEMEINDE : opts.gemeinde;
const emails = new Set(opts.existingEmails ?? []);
const created: Record<string, unknown> = {};
const prisma = {
gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) },
user: {
findUnique: jest.fn(({ where }: { where: { email: string } }) =>
Promise.resolve(emails.has(where.email) ? { id: 'dup', email: where.email } : null),
),
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
created.user = { id: 'u-1', createdAt: new Date(), ...data };
return Promise.resolve(created.user);
}),
delete: jest.fn().mockResolvedValue({ id: 'u-1' }),
},
membership: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
created.membership = { id: 'm-1', ...data };
return Promise.resolve(created.membership);
}),
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
teamerInvite: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'inv-1', usedCount: 0, revokedAt: null, ...data }),
),
},
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const service = new TeamerService(prisma as never, sync as never);
return { service, prisma, sync, created };
}
describe('TeamerService scope check', () => {
it('404s when the Gemeinde does not exist', async () => {
const { service } = makeService({ gemeinde: null });
await expect(service.listTeamer(LT, 'gem-x')).rejects.toBeInstanceOf(NotFoundException);
});
it('lets the Leitungsteam manage any Gemeinde', async () => {
const { service, prisma } = makeService();
await expect(service.listTeamer(LT, 'gem-1')).resolves.toEqual([]);
});
it('lets a Verantwortliche/r manage their own Gemeinde', async () => {
const { service, prisma } = makeService();
await expect(service.listTeamer(VERANTW_GEM1, 'gem-1')).resolves.toEqual([]);
});
it('forbids a Verantwortliche/r from managing a different Gemeinde', async () => {
const { service } = makeService();
await expect(service.listTeamer(VERANTW_GEM2, 'gem-1')).rejects.toBeInstanceOf(
ForbiddenException,
);
});
});
describe('TeamerService.createTeamer', () => {
it('rejects a duplicate email', async () => {
const { service } = makeService({ existingEmails: ['dup@example.org'] });
await expect(
service.createTeamer(VERANTW_GEM1, 'gem-1', {
firstName: 'A',
lastName: 'B',
email: 'dup@example.org',
password: 'password1',
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('creates a hashed local account + GEMEINDE_TEAMER membership and hides the hash', async () => {
const { service, created, sync } = makeService();
const res = await service.createTeamer(VERANTW_GEM1, 'gem-1', {
firstName: 'Ada',
lastName: 'Lo',
email: 'Ada@Example.org',
password: 'password1',
});
expect(res).not.toHaveProperty('passwordHash');
expect(res.email).toBe('ada@example.org');
expect((created.user as { kcId: string }).kcId).toBe('kc-1');
expect(
await bcrypt.compare('password1', (created.user as { passwordHash: string }).passwordHash),
).toBe(true);
expect((created.membership as { role: Role }).role).toBe(Role.GEMEINDE_TEAMER);
expect((created.membership as { gemeindeId: string }).gemeindeId).toBe('gem-1');
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-1', expect.anything());
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', 'm-1', expect.anything());
});
});
describe('TeamerService.createInvite', () => {
it('defaults a group link to unlimited uses and no expiry', async () => {
const { service } = makeService();
const inv = await service.createInvite(LT, 'gem-1', {});
expect(inv.email).toBeNull();
expect(inv.maxUses).toBeNull();
expect(inv.expiresAt).toBeNull();
expect(inv.token).toEqual(expect.any(String));
});
it('defaults a personal invite to a single use and lowercases the email', async () => {
const { service } = makeService();
const inv = await service.createInvite(LT, 'gem-1', { email: 'New@Example.org' });
expect(inv.email).toBe('new@example.org');
expect(inv.maxUses).toBe(1);
});
it('turns expiresInHours into a concrete expiry', async () => {
const { service } = makeService();
const before = Date.now();
const inv = await service.createInvite(LT, 'gem-1', { expiresInHours: 48 });
const ms = (inv.expiresAt as Date).getTime() - before;
expect(ms).toBeGreaterThan(47 * 3600_000);
expect(ms).toBeLessThan(49 * 3600_000);
});
});
describe('TeamerService.removeTeamer', () => {
it('404s when the user is not a local Teamer of that Gemeinde', async () => {
const { service } = makeService();
await expect(service.removeTeamer(LT, 'gem-1', 'u-9')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('deletes the account and captures a User DELETE', async () => {
const { service, prisma, sync } = makeService();
prisma.membership.findFirst = jest
.fn()
.mockResolvedValue({ userId: 'u-1', gemeindeId: 'gem-1', user: { passwordHash: 'h' } });
const res = await service.removeTeamer(LT, 'gem-1', 'u-1');
expect(res).toEqual({ id: 'u-1' });
expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 'u-1' } });
expect(sync.capture).toHaveBeenCalledWith('User', 'DELETE', 'u-1', { id: 'u-1' });
});
});
+182
View File
@@ -0,0 +1,182 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import { Role, SyncOperation } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
const BCRYPT_ROUNDS = 10;
type PublicUser = {
id: string;
email: string;
firstName: string;
lastName: string;
createdAt: Date;
};
/// Management of local Gemeinde Teamer accounts and their invites. Callable by
/// the Leitungsteam (any Gemeinde) or by a Gemeinde Verantwortliche/r for
/// their own Gemeinde only.
@Injectable()
export class TeamerService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
async createTeamer(
caller: AuthenticatedUser,
gemeindeId: string,
input: { firstName: string; lastName: string; email: string; password: string },
): Promise<PublicUser> {
const gemeinde = await this.assertCanManage(caller, gemeindeId);
const email = input.email.toLowerCase();
if (await this.prisma.user.findUnique({ where: { email } })) {
throw new ConflictException('An account with this email already exists');
}
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS);
const user = await this.prisma.user.create({
data: {
email,
firstName: input.firstName,
lastName: input.lastName,
passwordHash,
kcId: gemeinde.kcId,
},
});
const membership = await this.prisma.membership.create({
data: {
userId: user.id,
kcId: gemeinde.kcId,
gemeindeId,
role: Role.GEMEINDE_TEAMER,
},
});
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
return toPublicUser(user);
}
async listTeamer(caller: AuthenticatedUser, gemeindeId: string): Promise<PublicUser[]> {
await this.assertCanManage(caller, gemeindeId);
const memberships = await this.prisma.membership.findMany({
where: { gemeindeId, role: Role.GEMEINDE_TEAMER },
include: { user: true },
orderBy: { user: { lastName: 'asc' } },
});
return memberships.map((m) => toPublicUser(m.user));
}
async removeTeamer(
caller: AuthenticatedUser,
gemeindeId: string,
userId: string,
): Promise<{ id: string }> {
await this.assertCanManage(caller, gemeindeId);
const membership = await this.prisma.membership.findFirst({
where: { userId, gemeindeId, role: Role.GEMEINDE_TEAMER },
include: { user: true },
});
if (!membership || !membership.user.passwordHash) {
throw new NotFoundException('No local Teamer account for this Gemeinde');
}
await this.prisma.user.delete({ where: { id: userId } });
await this.sync.capture('User', SyncOperation.DELETE, userId, { id: userId });
return { id: userId };
}
async createInvite(
caller: AuthenticatedUser,
gemeindeId: string,
dto: CreateTeamerInviteDto,
) {
const gemeinde = await this.assertCanManage(caller, gemeindeId);
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.teamerInvite.create({
data: {
kcId: gemeinde.kcId,
gemeindeId,
token: randomBytes(24).toString('base64url'),
email,
maxUses,
expiresAt,
createdByUserId: caller.userId,
},
});
await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, invite);
return invite;
}
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {
await this.assertCanManage(caller, gemeindeId);
return this.prisma.teamerInvite.findMany({
where: { gemeindeId },
orderBy: { createdAt: 'desc' },
});
}
async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) {
await this.assertCanManage(caller, gemeindeId);
const invite = await this.prisma.teamerInvite.findFirst({
where: { id: inviteId, gemeindeId },
});
if (!invite) {
throw new NotFoundException('Invite not found');
}
const updated = await this.prisma.teamerInvite.update({
where: { id: inviteId },
data: { revokedAt: new Date() },
});
await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updated.id, updated);
return updated;
}
/// LT may manage every Gemeinde; a Verantwortliche/r only the one they hold
/// that role for. Returns the Gemeinde (for its kcId) on success.
private async assertCanManage(caller: AuthenticatedUser, gemeindeId: string) {
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
if (!gemeinde) {
throw new NotFoundException('Gemeinde not found');
}
const isLeitungsteam = caller.memberships.some(
(m) => m.role === Role.LEITUNGSTEAM,
);
const isVerantwortlich = caller.memberships.some(
(m) => m.role === Role.GEMEINDE_VERANTWORTLICHER && m.gemeindeId === gemeindeId,
);
if (!isLeitungsteam && !isVerantwortlich) {
throw new ForbiddenException('Not responsible for this Gemeinde');
}
return gemeinde;
}
}
function toPublicUser(user: {
id: string;
email: string;
firstName: string;
lastName: string;
createdAt: Date;
}): PublicUser {
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
createdAt: user.createdAt,
};
}
+15 -7
View File
@@ -12,7 +12,7 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/T
- **Gemeinde**: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (`kcId` + `name`, eindeutig pro KC). - **Gemeinde**: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (`kcId` + `name`, eindeutig pro KC).
- **Rollenmodell** (Enum `Role`, Authentik-gestützt): - **Rollenmodell** (Enum `Role`, Authentik-gestützt):
- **Leitungsteam (LT)** global über alle KCs hinweg (Authentik-Gruppe); bleibt LT auf jedem KC, bis die Authentik-Gruppenmitgliedschaft entfernt wird. - **Leitungsteam (LT)** global über alle KCs hinweg (Authentik-Gruppe); bleibt LT auf jedem KC, bis die Authentik-Gruppenmitgliedschaft entfernt wird.
- **Gemeinde Verantwortliche** pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT. - **Gemeinde Verantwortliche** pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer.
- **Gemeinde Teamer** von Verantwortlichen angelegt, ebenfalls Lokaler Account. - **Gemeinde Teamer** von Verantwortlichen angelegt, ebenfalls Lokaler Account.
- **Guest/Konfi** optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events). - **Guest/Konfi** optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events).
- **Membership**: verknüpft `User` (Authentik) ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`. LT-Memberships lassen `gemeindeId` leer und gelten global. - **Membership**: verknüpft `User` (Authentik) ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`. LT-Memberships lassen `gemeindeId` leer und gelten global.
@@ -39,6 +39,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
| Web-Interimslösung | Backend liefert `client/web/` (reines HTML/CSS/JS, kein Build-Schritt) über `ServeStaticModule` aus; REST-API liegt unter `/api/*` | Nutzerwunsch: "Server soll auch Web-Client bereitstellen"; vermeidet Kollision zwischen API-Routen und statischen Dateien | | Web-Interimslösung | Backend liefert `client/web/` (reines HTML/CSS/JS, kein Build-Schritt) über `ServeStaticModule` aus; REST-API liegt unter `/api/*` | Nutzerwunsch: "Server soll auch Web-Client bereitstellen"; vermeidet Kollision zwischen API-Routen und statischen Dateien |
| Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend | Clients machen Authorization Code + PKCE direkt gegen Authentik; Backend validiert nur Access Token + löst lokale `Membership` auf | | Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend | Clients machen Authorization Code + PKCE direkt gegen Authentik; Backend validiert nur Access Token + löst lokale `Membership` auf |
| Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik | | Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik |
| Auth (Gemeinde Teamer) | Lokale Accounts: `User` mit `passwordHash`+`kcId`, `authentikSub` bleibt leer; eigenes JWT (`TEAM_JWT_SECRET`, Payload `typ:'team'`), Passwort-Login oder Invite-Redemption. Verantwortliche legen Teamer an (Direkt/Gruppen-Link/E-Mail-Invite) | Nutzervorgabe: Teamer laufen nicht über die Konfi-Castle-ID (Authentik), sondern werden pro KC lokal verwaltet (wie Guests, nur dauerhaft + mit Rolle) |
| Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` | | Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` |
| Chat-Transport | Raw `ws`-Gateway (`@nestjs/platform-ws`) statt Socket.IO | Passt zum schlanken REST-Stack; Rollen/Sichtbarkeits-Logik zentral in `ChatService`, geteilt zwischen REST und WS | | Chat-Transport | Raw `ws`-Gateway (`@nestjs/platform-ws`) statt Socket.IO | Passt zum schlanken REST-Stack; Rollen/Sichtbarkeits-Logik zentral in `ChatService`, geteilt zwischen REST und WS |
| Sync-Richtung | **Lokaler Server initiiert immer** Push *und* Pull gegen die Cloud-URL | Cloud kann i. d. R. nicht in ein lokales Eventnetzwerk zurückwählen (NAT); lokaler Server kann aber ausgehend zur Cloud verbinden, wenn Internet verfügbar ist | | Sync-Richtung | **Lokaler Server initiiert immer** Push *und* Pull gegen die Cloud-URL | Cloud kann i. d. R. nicht in ein lokales Eventnetzwerk zurückwählen (NAT); lokaler Server kann aber ausgehend zur Cloud verbinden, wenn Internet verfügbar ist |
@@ -49,7 +50,9 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
- **Datei-Bytes werden nicht repliziert** nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Datei-Bytes werden nicht repliziert** nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist.
- **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit).
- **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt.
- **Authentik-Provisionierung**: Wenn ein Gemeinde Verantwortlicher einen Teamer anlegt, muss dieser aktuell weiterhin manuell (oder über eine noch zu bauende Authentik-Admin-API-Integration) in Authentik angelegt werden — das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits existiert, bevor er sich einloggen kann. - **Authentik-Provisionierung (LT + Gemeinde Verantwortliche)**: Diese beiden Rollen laufen über die Konfi-Castle-ID (Authentik). Das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits lokal existiert, bevor er sich einloggen kann — ein automatischer Provisionierungs-/Sync-Pfad aus Authentik heraus fehlt noch. (Gemeinde Teamer brauchen das nicht mehr: seit dieser Session sind sie lokale Accounts, siehe `teamer/` + `auth/team-login`.)
- **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert.
- **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden.
--- ---
@@ -58,9 +61,10 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
| Modul | Kernfunktion | Wichtige Endpunkte | | Modul | Kernfunktion | Wichtige Endpunkte |
|---|---|---| |---|---|---|
| `prisma/` | Geteilter `PrismaClient`-Provider | | | `prisma/` | Geteilter `PrismaClient`-Provider | |
| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake) | `POST /api/auth/guest` | | `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert jetzt Authentik/Team/Guest) | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` |
| `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` |
| `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | | `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` |
| `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite; nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` |
| `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | | `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` |
| `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` | | `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` |
| `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` | | `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` |
@@ -103,7 +107,10 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud).
2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft).
3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token).
4. `ZuteilungService`: Jest-Unit-Tests (`src/wahl/zuteilung.service.spec.ts`, Prisma/Sync gemockt) decken Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops und die Sync-Capture-Anzahl ab. `npm test` grün. 4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 32 Tests):
- `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl.
- `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login.
- `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung.
5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). 5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud).
--- ---
@@ -111,6 +118,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
## 8. Nächste Schritte ## 8. Nächste Schritte
1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API.
2. Authentik-Admin-API-Integration für automatische Teamer-Provisionierung durch Gemeinde Verantwortliche. 2. Authentik-Provisionierung für LT + Gemeinde Verantwortliche automatisieren (JIT-Anlage des lokalen `User` beim ersten Login aus den Token-Claims, oder Sync aus der Authentik-Admin-API).
3. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. 3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt.
4. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen und die in Abschnitt 7 offenen Verifikationsschritte durchführen. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen.
5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen.