Introduce a CodeResolverService to classify user login codes, complete with detailed resolution logic and usability checks. Extend the sync system to handle conflicts via last-write-wins arbitration, with detailed conflict tracking for review. Update file permissions and runtime isolation in Docker to enhance security.
837 lines
26 KiB
Dart
837 lines
26 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'browser.dart' as browser;
|
|
import 'oidc.dart';
|
|
|
|
/// Backend base URL. Override at build/run time with
|
|
/// `--dart-define=API_BASE=https://...`.
|
|
const String kApiBase = String.fromEnvironment(
|
|
'API_BASE',
|
|
defaultValue: 'http://localhost:3000/api',
|
|
);
|
|
|
|
class ApiException implements Exception {
|
|
ApiException(this.statusCode, this.message);
|
|
final int statusCode;
|
|
final String message;
|
|
@override
|
|
String toString() => 'ApiException($statusCode): $message';
|
|
}
|
|
|
|
enum SessionKind { guest, user }
|
|
|
|
/// Whatever `GET /auth/me` told us about the current token.
|
|
class Identity {
|
|
Identity({
|
|
required this.kind,
|
|
this.guestId,
|
|
this.userId,
|
|
this.email,
|
|
this.kcId,
|
|
this.gemeindeId,
|
|
this.isLeitungsteam = false,
|
|
this.memberships = const [],
|
|
});
|
|
|
|
final SessionKind kind;
|
|
final String? guestId;
|
|
final String? userId;
|
|
final String? email;
|
|
final String? kcId;
|
|
final String? gemeindeId;
|
|
final bool isLeitungsteam;
|
|
final List<Membership> memberships;
|
|
|
|
factory Identity.fromJson(Map<String, dynamic> j) {
|
|
if (j['kind'] == 'guest') {
|
|
return Identity(
|
|
kind: SessionKind.guest,
|
|
guestId: j['guestId'] as String?,
|
|
kcId: j['kcId'] as String?,
|
|
gemeindeId: j['gemeindeId'] as String?,
|
|
);
|
|
}
|
|
final ms = (j['memberships'] as List<dynamic>? ?? [])
|
|
.map((m) => Membership.fromJson(m as Map<String, dynamic>))
|
|
.toList();
|
|
return Identity(
|
|
kind: SessionKind.user,
|
|
userId: j['userId'] as String?,
|
|
email: j['email'] as String?,
|
|
isLeitungsteam: j['isLeitungsteam'] as bool? ?? false,
|
|
memberships: ms,
|
|
kcId: ms.isNotEmpty ? ms.first.kcId : null,
|
|
gemeindeId: ms.isNotEmpty ? ms.first.gemeindeId : null,
|
|
);
|
|
}
|
|
|
|
String get roleLabel {
|
|
if (kind == SessionKind.guest) return 'Konfi / Gast';
|
|
if (isLeitungsteam) return 'Leitungsteam';
|
|
if (memberships.any((m) => m.role == 'GEMEINDE_VERANTWORTLICHER')) {
|
|
return 'Gemeinde Verantwortliche/r';
|
|
}
|
|
if (memberships.any((m) => m.role == 'GEMEINDE_TEAMER')) {
|
|
return 'Gemeinde Teamer:in';
|
|
}
|
|
return 'Angemeldet (ohne Rolle)';
|
|
}
|
|
}
|
|
|
|
class Membership {
|
|
Membership({required this.kcId, this.gemeindeId, required this.role});
|
|
final String kcId;
|
|
final String? gemeindeId;
|
|
final String role;
|
|
factory Membership.fromJson(Map<String, dynamic> j) => Membership(
|
|
kcId: j['kcId'] as String,
|
|
gemeindeId: j['gemeindeId'] as String?,
|
|
role: j['role'] as String,
|
|
);
|
|
}
|
|
|
|
class Kc {
|
|
Kc({required this.id, required this.name, required this.inviteCode, required this.isActive});
|
|
final String id;
|
|
final String name;
|
|
final String inviteCode;
|
|
final bool isActive;
|
|
factory Kc.fromJson(Map<String, dynamic> j) => Kc(
|
|
id: j['id'] as String,
|
|
name: j['name'] as String,
|
|
inviteCode: j['inviteCode'] as String? ?? '',
|
|
isActive: j['isActive'] as bool? ?? true,
|
|
);
|
|
}
|
|
|
|
class Gemeinde {
|
|
Gemeinde({required this.id, required this.name, required this.kcId});
|
|
final String id;
|
|
final String name;
|
|
final String kcId;
|
|
factory Gemeinde.fromJson(Map<String, dynamic> j) => Gemeinde(
|
|
id: j['id'] as String,
|
|
name: j['name'] as String,
|
|
kcId: j['kcId'] as String? ?? '',
|
|
);
|
|
}
|
|
|
|
class OnboardingRequest {
|
|
OnboardingRequest({
|
|
required this.id,
|
|
required this.userName,
|
|
required this.userEmail,
|
|
required this.gemeindeName,
|
|
});
|
|
final String id;
|
|
final String userName;
|
|
final String userEmail;
|
|
final String gemeindeName;
|
|
factory OnboardingRequest.fromJson(Map<String, dynamic> j) {
|
|
final u = j['user'] as Map<String, dynamic>? ?? const {};
|
|
final g = j['gemeinde'] as Map<String, dynamic>? ?? const {};
|
|
return OnboardingRequest(
|
|
id: j['id'] as String,
|
|
userName: [u['firstName'], u['lastName']].whereType<String>().join(' ').trim(),
|
|
userEmail: u['email'] as String? ?? '',
|
|
gemeindeName: g['name'] as String? ?? '',
|
|
);
|
|
}
|
|
}
|
|
|
|
class WahlAdmin {
|
|
WahlAdmin({
|
|
required this.id,
|
|
required this.name,
|
|
required this.datumsSchluessel,
|
|
required this.teil,
|
|
required this.isOpen,
|
|
});
|
|
final String id;
|
|
final String name;
|
|
final String datumsSchluessel;
|
|
final String teil;
|
|
final bool isOpen;
|
|
factory WahlAdmin.fromJson(Map<String, dynamic> j) => WahlAdmin(
|
|
id: j['id'] as String,
|
|
name: j['name'] as String,
|
|
datumsSchluessel: j['datumsSchluessel'] as String? ?? '',
|
|
teil: j['teil'] as String? ?? '',
|
|
isOpen: j['isOpen'] as bool? ?? true,
|
|
);
|
|
}
|
|
|
|
class WorkshopAdmin {
|
|
WorkshopAdmin({
|
|
required this.id,
|
|
required this.name,
|
|
required this.kapazitaet,
|
|
required this.minTeilnehmer,
|
|
});
|
|
final String id;
|
|
final String name;
|
|
final int kapazitaet;
|
|
final int minTeilnehmer;
|
|
factory WorkshopAdmin.fromJson(Map<String, dynamic> j) => WorkshopAdmin(
|
|
id: j['id'] as String,
|
|
name: j['name'] as String,
|
|
kapazitaet: (j['kapazitaet'] as num).toInt(),
|
|
minTeilnehmer: (j['minTeilnehmer'] as num?)?.toInt() ?? 0,
|
|
);
|
|
}
|
|
|
|
class TeilnehmerRow {
|
|
TeilnehmerRow({
|
|
required this.id,
|
|
required this.name,
|
|
required this.prioritaeten,
|
|
required this.forcedWorkshopId,
|
|
});
|
|
final String id;
|
|
final String name;
|
|
final List<String> prioritaeten;
|
|
final String? forcedWorkshopId;
|
|
factory TeilnehmerRow.fromJson(Map<String, dynamic> j) => TeilnehmerRow(
|
|
id: j['id'] as String,
|
|
name: j['name'] as String? ?? '',
|
|
prioritaeten:
|
|
(j['prioritaeten'] as List<dynamic>? ?? []).map((e) => e as String).toList(),
|
|
forcedWorkshopId: j['forcedWorkshopId'] as String?,
|
|
);
|
|
}
|
|
|
|
class ZuteilungRow {
|
|
ZuteilungRow({
|
|
required this.name,
|
|
required this.workshopName,
|
|
required this.wunschRang,
|
|
required this.isForced,
|
|
});
|
|
final String name;
|
|
final String? workshopName;
|
|
final int wunschRang;
|
|
final bool isForced;
|
|
factory ZuteilungRow.fromJson(Map<String, dynamic> j) {
|
|
final ga = (j['teilnehmer'] as Map<String, dynamic>?)?['guestAccount']
|
|
as Map<String, dynamic>? ??
|
|
const {};
|
|
return ZuteilungRow(
|
|
name: [ga['firstName'], ga['lastName']].whereType<String>().join(' ').trim(),
|
|
workshopName: (j['workshop'] as Map<String, dynamic>?)?['name'] as String?,
|
|
wunschRang: (j['wunschRang'] as num?)?.toInt() ?? -1,
|
|
isForced: j['isForced'] as bool? ?? false,
|
|
);
|
|
}
|
|
}
|
|
|
|
class TeamerAccount {
|
|
TeamerAccount({required this.id, required this.email, required this.name});
|
|
final String id;
|
|
final String email;
|
|
final String name;
|
|
factory TeamerAccount.fromJson(Map<String, dynamic> j) => TeamerAccount(
|
|
id: j['id'] as String,
|
|
email: j['email'] as String? ?? '',
|
|
name: [j['firstName'], j['lastName']].whereType<String>().join(' ').trim(),
|
|
);
|
|
}
|
|
|
|
class TeamerInvite {
|
|
TeamerInvite({
|
|
required this.id,
|
|
required this.token,
|
|
required this.email,
|
|
required this.usedCount,
|
|
required this.maxUses,
|
|
required this.revoked,
|
|
});
|
|
final String id;
|
|
final String token;
|
|
final String? email;
|
|
final int usedCount;
|
|
final int? maxUses;
|
|
final bool revoked;
|
|
factory TeamerInvite.fromJson(Map<String, dynamic> j) => TeamerInvite(
|
|
id: j['id'] as String,
|
|
token: j['token'] as String,
|
|
email: j['email'] as String?,
|
|
usedCount: (j['usedCount'] as num?)?.toInt() ?? 0,
|
|
maxUses: (j['maxUses'] as num?)?.toInt(),
|
|
revoked: j['revokedAt'] != null,
|
|
);
|
|
}
|
|
|
|
class Workshop {
|
|
Workshop({required this.id, required this.name, required this.kapazitaet});
|
|
final String id;
|
|
final String name;
|
|
final int kapazitaet;
|
|
factory Workshop.fromJson(Map<String, dynamic> j) => Workshop(
|
|
id: j['id'] as String,
|
|
name: j['name'] as String,
|
|
kapazitaet: (j['kapazitaet'] as num).toInt(),
|
|
);
|
|
}
|
|
|
|
class Wahl {
|
|
Wahl({
|
|
required this.id,
|
|
required this.name,
|
|
required this.datumsSchluessel,
|
|
required this.teil,
|
|
required this.workshops,
|
|
required this.meinePrioritaeten,
|
|
});
|
|
final String id;
|
|
final String name;
|
|
final String datumsSchluessel;
|
|
final String teil;
|
|
final List<Workshop> workshops;
|
|
final List<String>? meinePrioritaeten;
|
|
|
|
factory Wahl.fromJson(Map<String, dynamic> j) => Wahl(
|
|
id: j['id'] as String,
|
|
name: j['name'] as String,
|
|
datumsSchluessel: j['datumsSchluessel'] as String,
|
|
teil: j['teil'] as String,
|
|
workshops: (j['workshops'] as List<dynamic>)
|
|
.map((w) => Workshop.fromJson(w as Map<String, dynamic>))
|
|
.toList(),
|
|
meinePrioritaeten: (j['meinePrioritaeten'] as List<dynamic>?)
|
|
?.map((e) => e as String)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
class GuestOverview {
|
|
GuestOverview({required this.kcName, required this.wahlen});
|
|
final String kcName;
|
|
final List<Wahl> wahlen;
|
|
factory GuestOverview.fromJson(Map<String, dynamic> j) => GuestOverview(
|
|
kcName: (j['kc'] as Map<String, dynamic>?)?['name'] as String? ?? '',
|
|
wahlen: (j['wahlen'] as List<dynamic>)
|
|
.map((w) => Wahl.fromJson(w as Map<String, dynamic>))
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
enum WahlResultStatus { pending, assigned, unassigned }
|
|
|
|
class WahlResult {
|
|
WahlResult({
|
|
required this.wahlName,
|
|
required this.datumsSchluessel,
|
|
required this.teil,
|
|
required this.status,
|
|
required this.workshopName,
|
|
required this.wunschRang,
|
|
required this.isForced,
|
|
});
|
|
final String wahlName;
|
|
final String datumsSchluessel;
|
|
final String teil;
|
|
final WahlResultStatus status;
|
|
final String? workshopName;
|
|
final int? wunschRang;
|
|
final bool isForced;
|
|
|
|
factory WahlResult.fromJson(Map<String, dynamic> j) {
|
|
final wahl = j['wahl'] as Map<String, dynamic>;
|
|
return WahlResult(
|
|
wahlName: wahl['name'] as String,
|
|
datumsSchluessel: wahl['datumsSchluessel'] as String,
|
|
teil: wahl['teil'] as String,
|
|
status: switch (j['status'] as String?) {
|
|
'ASSIGNED' => WahlResultStatus.assigned,
|
|
'UNASSIGNED' => WahlResultStatus.unassigned,
|
|
_ => WahlResultStatus.pending,
|
|
},
|
|
workshopName: j['workshopName'] as String?,
|
|
wunschRang: (j['wunschRang'] as num?)?.toInt(),
|
|
isForced: j['isForced'] as bool? ?? false,
|
|
);
|
|
}
|
|
}
|
|
|
|
class FileEntry {
|
|
FileEntry({required this.id, required this.filename, required this.visibility});
|
|
final String id;
|
|
final String filename;
|
|
final String visibility;
|
|
factory FileEntry.fromJson(Map<String, dynamic> j) => FileEntry(
|
|
id: j['id'] as String,
|
|
filename: j['filename'] as String,
|
|
visibility: j['visibility'] as String? ?? '',
|
|
);
|
|
}
|
|
|
|
class ChatChannel {
|
|
ChatChannel({
|
|
required this.id,
|
|
required this.type,
|
|
this.name,
|
|
this.gemeindeId,
|
|
this.createdByUserId,
|
|
});
|
|
final String id;
|
|
final String type;
|
|
final String? name;
|
|
final String? gemeindeId;
|
|
final String? createdByUserId;
|
|
|
|
factory ChatChannel.fromJson(Map<String, dynamic> j) => ChatChannel(
|
|
id: j['id'] as String,
|
|
type: j['type'] as String? ?? '',
|
|
name: j['name'] as String?,
|
|
gemeindeId: j['gemeindeId'] as String?,
|
|
createdByUserId: j['createdByUserId'] as String?,
|
|
);
|
|
}
|
|
|
|
class ChatMessage {
|
|
ChatMessage({
|
|
this.id,
|
|
this.channelId,
|
|
this.senderUserId,
|
|
this.senderGuestId,
|
|
required this.body,
|
|
required this.createdAt,
|
|
});
|
|
final String? id;
|
|
final String? channelId;
|
|
final String? senderUserId;
|
|
final String? senderGuestId;
|
|
final String body;
|
|
final String createdAt;
|
|
|
|
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
|
|
id: j['id'] as String?,
|
|
channelId: j['channelId'] as String?,
|
|
senderUserId: j['senderUserId'] as String?,
|
|
senderGuestId: j['senderGuestId'] as String?,
|
|
body: j['body'] as String? ?? '',
|
|
createdAt: j['createdAt'] as String? ?? '',
|
|
);
|
|
}
|
|
|
|
/// Thin REST wrapper. Holds the bearer token for the current session.
|
|
class Api {
|
|
Api(this._client);
|
|
final http.Client _client;
|
|
String? token;
|
|
|
|
Map<String, String> get _headers => {
|
|
'Content-Type': 'application/json',
|
|
if (token != null) 'Authorization': 'Bearer $token',
|
|
};
|
|
|
|
Future<dynamic> _get(String path) async {
|
|
final res = await _client.get(Uri.parse('$kApiBase$path'), headers: _headers);
|
|
return _decode(res);
|
|
}
|
|
|
|
Future<dynamic> _post(String path, Object? body) async {
|
|
final res = await _client.post(
|
|
Uri.parse('$kApiBase$path'),
|
|
headers: _headers,
|
|
body: body == null ? null : jsonEncode(body),
|
|
);
|
|
return _decode(res);
|
|
}
|
|
|
|
Future<dynamic> _patch(String path, Object? body) async {
|
|
final res = await _client.patch(
|
|
Uri.parse('$kApiBase$path'),
|
|
headers: _headers,
|
|
body: body == null ? null : jsonEncode(body),
|
|
);
|
|
return _decode(res);
|
|
}
|
|
|
|
dynamic _decode(http.Response res) {
|
|
final text = res.body.isEmpty ? '{}' : res.body;
|
|
dynamic parsed;
|
|
try {
|
|
parsed = jsonDecode(text);
|
|
} catch (_) {
|
|
parsed = text;
|
|
}
|
|
if (res.statusCode >= 200 && res.statusCode < 300) return parsed;
|
|
final msg = parsed is Map && parsed['message'] != null
|
|
? (parsed['message'] is List
|
|
? (parsed['message'] as List).join(', ')
|
|
: parsed['message'].toString())
|
|
: 'HTTP ${res.statusCode}';
|
|
throw ApiException(res.statusCode, msg);
|
|
}
|
|
|
|
// --- auth ---
|
|
Future<String> guestLogin(String inviteCode, String firstName, String lastName) async {
|
|
final j = await _post('/auth/guest', {
|
|
'inviteCode': inviteCode,
|
|
'firstName': firstName,
|
|
'lastName': lastName,
|
|
});
|
|
return j['accessToken'] as String;
|
|
}
|
|
|
|
/// Logs a Gemeinde Teamer in by Gemeinde name (the normal path) or by
|
|
/// email (legacy/personal accounts) — pass exactly one of the two.
|
|
Future<String> teamLogin({String? gemeindeName, String? email, required String password}) async {
|
|
final j = await _post('/auth/team-login', {
|
|
if (gemeindeName != null && gemeindeName.isNotEmpty) 'gemeindeName': gemeindeName,
|
|
if (email != null && email.isNotEmpty) 'email': email,
|
|
'password': password,
|
|
});
|
|
return j['accessToken'] as String;
|
|
}
|
|
|
|
Future<String> redeemTeamerInvite({
|
|
required String inviteToken,
|
|
required String firstName,
|
|
required String lastName,
|
|
required String password,
|
|
String? email,
|
|
}) async {
|
|
final j = await _post('/auth/teamer/register', {
|
|
'token': inviteToken,
|
|
'firstName': firstName,
|
|
'lastName': lastName,
|
|
'password': password,
|
|
if (email != null && email.isNotEmpty) 'email': email,
|
|
});
|
|
return j['accessToken'] as String;
|
|
}
|
|
|
|
Future<Identity> me() async =>
|
|
Identity.fromJson(await _get('/auth/me') as Map<String, dynamic>);
|
|
|
|
// --- guest Wahl ---
|
|
Future<GuestOverview> guestWahlOverview() async =>
|
|
GuestOverview.fromJson(await _get('/wahl/guest/overview') as Map<String, dynamic>);
|
|
|
|
Future<void> submitPrioritaeten(String wahlId, List<String> workshopIds) async {
|
|
await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds});
|
|
}
|
|
|
|
Future<List<WahlResult>> guestWahlResults() async {
|
|
final list = await _get('/wahl/guest/results') as List<dynamic>;
|
|
return list.map((e) => WahlResult.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
// --- files ---
|
|
Future<List<FileEntry>> files(String kcId) async {
|
|
final list = await _get('/files/$kcId') as List<dynamic>;
|
|
return list.map((e) => FileEntry.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId';
|
|
|
|
/// WebSocket endpoint for the chat gateway. It lives at `/chat` (outside the
|
|
/// `/api` prefix) and authenticates via a `?token=` query param.
|
|
Uri chatWsUri() {
|
|
final base = Uri.parse(kApiBase);
|
|
return Uri(
|
|
scheme: base.scheme == 'https' ? 'wss' : 'ws',
|
|
host: base.host,
|
|
port: base.hasPort ? base.port : null,
|
|
path: '/chat',
|
|
queryParameters: {'token': token ?? ''},
|
|
);
|
|
}
|
|
|
|
// --- LT admin ---
|
|
Future<List<Kc>> kcs() async {
|
|
final list = await _get('/kc') as List<dynamic>;
|
|
return list.map((e) => Kc.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<Kc> createKc(String name) async =>
|
|
Kc.fromJson(await _post('/kc', {'name': name}) as Map<String, dynamic>);
|
|
|
|
Future<List<Gemeinde>> gemeinden(String kcId) async {
|
|
final list = await _get('/gemeinde?kcId=$kcId') as List<dynamic>;
|
|
return list.map((e) => Gemeinde.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<Gemeinde> createGemeinde(String kcId, String name) async => Gemeinde.fromJson(
|
|
await _post('/gemeinde', {'kcId': kcId, 'name': name}) as Map<String, dynamic>);
|
|
|
|
Future<List<OnboardingRequest>> onboardingRequests(String kcId) async {
|
|
final list = await _get('/onboarding/requests?kcId=$kcId') as List<dynamic>;
|
|
return list.map((e) => OnboardingRequest.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null);
|
|
Future<void> rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null);
|
|
|
|
// --- LT Wahl administration ---
|
|
Future<List<WahlAdmin>> wahlenForKc(String kcId) async {
|
|
final list = await _get('/wahl?kcId=$kcId') as List<dynamic>;
|
|
return list.map((e) => WahlAdmin.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> createWahl(
|
|
String kcId,
|
|
String name,
|
|
String datumsSchluessel,
|
|
String teil,
|
|
) =>
|
|
_post('/wahl', {
|
|
'kcId': kcId,
|
|
'name': name,
|
|
'datumsSchluessel': datumsSchluessel,
|
|
'teil': teil,
|
|
});
|
|
|
|
Future<List<WorkshopAdmin>> workshopsForWahl(String wahlId) async {
|
|
final list = await _get('/wahl/$wahlId/workshops') as List<dynamic>;
|
|
return list.map((e) => WorkshopAdmin.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> createWorkshop(
|
|
String wahlId,
|
|
String name,
|
|
int kapazitaet,
|
|
int minTeilnehmer,
|
|
) =>
|
|
_post('/wahl/$wahlId/workshops', {
|
|
'name': name,
|
|
'kapazitaet': kapazitaet,
|
|
'minTeilnehmer': minTeilnehmer,
|
|
});
|
|
|
|
Future<void> runZuteilung(String wahlId) => _post('/wahl/$wahlId/zuteilung/run', null);
|
|
|
|
Future<List<ZuteilungRow>> zuteilungResults(String wahlId) async {
|
|
final list = await _get('/wahl/$wahlId/zuteilung') as List<dynamic>;
|
|
return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> setWahlOpen(String wahlId, bool isOpen) =>
|
|
_patch('/wahl/$wahlId', {'isOpen': isOpen});
|
|
|
|
Future<List<TeilnehmerRow>> wahlTeilnehmer(String wahlId) async {
|
|
final list = await _get('/wahl/$wahlId/teilnehmer') as List<dynamic>;
|
|
return list.map((e) => TeilnehmerRow.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> forceZuteilung(String wahlId, String teilnehmerId, String workshopId) =>
|
|
_post('/wahl/$wahlId/force-zuteilung', {
|
|
'teilnehmerId': teilnehmerId,
|
|
'workshopId': workshopId,
|
|
});
|
|
|
|
Future<String> zuteilungCsv(String wahlId) async {
|
|
final res = await _get('/wahl/$wahlId/zuteilung/csv');
|
|
return res is String ? res : res.toString();
|
|
}
|
|
|
|
Future<void> uploadFile(
|
|
String kcId,
|
|
String filename,
|
|
List<int> bytes,
|
|
String visibility,
|
|
) async {
|
|
final req = http.MultipartRequest('POST', Uri.parse('$kApiBase/files/$kcId'))
|
|
..fields['visibility'] = visibility
|
|
..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename));
|
|
if (token != null) req.headers['Authorization'] = 'Bearer $token';
|
|
final streamed = await req.send();
|
|
final res = await http.Response.fromStream(streamed);
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
_decode(res); // throws ApiException with the server message
|
|
}
|
|
}
|
|
|
|
// --- Teamer administration (LT or the responsible Verantwortliche/r) ---
|
|
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
|
|
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
|
|
return list.map((e) => TeamerAccount.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<void> createTeamer(
|
|
String gemeindeId, {
|
|
required String firstName,
|
|
required String lastName,
|
|
required String email,
|
|
required String password,
|
|
}) =>
|
|
_post('/gemeinde/$gemeindeId/teamer', {
|
|
'firstName': firstName,
|
|
'lastName': lastName,
|
|
'email': email,
|
|
'password': password,
|
|
});
|
|
|
|
Future<List<TeamerInvite>> teamerInvitesFor(String gemeindeId) async {
|
|
final list = await _get('/gemeinde/$gemeindeId/teamer-invites') as List<dynamic>;
|
|
return list.map((e) => TeamerInvite.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<TeamerInvite> createTeamerInvite(
|
|
String gemeindeId, {
|
|
String? email,
|
|
int? maxUses,
|
|
int? expiresInHours,
|
|
}) async =>
|
|
TeamerInvite.fromJson(await _post('/gemeinde/$gemeindeId/teamer-invites', {
|
|
if (email != null && email.isNotEmpty) 'email': email,
|
|
'maxUses': ?maxUses,
|
|
'expiresInHours': ?expiresInHours,
|
|
}) as Map<String, dynamic>);
|
|
|
|
// --- Verantwortlichen self-registration ---
|
|
Future<Map<String, dynamic>> resolveInvite(String inviteCode) async =>
|
|
await _get('/onboarding/kc/$inviteCode') as Map<String, dynamic>;
|
|
|
|
Future<Map<String, dynamic>> registerVerantwortliche(
|
|
String inviteCode,
|
|
String gemeindeId,
|
|
) async =>
|
|
await _post('/onboarding/verantwortliche', {
|
|
'inviteCode': inviteCode,
|
|
'gemeindeId': gemeindeId,
|
|
}) as Map<String, dynamic>;
|
|
|
|
// --- push ---
|
|
Future<void> registerDevice(String token, {String platform = 'web'}) =>
|
|
_post('/push/register', {'token': token, 'platform': platform});
|
|
|
|
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
|
|
Future<List<ChatChannel>> channels(String kcId) async {
|
|
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
|
|
return list.map((e) => ChatChannel.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
|
|
Future<List<ChatMessage>> messages(String channelId) async {
|
|
final list = await _get('/chat/channels/$channelId/messages') as List<dynamic>;
|
|
return list.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>)).toList();
|
|
}
|
|
}
|
|
|
|
/// App-wide session + auth actions. Persists the token in shared_preferences
|
|
/// (localStorage on web).
|
|
class AppState extends ChangeNotifier {
|
|
AppState(this._api, {OidcClient? oidc}) : _oidc = oidc ?? OidcClient(http.Client());
|
|
final Api _api;
|
|
final OidcClient _oidc;
|
|
|
|
static const _tokenKey = 'kc_token';
|
|
static const _refreshKey = 'kc_refresh';
|
|
|
|
Identity? _identity;
|
|
Identity? get identity => _identity;
|
|
bool _loading = true;
|
|
bool get loading => _loading;
|
|
bool get isLoggedIn => _identity != null;
|
|
String? _authError;
|
|
String? get authError => _authError;
|
|
|
|
Api get api => _api;
|
|
|
|
Future<void> bootstrap() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
// 1. Are we landing on the OIDC redirect (?code=…)?
|
|
try {
|
|
final tokens = await _oidc.completeIfCallback();
|
|
if (tokens != null) {
|
|
await _establish(tokens.accessToken, refreshToken: tokens.refreshToken);
|
|
_loading = false;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
_authError = '$e';
|
|
}
|
|
|
|
// 2. Restore a stored session, refreshing an expired Authentik token.
|
|
final saved = prefs.getString(_tokenKey);
|
|
if (saved != null) {
|
|
_api.token = saved;
|
|
try {
|
|
_identity = await _api.me();
|
|
} catch (_) {
|
|
final refresh = prefs.getString(_refreshKey);
|
|
if (refresh != null) {
|
|
try {
|
|
final t = await _oidc.refresh(refresh);
|
|
await _establish(t.accessToken, refreshToken: t.refreshToken ?? refresh);
|
|
} catch (_) {
|
|
await _clear(prefs);
|
|
}
|
|
} else {
|
|
await _clear(prefs);
|
|
}
|
|
}
|
|
}
|
|
_loading = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> beginOidcLogin() => _oidc.beginLogin();
|
|
|
|
Future<void> _establish(String token, {String? refreshToken}) async {
|
|
_api.token = token;
|
|
_identity = await _api.me();
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_tokenKey, token);
|
|
if (refreshToken != null) {
|
|
await prefs.setString(_refreshKey, refreshToken);
|
|
}
|
|
_authError = null;
|
|
notifyListeners();
|
|
_registerForPush(); // best-effort, fire and forget
|
|
}
|
|
|
|
Future<void> _registerForPush() async {
|
|
try {
|
|
final pushToken = await browser.getPushToken();
|
|
if (pushToken != null) await _api.registerDevice(pushToken);
|
|
} catch (_) {
|
|
// push is optional
|
|
}
|
|
}
|
|
|
|
Future<void> _clear(SharedPreferences prefs) async {
|
|
_api.token = null;
|
|
await prefs.remove(_tokenKey);
|
|
await prefs.remove(_refreshKey);
|
|
}
|
|
|
|
Future<void> guestLogin(String code, String first, String last) =>
|
|
_api.guestLogin(code, first, last).then(_establish);
|
|
|
|
Future<void> teamLogin({String? gemeindeName, String? email, required String password}) =>
|
|
_api.teamLogin(gemeindeName: gemeindeName, email: email, password: password).then(_establish);
|
|
|
|
Future<void> redeemInvite({
|
|
required String token,
|
|
required String first,
|
|
required String last,
|
|
required String password,
|
|
String? email,
|
|
}) =>
|
|
_api
|
|
.redeemTeamerInvite(
|
|
inviteToken: token,
|
|
firstName: first,
|
|
lastName: last,
|
|
password: password,
|
|
email: email,
|
|
)
|
|
.then(_establish);
|
|
|
|
Future<void> logout() async {
|
|
_identity = null;
|
|
_authError = null;
|
|
await _clear(await SharedPreferences.getInstance());
|
|
notifyListeners();
|
|
}
|
|
}
|