added guardian login with views for their assigned childs

This commit is contained in:
2026-09-20 11:11:58 +02:00
parent e4e2b1a4fb
commit 67c935c05b
117 changed files with 4784 additions and 1514 deletions
+23
View File
@@ -1,5 +1,7 @@
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
import '../../marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../demo_persona.dart';
/// Demo fixtures for the mobile capability flags — everything granted so the
/// demo persona sees every feature (incl. push) as available and the timetable
@@ -17,6 +19,27 @@ class DemoCapabilities {
userType: 'STUDENT',
loaded: true,
);
/// Guardian persona with two children, so the child switcher is visible.
static CapabilitiesState guardianState() => const CapabilitiesState(
pushNotifications: true,
userType: 'PARENT',
children: [
GuardianChild(
id: 'demo-child-1',
firstName: DemoPersona.studentFirstName,
lastName: 'Hoffmann',
className: DemoPersona.className,
),
GuardianChild(
id: 'demo-child-2',
firstName: 'Jonas',
lastName: 'Hoffmann',
className: '6a',
),
],
loaded: true,
);
}
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
+4 -3
View File
@@ -1,4 +1,4 @@
import '../../../model/account_data.dart';
import '../../../session/session_manager.dart';
import '../../marianumcloud/talk/chat/get_chat_response.dart';
import '../../marianumcloud/talk/room/get_room_response.dart';
import '../demo_persona.dart';
@@ -204,7 +204,8 @@ class DemoTalk {
id: base + 2,
token: token,
ago: const Duration(days: 1, hours: 6),
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
message:
'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
),
_msg(
id: base + 3,
@@ -366,7 +367,7 @@ class DemoTalk {
}) => _msg(
id: id,
token: token,
actor: AccountData().getUsername(),
actor: SessionManager().requireNextcloud().username,
display: DemoPersona.studentName,
ago: ago,
message: message,
+11 -3
View File
@@ -1,4 +1,4 @@
import '../../model/account_data.dart';
import '../../session/session_manager.dart';
/// Central switch for the client-side demo mode.
///
@@ -8,7 +8,7 @@ import '../../model/account_data.dart';
/// Play reviewers and automated screenshot runs see a fully populated app
/// without a real account and without any network dependency.
///
/// The flag is persisted through [AccountData.isDemo], so a demo session
/// The flag is persisted through [Session.isDemo], so a demo session
/// survives cold starts exactly like a normal login. It works in release builds
/// too — reviewers run the shipped release build.
class DemoMode {
@@ -22,6 +22,14 @@ class DemoMode {
static bool matches(String username) =>
username.trim().toLowerCase().startsWith(usernamePrefix);
/// Guardian logins are passwordless, so reviewers need a fixed address that
/// skips the mail round-trip. Deliberately not the `demo@` prefix, which a
/// real e-mail address could start with.
static const String guardianEmail = 'demo-eltern@marianum-fulda.de';
static bool matchesGuardian(String email) =>
email.trim().toLowerCase() == guardianEmail;
/// True while the active session is a demo session.
static bool get active => AccountData().isDemo;
static bool get active => SessionManager().isDemo;
}
+8 -1
View File
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
import 'package:http/http.dart' as http;
import 'package:nextcloud/nextcloud.dart';
import '../../session/session.dart';
import '../api_error.dart';
import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart';
@@ -61,7 +62,9 @@ AppException? _dioToAppException(DioException error) {
AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode;
final preview = previewBody(error.body);
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
final detail = preview.isEmpty
? 'HTTP $status'
: 'HTTP $status body=$preview';
switch (status) {
case 401:
return AuthException.unauthorized(technicalDetails: detail);
@@ -86,6 +89,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error is AppException) return error.userMessage;
if (error is TalkError) return TalkException(error).userMessage;
if (error is NextcloudUnavailableException) {
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
}
if (error is DioException) {
final mapped = _dioToAppException(error);
@@ -136,6 +142,7 @@ String? errorToTechnicalDetails(Object? error) {
bool errorAllowsRetry(Object? error) {
if (error == null) return true;
if (error is AppException) return error.allowRetry;
if (error is NextcloudUnavailableException) return false;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.allowRetry;
@@ -2,13 +2,13 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../session/session_manager.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]),
/// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]),
/// which is what the push-v2 registration binds to.
///
/// Must authenticate with the *real* password — an app password cannot mint
@@ -33,7 +33,9 @@ class GetAppPassword {
// Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one —
// this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
'Authorization': SessionManager()
.requireNextcloud()
.realPasswordBasicAuthHeader,
},
),
))!;
@@ -4,8 +4,8 @@ import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart';
import '../../errors/parse_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
@@ -27,12 +27,12 @@ Uri _coreAvatarUri() {
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
}
Uri _userInfoUri() =>
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}');
Uri _userInfoUri() => NextcloudOcs.uri(
'cloud/users/${SessionManager().requireNextcloud().username}',
);
Future<http.Response> _send(
Future<http.Response> Function(Uri uri, Map<String, String> headers)
perform,
Future<http.Response> Function(Uri uri, Map<String, String> headers) perform,
Uri uri,
) async {
final headers = NextcloudOcs.headers();
@@ -98,16 +98,13 @@ class GetUserInfo {
try {
final root = jsonDecode(response.body) as Map<String, dynamic>;
final data =
(root['ocs'] as Map<String, dynamic>)['data']
as Map<String, dynamic>;
(root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>;
return CloudUserInfo(
userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '',
);
} catch (e) {
throw ParseException(
technicalDetails: 'Cloud $uri user info parse: $e',
);
throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e');
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import 'dart:convert';
import '../../model/account_data.dart';
import '../../model/endpoint_data.dart';
import '../../session/session_manager.dart';
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
/// TalkApi, AutocompleteApi, FileSharingApi.
@@ -16,7 +16,7 @@ class NextcloudOcs {
static Map<String, String> headers() => {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': AccountData().getBasicAuthHeader(),
'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
};
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
+7 -7
View File
@@ -1,7 +1,7 @@
import 'package:nextcloud/nextcloud.dart';
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart';
import '../../api_response.dart';
abstract class WebdavApi<T> {
@@ -18,7 +18,7 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials.
static Future<WebDavClient> get webdav {
final secret = AccountData().getNextcloudSecret();
final secret = SessionManager().requireNextcloud().secret;
if (_webdav == null || _webdavSecret != secret) {
_webdavSecret = secret;
_webdav = establishWebdavConnection();
@@ -30,13 +30,13 @@ abstract class WebdavApi<T> {
NextcloudClient(
Uri.parse('https://${EndpointData().nextcloud().full()}'),
// App password preferred — with 2FA the real password is not accepted
// by Nextcloud at all (see AccountData.usesLoginFlow).
password: AccountData().getNextcloudSecret(),
loginName: AccountData().getUsername(),
// by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
password: SessionManager().requireNextcloud().secret,
loginName: SessionManager().requireNextcloud().username,
).webdav;
/// Builds the WebDAV download URL without embedded credentials. Callers must
/// authenticate via the [AccountData.authHeaders] header instead.
/// authenticate via the [NextcloudCredentials.authHeaders] header instead.
static String buildWebdavUrl() =>
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/';
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/';
}
@@ -1,12 +1,14 @@
import 'package:dio/dio.dart';
import '../../../model/account_data.dart';
import '../../../session/session.dart';
import '../../../session/session_manager.dart';
import '../queries/auth_login/auth_login.dart';
import 'device_token_name.dart';
import 'token_storage.dart';
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
/// re-logs in once with the credentials in [AccountData] before retrying.
/// renews the token once before retrying. Only password accounts can renew
/// silently; passwordless accounts surface the 401.
class MarianumConnectAuthInterceptor extends Interceptor {
static const _retriedKey = 'mc_auth_retried';
@@ -64,6 +66,9 @@ class MarianumConnectAuthInterceptor extends Interceptor {
}
final refreshed = await _attemptReLogin();
if (!refreshed) {
if (SessionManager().current is GuardianSession) {
SessionManager().reportUnauthorized();
}
handler.next(err);
return;
}
@@ -87,11 +92,12 @@ class MarianumConnectAuthInterceptor extends Interceptor {
}
Future<bool> _performReLogin() async {
if (!AccountData().isPopulated()) return false;
final session = SessionManager().current;
if (session is! CredentialSession) return false;
try {
await _loginClient.run(
username: AccountData().getUsername(),
password: AccountData().getPassword(),
username: session.username,
password: session.password,
tokenName: await DeviceTokenName.resolve(),
);
return true;
@@ -1,35 +1,38 @@
import 'dart:developer';
import '../../../model/account_data.dart';
import '../../../session/session.dart';
import '../../../session/session_lifecycle.dart';
import '../../../session/session_manager.dart';
import '../../errors/auth_exception.dart';
import '../queries/auth_logout/auth_logout.dart';
import '../queries/auth_me/auth_me.dart';
import '../queries/auth_verify/auth_verify.dart';
import 'token_storage.dart';
/// Background credential probe a server-side password rotation forces a
/// re-login on the next cold start even when the bearer token would still
/// be accepted.
/// Credential probe. For password accounts a server-side password rotation
/// forces a re-login on the next cold start even when the bearer token would
/// still be accepted; for guardians it confirms a rejected token before the
/// session is dropped.
class SessionValidator {
static Future<void> probeStored({
required Future<void> Function() onInvalidated,
}) async {
if (!AccountData().isPopulated()) return;
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo
// session must be skipped here or its missing token would 401 into a logout.
if (AccountData().isDemo) return;
final username = AccountData().getUsername();
final password = AccountData().getPassword();
final session = SessionManager().current;
// The probes use their own dio (bypassing the demo interceptor), so a demo
// session must be skipped or its missing token would 401 into a logout.
if (session == null || session.isDemo) return;
try {
await AuthVerify().run(username: username, password: password);
switch (session) {
case CredentialSession(:final username, :final password):
await AuthVerify().run(username: username, password: password);
case GuardianSession():
await AuthMe().run();
}
} on AuthException catch (e) {
if (e.statusCode != 401) return;
log('MC: stored credentials rejected — forcing re-login');
await AuthLogout().run();
await const MarianumConnectTokenStorage().clear();
await AccountData().removeData();
log('MC: stored session rejected — forcing re-login');
await SessionLifecycle.signOut();
await onInvalidated();
} catch (e) {
log('MC: background credential check failed (transient): $e');
log('MC: background session check failed (transient): $e');
}
}
}
@@ -1,5 +1,8 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../errors/auth_exception.dart';
/// `first_unlock` accessibility so the token can be read during background
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
@@ -9,7 +12,7 @@ const IOSOptions _mcIosOptions = IOSOptions(
);
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
/// separate from `AccountData` because the username/password live on (Nextcloud
/// separate from `SessionManager` because the username/password live on (Nextcloud
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
class MarianumConnectTokenStorage {
static const _tokenKey = 'mc_bearer_token';
@@ -24,6 +27,18 @@ class MarianumConnectTokenStorage {
Future<String?> readToken() => _storage.read(key: _tokenKey);
/// Request options carrying the stored token, for probes that bypass the
/// auth interceptor. Throws [AuthException] when no token is stored.
Future<Options> requireBearerOptions(String caller) async {
final token = await readToken();
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: '$caller: no bearer token in storage',
);
}
return Options(headers: {'Authorization': 'Bearer $token'});
}
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
Future<DateTime?> readExpiresAt() async {
@@ -2,9 +2,14 @@ import '../../marianumconnect_query.dart';
import 'absence_prefill_response.dart';
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
/// Guardians pass the [childId] the report is for; the identity then comes
/// from that child.
class AbsencePrefill extends MarianumConnectQuery {
AbsencePrefill({super.dio});
Future<AbsencePrefillResponse> run() =>
getObject('absence/prefill', AbsencePrefillResponse.fromJson);
Future<AbsencePrefillResponse> run({String? childId}) => getObject(
'absence/prefill',
AbsencePrefillResponse.fromJson,
queryParameters: {'childId': ?childId},
);
}
@@ -3,7 +3,8 @@ import '../../marianumconnect_query.dart';
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
/// Empty identity fields are backfilled from LDAP server-side; validation
/// (all fields required, class must exist, no past start date, end >= start)
/// also runs server-side and mirrors the client checks.
/// also runs server-side and mirrors the client checks. For guardians the
/// server takes name and class from the child given by [childId].
class AbsenceSubmit extends MarianumConnectQuery {
AbsenceSubmit({super.dio});
@@ -15,6 +16,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
required DateTime absentUntil,
required String phone,
required String note,
String? childId,
}) => guard(() async {
await dio.post<void>(
endpoint('absence'),
@@ -26,6 +28,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
'absentUntil': isoDate(absentUntil),
'phone': phone,
'note': note,
'childId': ?childId,
},
);
});
@@ -0,0 +1,58 @@
import 'package:dio/dio.dart';
import '../../../../auth_link/pending_guardian_request.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import 'guardian_login_exception.dart';
class AuthGuardianRequestResponse {
final String requestId;
final DateTime expiresAt;
final DateTime resendAvailableAt;
final int codeLength;
const AuthGuardianRequestResponse({
required this.requestId,
required this.expiresAt,
required this.resendAvailableAt,
required this.codeLength,
});
factory AuthGuardianRequestResponse.fromJson(Map<String, dynamic> json) =>
AuthGuardianRequestResponse(
requestId: json['requestId'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
codeLength:
json['codeLength'] as int? ??
PendingGuardianRequest.defaultCodeLength,
);
}
/// Starts a passwordless guardian login: the server mails a code and a link.
/// The answer is identical for unknown addresses, so it reveals nothing about
/// which e-mails are registered.
class AuthGuardianRequest extends MarianumConnectQuery {
AuthGuardianRequest({Dio? dio})
: super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthGuardianRequestResponse> run({
required String email,
required String deviceChallenge,
required String tokenName,
}) async {
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/request'),
data: {
'email': email,
'deviceChallenge': deviceChallenge,
'tokenName': tokenName,
},
);
return AuthGuardianRequestResponse.fromJson(response.data!);
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e);
}
}
}
@@ -0,0 +1,51 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import '../auth_login/auth_login_response.dart';
import 'guardian_login_exception.dart';
/// Completes a guardian login with the mailed code or link token and stores
/// the issued bearer token.
class AuthGuardianVerify extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthGuardianVerify({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthLoginResponse> run({
required String requestId,
required String deviceVerifier,
required String tokenName,
String? code,
String? linkToken,
}) async {
assert((code == null) != (linkToken == null));
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/verify'),
data: {
'requestId': requestId,
'deviceVerifier': deviceVerifier,
'tokenName': tokenName,
'code': ?code,
'linkToken': ?linkToken,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e);
}
}
}
@@ -0,0 +1,126 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum GuardianLoginError {
invalidRequest,
emailNotRegistered,
accountDisabled,
invalidCode,
deviceMismatch,
requestConsumed,
requestExpired,
tooManyAttempts,
rateLimited,
unsupportedServer,
}
/// A rejected guardian login step with the reason the server reported.
class GuardianLoginException extends AppException {
final GuardianLoginError error;
final int? attemptsLeft;
const GuardianLoginException(
this.error, {
required super.userMessage,
this.attemptsLeft,
super.technicalDetails,
}) : super(allowRetry: false);
/// Maps a failed guardian auth call. Only 4xx answers that carry a guardian
/// login reason become [GuardianLoginException]; everything else keeps the
/// generic MarianumConnect mapping (network, 5xx, …).
static AppException fromDio(DioException e) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500) {
return mapMarianumConnectError(e);
}
final (code, attemptsLeft) = _parseBody(response!.data);
final error = _errorFor(code, status);
if (error == null) return mapMarianumConnectError(e);
return GuardianLoginException(
error,
attemptsLeft: attemptsLeft,
userMessage: messageFor(error, attemptsLeft: attemptsLeft),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(
GuardianLoginError error, {
int? attemptsLeft,
}) => switch (error) {
GuardianLoginError.invalidRequest =>
'Bitte gib eine gültige E-Mail-Adresse ein.',
GuardianLoginError.emailNotRegistered =>
'Unter dieser E-Mail-Adresse ist kein Eltern-Zugang hinterlegt. '
'Bitte prüfe, ob die korrekte Adresse verwendet wurde. Bitte wende dich an das Sekretariat, '
'wenn du nicht weißt, welche Adresse für dich registriert ist.',
GuardianLoginError.accountDisabled =>
'Dieser Eltern-Zugang ist derzeit deaktiviert. Bitte wende dich an '
'das Sekretariat.',
GuardianLoginError.invalidCode =>
attemptsLeft == null
? 'Der Code ist falsch.'
: 'Der Code ist falsch. Noch $attemptsLeft '
'${attemptsLeft == 1 ? 'Versuch' : 'Versuche'}.',
GuardianLoginError.deviceMismatch =>
'Dieser Anmeldelink wurde auf einem anderen Gerät angefordert. '
'Bitte gib stattdessen den Code aus der E-Mail ein.',
GuardianLoginError.requestConsumed =>
'Diese Anmeldung wurde bereits verwendet. Bitte fordere einen neuen '
'Code an.',
GuardianLoginError.requestExpired =>
'Der Code ist abgelaufen. Bitte fordere einen neuen an.',
GuardianLoginError.tooManyAttempts =>
'Zu viele Fehlversuche. Bitte fordere einen neuen Code an.',
GuardianLoginError.rateLimited =>
'Zu viele Anfragen. Bitte warte einige Stunden und versuche es '
'erneut.',
GuardianLoginError.unsupportedServer =>
'Die Eltern-Anmeldung ist auf diesem Server noch nicht verfügbar. '
'Bitte versuche es später erneut.',
};
/// Accepts the documented JSON body (`{"error": …, "attemptsLeft": …}`) as
/// well as the plain-text `Fehler: <code>` the server's generic error
/// handler produces.
static (String?, int?) _parseBody(Object? data) {
if (data is Map) {
final attempts = data['attemptsLeft'];
return (data['error'] as String?, attempts is int ? attempts : null);
}
if (data is String) {
final code = data.startsWith('Fehler: ')
? data.substring('Fehler: '.length)
: data;
return (code.trim(), null);
}
return (null, null);
}
static GuardianLoginError? _errorFor(String? code, int status) =>
switch (code) {
'invalid_request' => GuardianLoginError.invalidRequest,
'email_not_registered' => GuardianLoginError.emailNotRegistered,
'account_disabled' => GuardianLoginError.accountDisabled,
'invalid_code' => GuardianLoginError.invalidCode,
'device_mismatch' => GuardianLoginError.deviceMismatch,
'request_consumed' => GuardianLoginError.requestConsumed,
'request_expired' => GuardianLoginError.requestExpired,
'too_many_attempts' => GuardianLoginError.tooManyAttempts,
_ => switch (status) {
401 => GuardianLoginError.invalidCode,
// The server names an unknown address explicitly
// (`email_not_registered`); a bare 404/405 means the endpoint itself
// is missing, i.e. a server version without guardian login.
404 || 405 => GuardianLoginError.unsupportedServer,
409 => GuardianLoginError.requestConsumed,
410 => GuardianLoginError.requestExpired,
429 => GuardianLoginError.rateLimited,
_ => null,
},
};
}
@@ -0,0 +1,30 @@
import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
/// Probes that the stored bearer token is still accepted. Used for accounts
/// without a password (guardians), whose token cannot be renewed silently.
///
/// Bypasses the shared dio singleton so the auth interceptor does not react
/// to the 401 this probe is meant to observe.
class AuthMe extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthMe({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
/// Throws [AuthException] when the token is missing or rejected.
Future<void> run() async {
final options = await _tokenStorage.requireBearerOptions('AuthMe');
return guard(() async {
await dio.get<void>(endpoint('auth/me'), options: options);
});
}
}
@@ -29,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery {
required String username,
required String password,
}) async {
final token = await _tokenStorage.readToken();
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: 'AuthVerify: no bearer token in storage',
);
}
final options = await _tokenStorage.requireBearerOptions('AuthVerify');
return guard(() async {
await dio.post<void>(
endpoint('auth/verify'),
data: {'username': username, 'password': password},
options: Options(headers: {'Authorization': 'Bearer $token'}),
options: options,
);
});
}
@@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart';
import 'guardian_child.dart';
part 'get_capabilities_response.g.dart';
/// Slimmed-down capability flags the mobile UI gates features on. The backend
@@ -23,16 +25,21 @@ class CapabilitiesResponse {
final int? timetableFutureDays;
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend
/// predates the field or has no LDAP record for the user.
/// User type ('TEACHER' | 'STUDENT' | 'STAFF' | 'PARENT'). Null when the
/// backend predates the field or has no record for the user.
final String? userType;
/// Students linked to a guardian account; empty for everyone else.
@JsonKey(defaultValue: <GuardianChild>[])
final List<GuardianChild> children;
CapabilitiesResponse({
required this.viewForeignTimetables,
required this.pushNotifications,
this.timetablePastDays,
this.timetableFutureDays,
this.userType,
this.children = const [],
});
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
@@ -14,6 +14,11 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
Map<String, dynamic> _$CapabilitiesResponseToJson(
@@ -24,4 +29,5 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType,
'children': instance.children,
};
@@ -0,0 +1,23 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'guardian_child.freezed.dart';
part 'guardian_child.g.dart';
/// A student linked to the signed-in guardian. [id] is an opaque server id,
/// not a WebUntis id.
@freezed
abstract class GuardianChild with _$GuardianChild {
const GuardianChild._();
const factory GuardianChild({
required String id,
required String firstName,
required String lastName,
@Default('') String className,
}) = _GuardianChild;
factory GuardianChild.fromJson(Map<String, Object?> json) =>
_$GuardianChildFromJson(json);
String get displayName => '$firstName $lastName'.trim();
}
@@ -0,0 +1,294 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'guardian_child.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$GuardianChild {
String get id; String get firstName; String get lastName; String get className;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$GuardianChildCopyWith<GuardianChild> get copyWith => _$GuardianChildCopyWithImpl<GuardianChild>(this as GuardianChild, _$identity);
/// Serializes this GuardianChild to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as GuardianChild;
return identical(this, other) || (other.runtimeType == runtimeType&&other is GuardianChild&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.firstName, _this.firstName) || other.firstName == _this.firstName)&&(identical(other.lastName, _this.lastName) || other.lastName == _this.lastName)&&(identical(other.className, _this.className) || other.className == _this.className));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as GuardianChild;
return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className);
}
@override
String toString() {
final _this = this as GuardianChild;
return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className})';
}
}
/// @nodoc
abstract mixin class $GuardianChildCopyWith<$Res> {
factory $GuardianChildCopyWith(GuardianChild value, $Res Function(GuardianChild) _then) = _$GuardianChildCopyWithImpl;
@useResult
$Res call({
String id, String firstName, String lastName, String className
});
}
/// @nodoc
class _$GuardianChildCopyWithImpl<$Res>
implements $GuardianChildCopyWith<$Res> {
_$GuardianChildCopyWithImpl(this._self, this._then);
final GuardianChild _self;
final $Res Function(GuardianChild) _then;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) {
return _then(GuardianChild(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [GuardianChild].
extension GuardianChildPatterns on GuardianChild {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GuardianChild value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GuardianChild value) $default,){
final _that = this;
switch (_that) {
case _GuardianChild():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GuardianChild value)? $default,){
final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className) $default,) {final _that = this;
switch (_that) {
case _GuardianChild():
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String lastName, String className)? $default,) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _GuardianChild extends GuardianChild {
const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = ''}): super._();
factory _GuardianChild.fromJson(Map<String, dynamic> json) => _$GuardianChildFromJson(json);
@override final String id;
@override final String firstName;
@override final String lastName;
@override@JsonKey() final String className;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$GuardianChildCopyWith<_GuardianChild> get copyWith => __$GuardianChildCopyWithImpl<_GuardianChild>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$GuardianChildToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GuardianChild&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.className, className) || other.className == className));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,id,firstName,lastName,className);
}
@override
String toString() {
return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className)';
}
}
/// @nodoc
abstract mixin class _$GuardianChildCopyWith<$Res> implements $GuardianChildCopyWith<$Res> {
factory _$GuardianChildCopyWith(_GuardianChild value, $Res Function(_GuardianChild) _then) = __$GuardianChildCopyWithImpl;
@override @useResult
$Res call({
String id, String firstName, String lastName, String className
});
}
/// @nodoc
class __$GuardianChildCopyWithImpl<$Res>
implements _$GuardianChildCopyWith<$Res> {
__$GuardianChildCopyWithImpl(this._self, this._then);
final _GuardianChild _self;
final $Res Function(_GuardianChild) _then;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) {
return _then(_GuardianChild(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on
@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'guardian_child.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_GuardianChild _$GuardianChildFromJson(Map<String, dynamic> json) =>
_GuardianChild(
id: json['id'] as String,
firstName: json['firstName'] as String,
lastName: json['lastName'] as String,
className: json['className'] as String? ?? '',
);
Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
<String, dynamic>{
'id': instance.id,
'firstName': instance.firstName,
'lastName': instance.lastName,
'className': instance.className,
};
@@ -1,16 +1,18 @@
import '../../marianumconnect_query.dart';
/// Registers (upserts) this device's push subscription with MarianumConnect via
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
/// device-identifier signature, stores the routing metadata and starts
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
/// `PUT /api/mobile/v1/me/push-device`. For Nextcloud registrations the backend
/// verifies the device-identifier signature and forwards Nextcloud pushes to
/// this device's FCM token; `direct` registrations (accounts without
/// Nextcloud) carry no signature and only receive MarianumConnect pushes.
/// Responds 204.
class PushDeviceRegister extends MarianumConnectQuery {
PushDeviceRegister({super.dio});
Future<void> run({
required String deviceIdentifier,
required String deviceIdentifierSignature,
required String userPublicKey,
String? deviceIdentifierSignature,
String? userPublicKey,
required String pushToken,
required String platform,
required String registrationType,
@@ -20,12 +22,13 @@ class PushDeviceRegister extends MarianumConnectQuery {
endpoint('me/push-device'),
data: {
'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature,
'userPublicKey': userPublicKey,
'deviceIdentifierSignature': ?deviceIdentifierSignature,
'userPublicKey': ?userPublicKey,
'pushToken': pushToken,
'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
// 'direct' — no Nextcloud subscription behind it.
'registrationType': registrationType,
'appVersion': ?appVersion,
},
@@ -1,7 +1,7 @@
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../utils/random_id.dart';
/// A stable, anonymous per-install identifier for telemetry. Generated once on
/// first use (128 bits from a cryptographic RNG) and persisted in the secure
/// keystore, so a device stays a single row across password rotations and FCM
@@ -21,15 +21,9 @@ class TelemetryDeviceId {
_cached = existing;
return existing;
}
final generated = _generate();
final generated = randomHexId();
await _storage.write(key: _key, value: generated);
_cached = generated;
return generated;
}
static String _generate() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
}
@@ -2,8 +2,8 @@ import 'dart:developer';
import 'package:localstore/localstore.dart';
import '../../../../model/account_data.dart';
import '../../../demo/demo_mode.dart';
import '../../../../session/session.dart';
import '../../../../session/session_manager.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
@@ -28,12 +28,15 @@ class CustomEventsMigration {
const CustomEventsMigration._();
static Future<void> runOnce() async {
if (DemoMode.active) return;
// Guardians never had MHSL events; only password accounts can derive the
// legacy identity.
final session = SessionManager().current;
if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return;
try {
final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()),
GetCustomTimetableEventParams(session.legacyUserSecret),
).run();
for (final event in response.events) {
@@ -44,7 +47,9 @@ class CustomEventsMigration {
}
await _markDone();
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.');
log(
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
);
} catch (e) {
// Leave the flag unset so the next launch retries; the delete-after-post
// above keeps a partial run duplicate-free.
@@ -0,0 +1,18 @@
import '../../marianumconnect_query.dart';
import '../timetable_get_week/timetable_get_week_response.dart';
/// Fetches the weekly timetable of a guardian's child from
/// `timetable/child/{childId}`. Same response shape as `timetable/me`.
class TimetableGetChildWeek extends MarianumConnectQuery {
TimetableGetChildWeek({super.dio});
Future<TimetableGetWeekResponse> run({
required String childId,
required DateTime from,
required DateTime until,
}) => getObject(
'timetable/child/${Uri.encodeComponent(childId)}',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
}