From be66e4b3a36609a028d865e07d1b3242c1c04f8a Mon Sep 17 00:00:00 2001
From: Nikolas Rimikis <leptopoda@users.noreply.github.com>
Date: Wed, 14 Aug 2024 10:37:42 +0200
Subject: [PATCH] feat(neon_framework): add account_repository

Signed-off-by: Nikolas Rimikis <leptopoda@users.noreply.github.com>
---
 .../packages/account_repository/LICENSE       |   1 +
 .../account_repository/analysis_options.yaml  |   5 +
 .../lib/account_repository.dart               |   2 +
 .../lib/src/account_repository.dart           | 329 ++++++++++
 .../lib/src/account_storage.dart              |  54 ++
 .../lib/src/models/account.dart               |  36 ++
 .../lib/src/models/account.g.dart             | 110 ++++
 .../lib/src/models/credentials.dart           |  61 ++
 .../lib/src/models/credentials.g.dart         | 175 +++++
 .../lib/src/models/login_qr_code.dart         | 102 +++
 .../lib/src/models/models.dart                |   4 +
 .../lib/src/models/serializers.dart           |  11 +
 .../lib/src/testing/testing.dart              |   2 +
 .../lib/src/testing/testing_account.dart      |  32 +
 .../lib/src/testing/testing_credentials.dart  |  17 +
 .../lib/src/utils/authentication_client.dart  |  42 ++
 .../lib/src/utils/http_client_builder.dart    |  52 ++
 .../lib/src/utils/utils.dart                  |   2 +
 .../account_repository/lib/testing.dart       |   4 +
 .../packages/account_repository/pubspec.yaml  |  39 ++
 .../account_repository/pubspec_overrides.yaml |  16 +
 .../test/account_repository_test.dart         | 602 ++++++++++++++++++
 .../test/account_storage_test.dart            | 127 ++++
 .../test/models/account_test.dart             |  94 +++
 .../test/models/credentials_test.dart         | 132 ++++
 .../test/models/login_qr_code_test.dart       |  41 ++
 .../utils/authentication_client_test.dart     |  16 +
 .../test/utils/http_client_builder_test.dart  |  95 +++
 28 files changed, 2203 insertions(+)
 create mode 120000 packages/neon_framework/packages/account_repository/LICENSE
 create mode 100644 packages/neon_framework/packages/account_repository/analysis_options.yaml
 create mode 100644 packages/neon_framework/packages/account_repository/lib/account_repository.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/account_repository.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/account_storage.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/account.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/account.g.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/credentials.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/credentials.g.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/login_qr_code.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/models.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/models/serializers.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/testing/testing.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/testing/testing_credentials.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/utils/authentication_client.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/src/utils/utils.dart
 create mode 100644 packages/neon_framework/packages/account_repository/lib/testing.dart
 create mode 100644 packages/neon_framework/packages/account_repository/pubspec.yaml
 create mode 100644 packages/neon_framework/packages/account_repository/pubspec_overrides.yaml
 create mode 100644 packages/neon_framework/packages/account_repository/test/account_repository_test.dart
 create mode 100644 packages/neon_framework/packages/account_repository/test/account_storage_test.dart
 create mode 100644 packages/neon_framework/packages/account_repository/test/models/account_test.dart
 create mode 100644 packages/neon_framework/packages/account_repository/test/models/credentials_test.dart
 create mode 100644 packages/neon_framework/packages/account_repository/test/models/login_qr_code_test.dart
 create mode 100644 packages/neon_framework/packages/account_repository/test/utils/authentication_client_test.dart
 create mode 100644 packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart

diff --git a/packages/neon_framework/packages/account_repository/LICENSE b/packages/neon_framework/packages/account_repository/LICENSE
new file mode 120000
index 00000000000..f0b83dad961
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/LICENSE
@@ -0,0 +1 @@
+../../../../assets/AGPL-3.0.txt
\ No newline at end of file
diff --git a/packages/neon_framework/packages/account_repository/analysis_options.yaml b/packages/neon_framework/packages/account_repository/analysis_options.yaml
new file mode 100644
index 00000000000..bff1b129f3c
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/analysis_options.yaml
@@ -0,0 +1,5 @@
+include: package:neon_lints/dart.yaml
+
+custom_lint:
+  rules:
+    - avoid_exports: false
diff --git a/packages/neon_framework/packages/account_repository/lib/account_repository.dart b/packages/neon_framework/packages/account_repository/lib/account_repository.dart
new file mode 100644
index 00000000000..49ced2d9d1e
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/account_repository.dart
@@ -0,0 +1,2 @@
+export 'src/account_repository.dart';
+export 'src/models/models.dart';
diff --git a/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart b/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart
new file mode 100644
index 00000000000..e7a472e7818
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/account_repository.dart
@@ -0,0 +1,329 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:account_repository/src/models/models.dart';
+import 'package:account_repository/src/utils/utils.dart';
+import 'package:built_collection/built_collection.dart';
+import 'package:equatable/equatable.dart';
+import 'package:http/http.dart' as http;
+import 'package:meta/meta.dart';
+import 'package:neon_framework/storage.dart';
+import 'package:nextcloud/core.dart' as core;
+import 'package:nextcloud/nextcloud.dart';
+import 'package:rxdart/rxdart.dart';
+
+part 'account_storage.dart';
+
+/// {@template account_failure}
+/// A base failure for the account repository failures.
+/// {@endtemplate}
+sealed class AccountFailure with EquatableMixin implements Exception {
+  /// {@macro account_failure}
+  const AccountFailure(this.error);
+
+  /// The error which was thrown.
+  final Object error;
+
+  @override
+  List<Object> get props => [error];
+}
+
+/// {@template fetch_status_failure}
+/// Thrown when fetching the server status fails.
+/// {@endtemplate}
+final class FetchStatusFailure extends AccountFailure {
+  /// {@macro fetch_status_failure}
+  const FetchStatusFailure(super.error);
+}
+
+/// {@template init_login_failure}
+/// Thrown when initializing the login flow fails.
+/// {@endtemplate}
+final class InitLoginFailure extends AccountFailure {
+  /// {@macro init_login_failure}
+  const InitLoginFailure(super.error);
+}
+
+/// {@template poll_login_failure}
+/// Thrown when polling the login flow fails.
+/// {@endtemplate}
+final class PollLoginFailure extends AccountFailure {
+  /// {@macro poll_login_failure}
+  const PollLoginFailure(super.error);
+}
+
+/// {@template fetch_account_failure}
+/// Thrown when fetching the account information fails.
+/// {@endtemplate}
+final class FetchAccountFailure extends AccountFailure {
+  /// {@macro fetch_account_failure}
+  const FetchAccountFailure(super.error);
+}
+
+/// {@template delete_credentials_failure}
+/// Thrown when deleting the account credentials fails.
+///
+/// This can happen when the user logs out but removing the app password on the server fails.
+/// {@endtemplate}
+final class DeleteCredentialsFailure extends AccountFailure {
+  /// {@macro delete_credentials_failure}
+  const DeleteCredentialsFailure(super.error);
+}
+
+/// {@template account_repository}
+/// A repository that manages the account data.
+/// {@endtemplate}
+class AccountRepository {
+  /// {@macro account_repository}
+  AccountRepository({
+    required String userAgent,
+    required http.Client httpClient,
+    required AccountStorage storage,
+  })  : _userAgent = userAgent,
+        _httpClient = httpClient,
+        _storage = storage;
+
+  final String _userAgent;
+  final http.Client _httpClient;
+  final AccountStorage _storage;
+
+  final BehaviorSubject<({String? active, BuiltMap<String, Account> accounts})> _accounts =
+      BehaviorSubject.seeded((active: null, accounts: BuiltMap()));
+
+  /// A stream of account information.
+  ///
+  /// The initial state of the stream will be `(active: null, accounts: BuiltMap())`.
+  Stream<({Account? active, BuiltList<Account> accounts})> get accounts => _accounts.stream.map((e) {
+        final active = e.accounts[e.active];
+        final accounts = e.accounts.values.toBuiltList();
+
+        return (active: active, accounts: accounts);
+      }).asBroadcastStream();
+
+  /// Whether accounts are logged in.
+  bool get hasAccounts {
+    return _accounts.hasValue && _accounts.value.accounts.isNotEmpty;
+  }
+
+  /// Retrieves the account associated with the given [accountID].
+  Account? accountByID(String accountID) {
+    return _accounts.value.accounts[accountID];
+  }
+
+  /// Checks whether the provided [url] is a valid login qr code.
+  static bool isLogInQRCode({required String url}) {
+    return LoginQRcode.tryParse(url) != null;
+  }
+
+  /// Loads the stored account information.
+  ///
+  /// If specified [rememberLastUsedAccount] takes precedence over [initialAccount].
+  /// If the stored or provided initial account are not in the list of stored credentials
+  /// they are silently ignored and the default of `first account in the list` is used.
+  Future<void> loadAccounts({
+    String? initialAccount,
+    bool rememberLastUsedAccount = false,
+  }) async {
+    final stored = await _storage.readCredentials();
+    final accounts = BuiltMap<String, Account>.build((b) {
+      for (final credentials in stored) {
+        b[credentials.id] = Account((b) {
+          b
+            ..credentials.replace(credentials)
+            ..client = buildClient(
+              httpClient: _httpClient,
+              userAgent: _userAgent,
+              credentials: credentials,
+            );
+        });
+      }
+    });
+
+    var active = initialAccount;
+    if (rememberLastUsedAccount) {
+      active = await _storage.readLastAccount();
+    }
+
+    if (active == null || !accounts.containsKey(active)) {
+      active = accounts.keys.firstOrNull;
+    }
+
+    _accounts.add(
+      (
+        active: active,
+        accounts: accounts,
+      ),
+    );
+  }
+
+  /// Fetches the status of the server.
+  ///
+  /// May throw a [FetchStatusFailure].
+  Future<core.Status> getServerStatus(Uri serverURL) async {
+    final client = buildUnauthenticatedClient(
+      httpClient: _httpClient,
+      userAgent: _userAgent,
+      serverURL: serverURL,
+    );
+
+    try {
+      final response = await client.authentication.core.getStatus();
+      return response.body;
+    } on http.ClientException catch (error, stackTrace) {
+      Error.throwWithStackTrace(FetchStatusFailure(error), stackTrace);
+    }
+  }
+
+  /// Initializes the Nextcloud login flow.
+  ///
+  /// May throw a [InitLoginFailure].
+  Future<(Uri loginUrl, String token)> loginFlowInit(Uri serverURL) async {
+    final client = buildUnauthenticatedClient(
+      httpClient: _httpClient,
+      userAgent: _userAgent,
+      serverURL: serverURL,
+    );
+
+    try {
+      final initResponse = await client.authentication.clientFlowLoginV2.init();
+
+      final loginUrl = Uri.parse(initResponse.body.login);
+      final token = initResponse.body.poll.token;
+
+      return (loginUrl, token);
+    } on http.ClientException catch (error, stackTrace) {
+      Error.throwWithStackTrace(InitLoginFailure(error), stackTrace);
+    }
+  }
+
+  /// Polls the login flow endpoint and returns the retrieved credentials.
+  ///
+  /// Throws a [PollLoginFailure] when polling the endpoint fails. The flow must be initialized
+  /// again if a failure occurs.
+  Future<Credentials?> loginFlowPoll(Uri serverURL, String token) async {
+    final client = buildUnauthenticatedClient(
+      httpClient: _httpClient,
+      userAgent: _userAgent,
+      serverURL: serverURL,
+    );
+
+    final body = core.ClientFlowLoginV2PollRequestApplicationJson((b) {
+      b.token = token;
+    });
+
+    try {
+      final resultResponse = await client.authentication.clientFlowLoginV2.poll($body: body);
+
+      final response = resultResponse.body;
+      return Credentials((b) {
+        b
+          ..serverURL = Uri.parse(response.server)
+          ..username = response.loginName
+          ..password = response.appPassword;
+      });
+    } on http.ClientException catch (error, stackTrace) {
+      if (error case DynamiteStatusCodeException(statusCode: 404)) {
+        return null;
+      }
+
+      Error.throwWithStackTrace(PollLoginFailure(error), stackTrace);
+    }
+  }
+
+  /// Fetches the information for the account with the given [credentials].
+  ///
+  /// May throw a [FetchAccountFailure].
+  Future<Account> getAccount(Credentials credentials) async {
+    final client = buildClient(
+      credentials: credentials,
+      userAgent: _userAgent,
+      httpClient: _httpClient,
+    );
+
+    try {
+      final response = await client.authentication.users.getCurrentUser();
+
+      return Account((b) {
+        b
+          ..client = client
+          ..credentials.replace(credentials)
+          ..credentials.username = response.body.ocs.data.id;
+      });
+    } on http.ClientException catch (error, stackTrace) {
+      Error.throwWithStackTrace(FetchAccountFailure(error), stackTrace);
+    }
+  }
+
+  /// Logs in the given [account].
+  ///
+  /// It will also set the active account if none is active yet.
+  Future<void> logIn(Account account) async {
+    final value = _accounts.value;
+
+    final active = value.active ?? account.credentials.id;
+    final accounts = value.accounts.rebuild((b) {
+      b[account.credentials.id] = account;
+    });
+
+    _accounts.add((active: active, accounts: accounts));
+    await Future.wait([
+      _storage.saveCredentials(accounts.values.map((e) => e.credentials)),
+      _storage.saveLastAccount(active),
+    ]);
+  }
+
+  /// Logs out the user from the server.
+  ///
+  /// May throw a [DeleteCredentialsFailure].
+  Future<void> logOut(String accountID) async {
+    final value = _accounts.value;
+
+    Account? account;
+    final accounts = value.accounts.rebuild((b) {
+      account = b.remove(accountID);
+    });
+
+    if (account == null) {
+      return;
+    }
+
+    var active = value.active;
+    if (active == accountID) {
+      active = accounts.keys.firstOrNull;
+    }
+
+    await Future.wait([
+      _storage.saveCredentials(accounts.values.map((e) => e.credentials)),
+      _storage.saveLastAccount(active),
+    ]);
+
+    _accounts.add((active: active, accounts: accounts));
+
+    try {
+      await account?.client.authentication.appPassword.deleteAppPassword();
+    } on http.ClientException catch (error, stackTrace) {
+      Error.throwWithStackTrace(DeleteCredentialsFailure(error), stackTrace);
+    }
+  }
+
+  /// Deletes the current user account.
+  ///
+  /// Not yet implemented see: https://github.com/nextcloud/neon/issues/2177
+  Future<void> deleteAccount() async {
+    throw UnimplementedError();
+  }
+
+  /// Sets the active `account` to [accountID].
+  ///
+  /// Throws an [ArgumentError] when the id is not in the accounts map.
+  Future<void> switchAccount(String accountID) async {
+    final value = _accounts.value;
+
+    if (!value.accounts.containsKey(accountID)) {
+      throw ArgumentError.value(accountID, 'accountID', 'is not a logged in account');
+    }
+
+    _accounts.add((active: accountID, accounts: value.accounts));
+    await _storage.saveLastAccount(accountID);
+  }
+}
diff --git a/packages/neon_framework/packages/account_repository/lib/src/account_storage.dart b/packages/neon_framework/packages/account_repository/lib/src/account_storage.dart
new file mode 100644
index 00000000000..4be66f4c555
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/account_storage.dart
@@ -0,0 +1,54 @@
+part of 'account_repository.dart';
+
+/// {@template account_repository_storage}
+/// Storage for the [AccountRepository].
+/// {@endtemplate}
+@immutable
+class AccountStorage {
+  /// {@macro account_repository_storage}
+  const AccountStorage({
+    required this.accountsPersistence,
+    required this.lastAccountPersistence,
+  });
+
+  /// The store for the account list.
+  final SingleValueStore accountsPersistence;
+
+  /// The store for the last used account.
+  final SingleValueStore lastAccountPersistence;
+
+  /// Gets a list of logged in credentials from storage.
+  ///
+  /// It is not checked whether the stored information is still valid.
+  Future<BuiltList<Credentials>> readCredentials() async {
+    if (accountsPersistence.hasValue()) {
+      return accountsPersistence
+          .getStringList()!
+          .map((a) => Credentials.fromJson(json.decode(a) as Map<String, dynamic>))
+          .toBuiltList();
+    }
+
+    return BuiltList();
+  }
+
+  /// Saves the given [credentials] to the storage.
+  Future<void> saveCredentials(Iterable<Credentials> credentials) async {
+    final values = credentials.map((a) => json.encode(a.toJson())).toBuiltList();
+
+    await accountsPersistence.setStringList(values);
+  }
+
+  /// Retrieves the id of the last used account.
+  Future<String?> readLastAccount() async {
+    return lastAccountPersistence.getString();
+  }
+
+  /// Sets the last used account to the given [accountID].
+  Future<void> saveLastAccount(String? accountID) async {
+    if (accountID == null) {
+      await lastAccountPersistence.remove();
+    } else {
+      await lastAccountPersistence.setString(accountID);
+    }
+  }
+}
diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/account.dart b/packages/neon_framework/packages/account_repository/lib/src/models/account.dart
new file mode 100644
index 00000000000..25faa3703d8
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/models/account.dart
@@ -0,0 +1,36 @@
+import 'package:account_repository/src/models/models.dart';
+import 'package:built_value/built_value.dart';
+import 'package:nextcloud/nextcloud.dart';
+
+part 'account.g.dart';
+
+/// Account data.
+abstract class Account implements Built<Account, AccountBuilder> {
+  /// Creates a new Account.
+  factory Account([void Function(AccountBuilder)? updates]) = _$Account;
+  const Account._();
+
+  /// The login and server credentials of the account.
+  Credentials get credentials;
+
+  /// Url of the server.
+  Uri get serverURL => credentials.serverURL;
+
+  /// The user id.
+  String get username => credentials.username;
+
+  /// App password.
+  String? get password => credentials.password;
+
+  /// The unique ID of the account.
+  ///
+  /// Implemented in a primitive way hashing the [username] and [serverURL].
+  String get id => credentials.id;
+
+  /// A human readable representation of [username] and [serverURL].
+  String get humanReadableID => credentials.humanReadableID;
+
+  /// An authenticated API client.
+  @BuiltValueField(compare: false, serialize: false)
+  NextcloudClient get client;
+}
diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/account.g.dart b/packages/neon_framework/packages/account_repository/lib/src/models/account.g.dart
new file mode 100644
index 00000000000..c7a4e6fbce8
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/models/account.g.dart
@@ -0,0 +1,110 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'account.dart';
+
+// **************************************************************************
+// BuiltValueGenerator
+// **************************************************************************
+
+class _$Account extends Account {
+  @override
+  final Credentials credentials;
+  @override
+  final NextcloudClient client;
+
+  factory _$Account([void Function(AccountBuilder)? updates]) => (AccountBuilder()..update(updates))._build();
+
+  _$Account._({required this.credentials, required this.client}) : super._() {
+    BuiltValueNullFieldError.checkNotNull(credentials, r'Account', 'credentials');
+    BuiltValueNullFieldError.checkNotNull(client, r'Account', 'client');
+  }
+
+  @override
+  Account rebuild(void Function(AccountBuilder) updates) => (toBuilder()..update(updates)).build();
+
+  @override
+  AccountBuilder toBuilder() => AccountBuilder()..replace(this);
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(other, this)) return true;
+    return other is Account && credentials == other.credentials;
+  }
+
+  @override
+  int get hashCode {
+    var _$hash = 0;
+    _$hash = $jc(_$hash, credentials.hashCode);
+    _$hash = $jf(_$hash);
+    return _$hash;
+  }
+
+  @override
+  String toString() {
+    return (newBuiltValueToStringHelper(r'Account')
+          ..add('credentials', credentials)
+          ..add('client', client))
+        .toString();
+  }
+}
+
+class AccountBuilder implements Builder<Account, AccountBuilder> {
+  _$Account? _$v;
+
+  CredentialsBuilder? _credentials;
+  CredentialsBuilder get credentials => _$this._credentials ??= CredentialsBuilder();
+  set credentials(CredentialsBuilder? credentials) => _$this._credentials = credentials;
+
+  NextcloudClient? _client;
+  NextcloudClient? get client => _$this._client;
+  set client(NextcloudClient? client) => _$this._client = client;
+
+  AccountBuilder();
+
+  AccountBuilder get _$this {
+    final $v = _$v;
+    if ($v != null) {
+      _credentials = $v.credentials.toBuilder();
+      _client = $v.client;
+      _$v = null;
+    }
+    return this;
+  }
+
+  @override
+  void replace(Account other) {
+    ArgumentError.checkNotNull(other, 'other');
+    _$v = other as _$Account;
+  }
+
+  @override
+  void update(void Function(AccountBuilder)? updates) {
+    if (updates != null) updates(this);
+  }
+
+  @override
+  Account build() => _build();
+
+  _$Account _build() {
+    _$Account _$result;
+    try {
+      _$result = _$v ??
+          _$Account._(
+              credentials: credentials.build(),
+              client: BuiltValueNullFieldError.checkNotNull(client, r'Account', 'client'));
+    } catch (_) {
+      late String _$failedField;
+      try {
+        _$failedField = 'credentials';
+        credentials.build();
+      } catch (e) {
+        throw BuiltValueNestedFieldError(r'Account', _$failedField, e.toString());
+      }
+      rethrow;
+    }
+    replace(_$result);
+    return _$result;
+  }
+}
+
+// ignore_for_file: deprecated_member_use_from_same_package,type=lint
diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/credentials.dart b/packages/neon_framework/packages/account_repository/lib/src/models/credentials.dart
new file mode 100644
index 00000000000..4210826c7c1
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/models/credentials.dart
@@ -0,0 +1,61 @@
+import 'dart:convert';
+
+import 'package:account_repository/src/models/models.dart';
+import 'package:built_value/built_value.dart';
+import 'package:built_value/serializer.dart';
+import 'package:crypto/crypto.dart';
+
+part 'credentials.g.dart';
+
+/// Credentials interface.
+abstract class Credentials implements Built<Credentials, CredentialsBuilder> {
+  /// Creates new credentials.
+  factory Credentials([void Function(CredentialsBuilder) updates]) = _$Credentials;
+  Credentials._();
+
+  /// Deserializes the given `Map<String, dynamic>` into a [Credentials].
+  factory Credentials.fromJson(Map<String, dynamic> json) {
+    return serializers.deserializeWith(Credentials.serializer, json)!;
+  }
+
+  /// Converts this [Credentials] into a `Map<String, dynamic>`.
+  Map<String, dynamic> toJson() {
+    return serializers.serializeWith(Credentials.serializer, this)! as Map<String, dynamic>;
+  }
+
+  /// The serializer that serializes this value.
+  static Serializer<Credentials> get serializer => _$credentialsSerializer;
+
+  /// Url of the server.
+  Uri get serverURL;
+
+  /// The user id.
+  String get username;
+
+  /// App password.
+  String? get password;
+
+  /// The unique ID of the account.
+  ///
+  /// Implemented in a primitive way hashing the [username] and [serverURL].
+  @memoized
+  String get id => sha1.convert(utf8.encode('$username@$serverURL')).toString();
+
+  /// A human readable representation of [username] and [serverURL].
+  @memoized
+  String get humanReadableID {
+    // Maybe also show path if it is not '/' ?
+    final buffer = StringBuffer()
+      ..write(username)
+      ..write('@')
+      ..write(serverURL.host);
+
+    if (serverURL.hasPort) {
+      buffer
+        ..write(':')
+        ..write(serverURL.port);
+    }
+
+    return buffer.toString();
+  }
+}
diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/credentials.g.dart b/packages/neon_framework/packages/account_repository/lib/src/models/credentials.g.dart
new file mode 100644
index 00000000000..311c9b6c90e
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/models/credentials.g.dart
@@ -0,0 +1,175 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'credentials.dart';
+
+// **************************************************************************
+// BuiltValueGenerator
+// **************************************************************************
+
+Serializer<Credentials> _$credentialsSerializer = _$CredentialsSerializer();
+
+class _$CredentialsSerializer implements StructuredSerializer<Credentials> {
+  @override
+  final Iterable<Type> types = const [Credentials, _$Credentials];
+  @override
+  final String wireName = 'Credentials';
+
+  @override
+  Iterable<Object?> serialize(Serializers serializers, Credentials object,
+      {FullType specifiedType = FullType.unspecified}) {
+    final result = <Object?>[
+      'serverURL',
+      serializers.serialize(object.serverURL, specifiedType: const FullType(Uri)),
+      'username',
+      serializers.serialize(object.username, specifiedType: const FullType(String)),
+    ];
+    Object? value;
+    value = object.password;
+    if (value != null) {
+      result
+        ..add('password')
+        ..add(serializers.serialize(value, specifiedType: const FullType(String)));
+    }
+    return result;
+  }
+
+  @override
+  Credentials deserialize(Serializers serializers, Iterable<Object?> serialized,
+      {FullType specifiedType = FullType.unspecified}) {
+    final result = CredentialsBuilder();
+
+    final iterator = serialized.iterator;
+    while (iterator.moveNext()) {
+      final key = iterator.current! as String;
+      iterator.moveNext();
+      final Object? value = iterator.current;
+      switch (key) {
+        case 'serverURL':
+          result.serverURL = serializers.deserialize(value, specifiedType: const FullType(Uri))! as Uri;
+          break;
+        case 'username':
+          result.username = serializers.deserialize(value, specifiedType: const FullType(String))! as String;
+          break;
+        case 'password':
+          result.password = serializers.deserialize(value, specifiedType: const FullType(String)) as String?;
+          break;
+      }
+    }
+
+    return result.build();
+  }
+}
+
+class _$Credentials extends Credentials {
+  @override
+  final Uri serverURL;
+  @override
+  final String username;
+  @override
+  final String? password;
+  String? __id;
+  String? __humanReadableID;
+
+  factory _$Credentials([void Function(CredentialsBuilder)? updates]) =>
+      (CredentialsBuilder()..update(updates))._build();
+
+  _$Credentials._({required this.serverURL, required this.username, this.password}) : super._() {
+    BuiltValueNullFieldError.checkNotNull(serverURL, r'Credentials', 'serverURL');
+    BuiltValueNullFieldError.checkNotNull(username, r'Credentials', 'username');
+  }
+
+  @override
+  String get id => __id ??= super.id;
+
+  @override
+  String get humanReadableID => __humanReadableID ??= super.humanReadableID;
+
+  @override
+  Credentials rebuild(void Function(CredentialsBuilder) updates) => (toBuilder()..update(updates)).build();
+
+  @override
+  CredentialsBuilder toBuilder() => CredentialsBuilder()..replace(this);
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(other, this)) return true;
+    return other is Credentials &&
+        serverURL == other.serverURL &&
+        username == other.username &&
+        password == other.password;
+  }
+
+  @override
+  int get hashCode {
+    var _$hash = 0;
+    _$hash = $jc(_$hash, serverURL.hashCode);
+    _$hash = $jc(_$hash, username.hashCode);
+    _$hash = $jc(_$hash, password.hashCode);
+    _$hash = $jf(_$hash);
+    return _$hash;
+  }
+
+  @override
+  String toString() {
+    return (newBuiltValueToStringHelper(r'Credentials')
+          ..add('serverURL', serverURL)
+          ..add('username', username)
+          ..add('password', password))
+        .toString();
+  }
+}
+
+class CredentialsBuilder implements Builder<Credentials, CredentialsBuilder> {
+  _$Credentials? _$v;
+
+  Uri? _serverURL;
+  Uri? get serverURL => _$this._serverURL;
+  set serverURL(Uri? serverURL) => _$this._serverURL = serverURL;
+
+  String? _username;
+  String? get username => _$this._username;
+  set username(String? username) => _$this._username = username;
+
+  String? _password;
+  String? get password => _$this._password;
+  set password(String? password) => _$this._password = password;
+
+  CredentialsBuilder();
+
+  CredentialsBuilder get _$this {
+    final $v = _$v;
+    if ($v != null) {
+      _serverURL = $v.serverURL;
+      _username = $v.username;
+      _password = $v.password;
+      _$v = null;
+    }
+    return this;
+  }
+
+  @override
+  void replace(Credentials other) {
+    ArgumentError.checkNotNull(other, 'other');
+    _$v = other as _$Credentials;
+  }
+
+  @override
+  void update(void Function(CredentialsBuilder)? updates) {
+    if (updates != null) updates(this);
+  }
+
+  @override
+  Credentials build() => _build();
+
+  _$Credentials _build() {
+    final _$result = _$v ??
+        _$Credentials._(
+            serverURL: BuiltValueNullFieldError.checkNotNull(serverURL, r'Credentials', 'serverURL'),
+            username: BuiltValueNullFieldError.checkNotNull(username, r'Credentials', 'username'),
+            password: password);
+    replace(_$result);
+    return _$result;
+  }
+}
+
+// ignore_for_file: deprecated_member_use_from_same_package,type=lint
diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/login_qr_code.dart b/packages/neon_framework/packages/account_repository/lib/src/models/login_qr_code.dart
new file mode 100644
index 00000000000..e3cbcf1d8d0
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/models/login_qr_code.dart
@@ -0,0 +1,102 @@
+import 'package:account_repository/src/models/models.dart';
+import 'package:equatable/equatable.dart';
+import 'package:meta/meta.dart';
+
+/// QRcode Login credentials.
+///
+/// The Credentials as provided by the server when manually creating an app
+/// password.
+@immutable
+final class LoginQRcode with EquatableMixin {
+  /// Creates a new LoginQRcode object.
+  @visibleForTesting
+  const LoginQRcode({
+    required this.credentials,
+  });
+
+  /// The server credentials.
+  final Credentials credentials;
+
+  /// Pattern matching the full QRcode content.
+  static final _loginQRcodeUrlRegex = RegExp(r'^nc://login/user:(.*)&password:(.*)&server:(.*)$');
+
+  /// Pattern matching the path part of the QRcode.
+  ///
+  /// This is used when launching the app through an intent.
+  static final _loginQRcodePathRegex = RegExp(r'^/user:(.*)&password:(.*)&server:(.*)$');
+
+  /// Creates a new `LoginQRcode` object by parsing a url string.
+  ///
+  /// If the [url] string is not valid as a LoginQRcode a [FormatException] is
+  /// thrown.
+  ///
+  /// Example:
+  /// ```dart
+  /// final loginQRcode =
+  ///     LoginQRcode.parse('nc://login/user:JohnDoe&password:super_secret&server:example.com');
+  /// print(loginQRcode.serverURL); // JohnDoe
+  /// print(loginQRcode.username); // super_secret
+  /// print(loginQRcode.password); // example.com
+  ///
+  /// LoginQRcode.parse('::Not valid LoginQRcode::'); // Throws FormatException.
+  /// ```
+  static LoginQRcode parse(String url) {
+    for (final regex in [_loginQRcodeUrlRegex, _loginQRcodePathRegex]) {
+      final matches = regex.allMatches(url);
+      if (matches.isEmpty) {
+        continue;
+      }
+
+      final match = matches.single;
+      if (match.groupCount != 3) {
+        continue;
+      }
+
+      return LoginQRcode(
+        credentials: Credentials((b) {
+          b
+            ..serverURL = Uri.parse(match.group(3)!)
+            ..username = match.group(1)
+            ..password = match.group(2);
+        }),
+      );
+    }
+
+    throw const FormatException();
+  }
+
+  /// Creates a new `LoginQRcode` object by parsing a url string.
+  ///
+  /// Returns `null` if the [url] string is not valid as a LoginQRcode.
+  ///
+  /// Example:
+  /// ```dart
+  /// final loginQRcode =
+  ///     LoginQRcode.parse('nc://login/user:JohnDoe&password:super_secret&server:example.com');
+  /// print(loginQRcode.serverURL); // JohnDoe
+  /// print(loginQRcode.username); // super_secret
+  /// print(loginQRcode.password); // example.com
+  ///
+  /// final notLoginQRcode = LoginQRcode.tryParse('::Not valid LoginQRcode::');
+  /// print(notLoginQRcode); // null
+  /// ```
+  static LoginQRcode? tryParse(String url) {
+    try {
+      return parse(url);
+    } on FormatException {
+      return null;
+    }
+  }
+
+  @override
+  String toString() {
+    final username = credentials.username;
+    final password = credentials.password;
+    final serverURL = credentials.serverURL;
+
+    return 'nc://login/user:$username&password:$password&server:$serverURL';
+  }
+
+  @override
+  List<Object?> get props => [credentials];
+}
diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/models.dart b/packages/neon_framework/packages/account_repository/lib/src/models/models.dart
new file mode 100644
index 00000000000..bb18a5c744b
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/models/models.dart
@@ -0,0 +1,4 @@
+export 'account.dart';
+export 'credentials.dart';
+export 'login_qr_code.dart';
+export 'serializers.dart';
diff --git a/packages/neon_framework/packages/account_repository/lib/src/models/serializers.dart b/packages/neon_framework/packages/account_repository/lib/src/models/serializers.dart
new file mode 100644
index 00000000000..7f7864b8cbc
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/models/serializers.dart
@@ -0,0 +1,11 @@
+import 'package:account_repository/src/models/models.dart';
+import 'package:built_value/serializer.dart';
+import 'package:built_value/standard_json_plugin.dart';
+import 'package:meta/meta.dart';
+
+/// The serializer for the account repository models.
+@internal
+final Serializers serializers = (Serializers().toBuilder()
+      ..add(Credentials.serializer)
+      ..addPlugin(StandardJsonPlugin()))
+    .build();
diff --git a/packages/neon_framework/packages/account_repository/lib/src/testing/testing.dart b/packages/neon_framework/packages/account_repository/lib/src/testing/testing.dart
new file mode 100644
index 00000000000..5ad500bf9ab
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/testing/testing.dart
@@ -0,0 +1,2 @@
+export 'testing_account.dart';
+export 'testing_credentials.dart';
diff --git a/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart
new file mode 100644
index 00000000000..f9546990689
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_account.dart
@@ -0,0 +1,32 @@
+import 'package:account_repository/src/models/models.dart';
+import 'package:account_repository/src/utils/utils.dart';
+import 'package:account_repository/testing.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart' as http;
+import 'package:meta/meta.dart';
+
+final _mockClient = http.MockClient((request) async {
+  throw UnsupportedError('The fake account client can not be used in tests.');
+});
+
+/// Creates a mocked [Account] object.
+///
+/// The default http client backing this account can not be used to make requests.
+/// Provide a custom [http.MockClient] for testing network requests.
+@visibleForTesting
+Account createAccount({
+  Credentials? credentials,
+  http.Client? httpClient,
+}) {
+  credentials ??= createCredentials();
+
+  return Account((b) {
+    b
+      ..credentials.replace(credentials!)
+      ..client = buildClient(
+        httpClient: httpClient ?? _mockClient,
+        userAgent: 'neon',
+        credentials: credentials,
+      );
+  });
+}
diff --git a/packages/neon_framework/packages/account_repository/lib/src/testing/testing_credentials.dart b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_credentials.dart
new file mode 100644
index 00000000000..ae77a64c224
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/testing/testing_credentials.dart
@@ -0,0 +1,17 @@
+import 'package:account_repository/src/models/models.dart';
+import 'package:meta/meta.dart';
+
+/// Creates a mocked [Credentials] object.
+@visibleForTesting
+Credentials createCredentials({
+  Uri? serverURL,
+  String username = 'username',
+  String? password = 'password',
+}) {
+  return Credentials((b) {
+    b
+      ..serverURL = serverURL ?? Uri.https('serverURL')
+      ..username = username
+      ..password = password;
+  });
+}
diff --git a/packages/neon_framework/packages/account_repository/lib/src/utils/authentication_client.dart b/packages/neon_framework/packages/account_repository/lib/src/utils/authentication_client.dart
new file mode 100644
index 00000000000..a17b579097e
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/utils/authentication_client.dart
@@ -0,0 +1,42 @@
+import 'package:meta/meta.dart';
+import 'package:nextcloud/core.dart' as $core;
+import 'package:nextcloud/nextcloud.dart';
+import 'package:nextcloud/provisioning_api.dart' as $provisioning_api;
+
+/// An AuthenticationClient client mock for testing.
+@internal
+@visibleForTesting
+AuthenticationClient? mockedClient;
+
+/// A client handling authentication APIs for easier unit testing.
+@internal
+class AuthenticationClient {
+  const AuthenticationClient({
+    required this.core,
+    required this.appPassword,
+    required this.clientFlowLoginV2,
+    required this.users,
+  });
+
+  final $core.$Client core;
+
+  final $core.$AppPasswordClient appPassword;
+
+  final $core.$ClientFlowLoginV2Client clientFlowLoginV2;
+
+  final $provisioning_api.$UsersClient users;
+}
+
+/// Extension for getting the [AuthenticationClient].
+@internal
+extension AuthenticationClientExtension on NextcloudClient {
+  /// Returns the [mockedClient] or build a [AuthenticationClient].
+  AuthenticationClient get authentication =>
+      mockedClient ??
+      AuthenticationClient(
+        core: core,
+        appPassword: core.appPassword,
+        clientFlowLoginV2: core.clientFlowLoginV2,
+        users: provisioningApi.users,
+      );
+}
diff --git a/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart b/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart
new file mode 100644
index 00000000000..4098991eec0
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/utils/http_client_builder.dart
@@ -0,0 +1,52 @@
+import 'package:account_repository/src/models/models.dart';
+import 'package:http/http.dart' as http;
+import 'package:neon_framework/storage.dart';
+import 'package:neon_http_client/neon_http_client.dart';
+import 'package:nextcloud/nextcloud.dart';
+
+/// Builds a [NextcloudClient] authenticated with the given [credentials].
+NextcloudClient buildClient({
+  required http.Client httpClient,
+  required String userAgent,
+  required Credentials credentials,
+}) {
+  final cookieStore = NeonStorage().cookieStore(
+    accountID: credentials.id,
+    serverURL: credentials.serverURL,
+  );
+
+  final neonHttpClient = NeonHttpClient(
+    cookieStore: cookieStore,
+    userAgent: userAgent,
+    client: httpClient,
+    timeLimit: kDefaultTimeout,
+    baseURL: credentials.serverURL,
+  );
+
+  return NextcloudClient(
+    credentials.serverURL,
+    loginName: credentials.username,
+    password: credentials.password,
+    appPassword: credentials.password,
+    httpClient: neonHttpClient,
+  );
+}
+
+/// Builds an unauthenticated [NextcloudClient] for the given [serverURL].
+NextcloudClient buildUnauthenticatedClient({
+  required http.Client httpClient,
+  required String userAgent,
+  required Uri serverURL,
+}) {
+  final neonHttpClient = NeonHttpClient(
+    userAgent: userAgent,
+    client: httpClient,
+    timeLimit: kDefaultTimeout,
+    baseURL: serverURL,
+  );
+
+  return NextcloudClient(
+    serverURL,
+    httpClient: neonHttpClient,
+  );
+}
diff --git a/packages/neon_framework/packages/account_repository/lib/src/utils/utils.dart b/packages/neon_framework/packages/account_repository/lib/src/utils/utils.dart
new file mode 100644
index 00000000000..e83024bd223
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/src/utils/utils.dart
@@ -0,0 +1,2 @@
+export 'authentication_client.dart';
+export 'http_client_builder.dart';
diff --git a/packages/neon_framework/packages/account_repository/lib/testing.dart b/packages/neon_framework/packages/account_repository/lib/testing.dart
new file mode 100644
index 00000000000..d27170b23a8
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/lib/testing.dart
@@ -0,0 +1,4 @@
+/// This library contains testing helpers for the account repository.
+library;
+
+export 'src/testing/testing.dart';
diff --git a/packages/neon_framework/packages/account_repository/pubspec.yaml b/packages/neon_framework/packages/account_repository/pubspec.yaml
new file mode 100644
index 00000000000..b322cdf6a39
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/pubspec.yaml
@@ -0,0 +1,39 @@
+name: account_repository
+description: Data repository for the neon account management.
+version: 0.1.0
+publish_to: none
+
+environment:
+  sdk: ^3.0.0
+  flutter: ^3.22.0
+
+dependencies:
+  built_collection: ^5.1.1
+  built_value: ^8.9.2
+  crypto: ^3.0.3
+  equatable: ^2.0.5
+  http: ^1.2.2
+  meta: ^1.0.0
+  neon_framework:
+    git:
+      url: https://github.com/nextcloud/neon
+      path: packages/neon_framework
+  neon_http_client:
+    git:
+      url: https://github.com/nextcloud/neon
+      path: packages/neon_framework/packages/neon_http_client
+  nextcloud: ^7.0.0
+  rxdart: ^0.27.0
+
+dev_dependencies:
+  build_runner: ^2.4.11
+  built_value_generator: ^8.9.2
+  built_value_test: ^8.9.2
+  flutter:
+    sdk: flutter
+  mocktail: ^1.0.4
+  neon_lints:
+    git:
+      url: https://github.com/nextcloud/neon
+      path: packages/neon_lints
+  test: ^1.25.2
diff --git a/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml b/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml
new file mode 100644
index 00000000000..198db33e28c
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml
@@ -0,0 +1,16 @@
+# melos_managed_dependency_overrides: cookie_store,dynamite_runtime,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box
+dependency_overrides:
+  cookie_store:
+    path: ../../../cookie_store
+  dynamite_runtime:
+    path: ../../../dynamite/packages/dynamite_runtime
+  neon_framework:
+    path: ../..
+  neon_http_client:
+    path: ../neon_http_client
+  neon_lints:
+    path: ../../../neon_lints
+  nextcloud:
+    path: ../../../nextcloud
+  sort_box:
+    path: ../sort_box
diff --git a/packages/neon_framework/packages/account_repository/test/account_repository_test.dart b/packages/neon_framework/packages/account_repository/test/account_repository_test.dart
new file mode 100644
index 00000000000..3564ac02b12
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/test/account_repository_test.dart
@@ -0,0 +1,602 @@
+// ignore_for_file: inference_failure_on_collection_literal
+
+import 'package:account_repository/account_repository.dart';
+import 'package:account_repository/src/testing/testing.dart';
+import 'package:account_repository/src/utils/authentication_client.dart';
+import 'package:built_collection/built_collection.dart';
+import 'package:built_value_test/matcher.dart';
+import 'package:http/http.dart' as http;
+import 'package:mocktail/mocktail.dart';
+import 'package:neon_framework/testing.dart' show MockNeonStorage;
+import 'package:nextcloud/core.dart' as core;
+import 'package:nextcloud/nextcloud.dart';
+import 'package:nextcloud/provisioning_api.dart' as provisioning_api;
+import 'package:test/test.dart';
+
+class _FakeStatus extends Fake implements core.Status {}
+
+class _FakeUri extends Fake implements Uri {}
+
+class _FakeClient extends Fake implements http.Client {}
+
+class _FakePollRequest extends Fake implements core.ClientFlowLoginV2PollRequestApplicationJson {}
+
+class _DynamiteResponseMock<B, H> extends Mock implements DynamiteResponse<B, H> {}
+
+class _CurrentUserResponseMock extends Mock implements provisioning_api.UsersGetCurrentUserResponseApplicationJson {}
+
+class _CurrentUserResponseOcsMock extends Mock
+    implements provisioning_api.UsersGetCurrentUserResponseApplicationJson_Ocs {}
+
+class _UserDetailsMock extends Mock implements provisioning_api.UserDetails {}
+
+class _CoreClientMock extends Mock implements core.$Client {}
+
+class _AppPasswordClientMock extends Mock implements core.$AppPasswordClient {}
+
+class _ClientFlowLoginV2ClientMock extends Mock implements core.$ClientFlowLoginV2Client {}
+
+class _UsersClientMock extends Mock implements provisioning_api.$UsersClient {}
+
+class _AccountStorageMock extends Mock implements AccountStorage {}
+
+typedef _AccountStream = ({BuiltList<Account> accounts, Account? active});
+
+void main() {
+  late AccountStorage storage;
+  late AccountRepository repository;
+
+  late core.$Client coreClient;
+  late core.$AppPasswordClient appPassword;
+  late core.$ClientFlowLoginV2Client clientFlowLoginV2;
+  late provisioning_api.$UsersClient users;
+
+  setUpAll(() {
+    registerFallbackValue(_FakeUri());
+    registerFallbackValue(_FakePollRequest());
+    MockNeonStorage();
+  });
+
+  setUp(() {
+    coreClient = _CoreClientMock();
+    appPassword = _AppPasswordClientMock();
+    clientFlowLoginV2 = _ClientFlowLoginV2ClientMock();
+    users = _UsersClientMock();
+
+    mockedClient = AuthenticationClient(
+      core: coreClient,
+      appPassword: appPassword,
+      clientFlowLoginV2: clientFlowLoginV2,
+      users: users,
+    );
+
+    storage = _AccountStorageMock();
+
+    repository = AccountRepository(
+      userAgent: 'userAgent',
+      httpClient: _FakeClient(),
+      storage: storage,
+    );
+  });
+
+  final credentialsList = BuiltList<Credentials>([
+    Credentials((b) {
+      b
+        ..serverURL = Uri.https('serverUrl')
+        ..username = 'username'
+        ..password = 'password';
+    }),
+    Credentials((b) {
+      b
+        ..serverURL = Uri.https('other-serverUrl')
+        ..username = 'username'
+        ..password = 'password';
+    }),
+  ]);
+
+  final accountsList = List.generate(credentialsList.length, (i) {
+    return createAccount(credentials: credentialsList[i]);
+  }).toBuiltList();
+
+  group('AccountRepository', () {
+    test('failure equality', () {
+      expect(
+        FetchStatusFailure(credentialsList),
+        equals(FetchStatusFailure(credentialsList)),
+      );
+
+      expect(
+        InitLoginFailure(credentialsList),
+        equals(InitLoginFailure(credentialsList)),
+      );
+
+      expect(
+        PollLoginFailure(credentialsList),
+        equals(PollLoginFailure(credentialsList)),
+      );
+
+      expect(
+        FetchAccountFailure(credentialsList),
+        equals(FetchAccountFailure(credentialsList)),
+      );
+
+      expect(
+        DeleteCredentialsFailure(credentialsList),
+        equals(DeleteCredentialsFailure(credentialsList)),
+      );
+    });
+
+    group('accounts', () {
+      test('emits empty user map before initialized', () {
+        expect(
+          repository.accounts,
+          emits((accounts: BuiltList<Account>(), active: null)),
+        );
+      });
+    });
+
+    test('hasAccounts', () async {
+      expect(repository.hasAccounts, isFalse);
+
+      when(() => storage.readCredentials()).thenAnswer((_) async => BuiltList());
+      await repository.loadAccounts();
+
+      expect(repository.hasAccounts, isFalse);
+
+      when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList);
+      await repository.loadAccounts();
+
+      expect(repository.hasAccounts, isTrue);
+    });
+
+    test('accountByID', () async {
+      when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList);
+      await repository.loadAccounts();
+
+      expect(repository.accountByID('invalid'), isNull);
+      expect(repository.accountByID(credentialsList.first.id), isNotNull);
+    });
+
+    test('isLogInQRCode', () {
+      expect(
+        AccountRepository.isLogInQRCode(url: 'nc://login/user:JohnDoe&password:super_secret&server:example.com'),
+        isTrue,
+      );
+
+      expect(
+        AccountRepository.isLogInQRCode(url: '/user:JohnDoe&password:super_secret&server:example.com'),
+        isTrue,
+      );
+
+      expect(
+        AccountRepository.isLogInQRCode(url: 'invalid'),
+        isFalse,
+      );
+    });
+
+    group('loadAccounts', () {
+      test('no active account when no credentials are available', () async {
+        when(() => storage.readCredentials()).thenAnswer((_) async => BuiltList());
+        await repository.loadAccounts();
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', isEmpty)
+                .having((e) => e.active, 'active', isNull),
+          ),
+        );
+
+        verifyNever(() => storage.readLastAccount());
+      });
+
+      test('emits stored credentials in accounts', () async {
+        when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList);
+        await repository.loadAccounts();
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList))
+                .having((e) => e.active, 'active', equals(accountsList.first)),
+          ),
+        );
+
+        verifyNever(() => storage.readLastAccount());
+      });
+
+      test('uses initialAccount as active', () async {
+        when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList);
+        await repository.loadAccounts(initialAccount: credentialsList[1].id);
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList))
+                .having((e) => e.active, 'active', equals(accountsList[1])),
+          ),
+        );
+
+        verifyNever(() => storage.readLastAccount());
+      });
+
+      test('ignores initialAccount when not it accounts', () async {
+        when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList);
+        await repository.loadAccounts(initialAccount: 'invalid');
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList))
+                .having((e) => e.active, 'active', equals(accountsList.first)),
+          ),
+        );
+
+        verifyNever(() => storage.readLastAccount());
+      });
+
+      test('uses last active account when specified', () async {
+        when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList);
+        when(() => storage.readLastAccount()).thenAnswer((_) async => credentialsList[1].id);
+        await repository.loadAccounts(rememberLastUsedAccount: true);
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList))
+                .having((e) => e.active, 'active', equals(accountsList[1])),
+          ),
+        );
+
+        verify(() => storage.readLastAccount()).called(1);
+      });
+
+      test('ignores last active account when not in accounts', () async {
+        when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList);
+        when(() => storage.readLastAccount()).thenAnswer((_) async => 'invalid');
+        await repository.loadAccounts(rememberLastUsedAccount: true);
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList))
+                .having((e) => e.active, 'active', equals(accountsList.first)),
+          ),
+        );
+
+        verify(() => storage.readLastAccount()).called(1);
+      });
+
+      test('prefers last active account over initial account', () async {
+        when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList);
+        when(() => storage.readLastAccount()).thenAnswer((_) async => credentialsList[1].id);
+        await repository.loadAccounts(
+          rememberLastUsedAccount: true,
+          initialAccount: credentialsList.first.id,
+        );
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList))
+                .having((e) => e.active, 'active', equals(accountsList[1])),
+          ),
+        );
+
+        verify(() => storage.readLastAccount()).called(1);
+      });
+    });
+
+    group('getServerStatus', () {
+      test('fetches the server status', () async {
+        final mockResponse = _DynamiteResponseMock<core.Status, void>();
+        when(() => coreClient.getStatus()).thenAnswer((_) async => mockResponse);
+        when(() => mockResponse.body).thenReturn(_FakeStatus());
+
+        await expectLater(
+          repository.getServerStatus(Uri()),
+          completion(isA<core.Status>()),
+        );
+
+        verify(() => coreClient.getStatus()).called(1);
+      });
+
+      test('rethrows http exceptions as `FetchStatusFailure`', () async {
+        when(() => coreClient.getStatus()).thenThrow(http.ClientException(''));
+
+        await expectLater(
+          repository.getServerStatus(Uri()),
+          throwsA(isA<FetchStatusFailure>().having((e) => e.error, 'error', isA<http.ClientException>())),
+        );
+
+        verify(() => coreClient.getStatus()).called(1);
+      });
+    });
+
+    group('loginFlowInit', () {
+      test('returns login endpoint and token', () async {
+        when(() => clientFlowLoginV2.init()).thenAnswer((_) async {
+          return DynamiteResponse(
+            200,
+            core.LoginFlowV2((b) {
+              b.login = 'https://login_url';
+              b.poll
+                ..token = 'token'
+                ..endpoint = 'endpoint';
+            }),
+            null,
+          );
+        });
+
+        await expectLater(
+          repository.loginFlowInit(Uri()),
+          completion(equals((Uri.https('login_url'), 'token'))),
+        );
+
+        verify(() => clientFlowLoginV2.init()).called(1);
+      });
+
+      test('rethrows http exceptions as `InitLoginFailure`', () async {
+        when(() => clientFlowLoginV2.init()).thenThrow(http.ClientException(''));
+
+        await expectLater(
+          repository.loginFlowInit(Uri()),
+          throwsA(isA<InitLoginFailure>().having((e) => e.error, 'error', isA<http.ClientException>())),
+        );
+
+        verify(() => clientFlowLoginV2.init()).called(1);
+      });
+    });
+
+    group('loginFlowPoll', () {
+      test('returns fetched credentials', () async {
+        when(() => clientFlowLoginV2.poll($body: any(named: r'$body'))).thenAnswer((_) async {
+          return DynamiteResponse(
+            200,
+            core.LoginFlowV2Credentials((b) {
+              b
+                ..appPassword = 'appPassword'
+                ..loginName = 'loginName'
+                ..server = 'https://server';
+            }),
+            null,
+          );
+        });
+
+        await expectLater(
+          repository.loginFlowPoll(Uri(), 'token'),
+          completion(
+            equalsBuilt(
+              Credentials((b) {
+                b
+                  ..password = 'appPassword'
+                  ..username = 'loginName'
+                  ..serverURL = Uri.https('server');
+              }),
+            ),
+          ),
+        );
+
+        verify(
+          () => clientFlowLoginV2.poll(
+            $body: any(
+              named: r'$body',
+              that: equalsBuilt(core.ClientFlowLoginV2PollRequestApplicationJson((b) => b.token = 'token')),
+            ),
+          ),
+        ).called(1);
+      });
+
+      test('returns null when for 404 response credentials', () async {
+        when(() => clientFlowLoginV2.poll($body: any(named: r'$body')))
+            .thenThrow(DynamiteStatusCodeException(http.Response('', 404)));
+
+        await expectLater(
+          repository.loginFlowPoll(Uri(), 'token'),
+          completion(isNull),
+        );
+
+        verify(
+          () => clientFlowLoginV2.poll(
+            $body: any(
+              named: r'$body',
+              that: equalsBuilt(core.ClientFlowLoginV2PollRequestApplicationJson((b) => b.token = 'token')),
+            ),
+          ),
+        ).called(1);
+      });
+
+      test('rethrows http exceptions as `PollLoginFailure`', () async {
+        when(() => clientFlowLoginV2.poll($body: any(named: r'$body'))).thenThrow(http.ClientException(''));
+
+        await expectLater(
+          repository.loginFlowPoll(Uri(), 'token'),
+          throwsA(isA<PollLoginFailure>().having((e) => e.error, 'error', isA<http.ClientException>())),
+        );
+
+        verify(
+          () => clientFlowLoginV2.poll(
+            $body: any(
+              named: r'$body',
+              that: equalsBuilt(core.ClientFlowLoginV2PollRequestApplicationJson((b) => b.token = 'token')),
+            ),
+          ),
+        ).called(1);
+      });
+    });
+
+    group('getAccount', () {
+      test('retrieves account id from server', () async {
+        final userDetails = _UserDetailsMock();
+        when(() => userDetails.id).thenReturn('admin');
+        final ocs = _CurrentUserResponseOcsMock();
+        when(() => ocs.data).thenReturn(userDetails);
+        final userStatusResponse = _CurrentUserResponseMock();
+        when(() => userStatusResponse.ocs).thenReturn(ocs);
+        final response = _DynamiteResponseMock<_CurrentUserResponseMock, void>();
+        when(() => response.body).thenReturn(userStatusResponse);
+
+        when(() => users.getCurrentUser()).thenAnswer((_) async => response);
+
+        await expectLater(
+          repository.getAccount(credentialsList.first),
+          completion(isA<Account>().having((e) => e.username, 'username', equals('admin'))),
+        );
+
+        verify(() => users.getCurrentUser()).called(1);
+      });
+
+      test('rethrows http exceptions as `FetchAccountFailure`', () async {
+        when(() => users.getCurrentUser()).thenThrow(http.ClientException(''));
+
+        await expectLater(
+          repository.getAccount(credentialsList.first),
+          throwsA(isA<FetchAccountFailure>().having((e) => e.error, 'error', isA<http.ClientException>())),
+        );
+
+        verify(() => users.getCurrentUser()).called(1);
+      });
+    });
+
+    group('logIn', () {
+      setUp(() async {
+        when(() => storage.saveCredentials(any())).thenAnswer((_) async => {});
+        when(() => storage.saveLastAccount(any())).thenAnswer((_) async => {});
+      });
+
+      final account = createAccount(
+        credentials: credentialsList.first,
+      );
+
+      test('adds account and sets active if null', () async {
+        await repository.logIn(account);
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', equals([accountsList.first]))
+                .having((e) => e.active, 'active', equals(accountsList.first)),
+          ),
+        );
+        verify(() => storage.saveCredentials(any(that: contains(credentialsList.first)))).called(1);
+        verify(() => storage.saveLastAccount(credentialsList.first.id)).called(1);
+      });
+    });
+
+    group('logOut', () {
+      setUp(() async {
+        when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList);
+        when(() => storage.saveLastAccount(any())).thenAnswer((_) async => {});
+        when(() => storage.saveCredentials(any())).thenAnswer((_) async => {});
+
+        await repository.loadAccounts();
+
+        resetMocktailState();
+      });
+
+      test('removes active account', () async {
+        when(() => appPassword.deleteAppPassword()).thenAnswer(
+          (_) async => _DynamiteResponseMock<core.AppPasswordDeleteAppPasswordResponseApplicationJson, void>(),
+        );
+        await repository.logOut(credentialsList.first.id);
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', equals([accountsList[1]]))
+                .having((e) => e.active, 'active', equals(accountsList[1])),
+          ),
+        );
+
+        verify(() => appPassword.deleteAppPassword()).called(1);
+        verify(() => storage.saveLastAccount(credentialsList[1].id)).called(1);
+        verify(() => storage.saveCredentials(any(that: equals([credentialsList[1]])))).called(1);
+      });
+
+      test('tries to remove invalid  account', () async {
+        when(() => appPassword.deleteAppPassword()).thenThrow(http.ClientException(''));
+
+        await expectLater(
+          repository.logOut('invalid'),
+          completes,
+        );
+
+        verifyNever(() => appPassword.deleteAppPassword());
+        verifyNever(() => storage.saveLastAccount(any()));
+        verifyNever(() => storage.saveCredentials(any()));
+      });
+
+      test('rethrows http exceptions as `DeleteCredentialsFailure`', () async {
+        when(() => appPassword.deleteAppPassword()).thenThrow(http.ClientException(''));
+
+        await expectLater(
+          repository.logOut(credentialsList.first.id),
+          throwsA(isA<DeleteCredentialsFailure>().having((e) => e.error, 'error', isA<http.ClientException>())),
+        );
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', equals([accountsList[1]]))
+                .having((e) => e.active, 'active', equals(accountsList[1])),
+          ),
+        );
+
+        verify(() => appPassword.deleteAppPassword()).called(1);
+        verify(() => storage.saveLastAccount(credentialsList[1].id)).called(1);
+        verify(() => storage.saveCredentials(any(that: equals([credentialsList[1]])))).called(1);
+      });
+    });
+
+    group('deleteAccount', () {
+      test('unimplemented', () async {
+        expect(
+          repository.deleteAccount(),
+          throwsUnimplementedError,
+        );
+      });
+    });
+
+    group('switchAccount', () {
+      setUp(() async {
+        when(() => storage.readCredentials()).thenAnswer((_) async => credentialsList);
+        await repository.loadAccounts();
+
+        resetMocktailState();
+      });
+
+      test('throws StateError for unregistered account ids', () async {
+        await expectLater(
+          repository.switchAccount('invalid'),
+          throwsArgumentError,
+        );
+      });
+
+      test('emits and saves active account ', () async {
+        when(() => storage.saveLastAccount(any())).thenAnswer((_) async => {});
+        await repository.switchAccount(credentialsList[1].id);
+
+        await expectLater(
+          repository.accounts,
+          emits(
+            isA<_AccountStream>()
+                .having((e) => e.accounts, 'accounts', containsAllInOrder(accountsList))
+                .having((e) => e.active, 'active', equals(accountsList[1])),
+          ),
+        );
+
+        verify(() => storage.saveLastAccount(credentialsList[1].id)).called(1);
+      });
+    });
+  });
+}
diff --git a/packages/neon_framework/packages/account_repository/test/account_storage_test.dart b/packages/neon_framework/packages/account_repository/test/account_storage_test.dart
new file mode 100644
index 00000000000..aadb6ade178
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/test/account_storage_test.dart
@@ -0,0 +1,127 @@
+import 'package:account_repository/account_repository.dart';
+import 'package:built_collection/built_collection.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:neon_framework/storage.dart';
+import 'package:test/test.dart';
+
+// ignore: avoid_implementing_value_types
+class _FakeBuiltList extends Fake implements BuiltList<String> {}
+
+class _SingleValueStoreMock extends Mock implements SingleValueStore {}
+
+void main() {
+  late SingleValueStore accountsStore;
+  late SingleValueStore lastAccountStore;
+  late AccountStorage storage;
+
+  setUp(() {
+    registerFallbackValue(_FakeBuiltList());
+
+    accountsStore = _SingleValueStoreMock();
+    lastAccountStore = _SingleValueStoreMock();
+
+    storage = AccountStorage(
+      accountsPersistence: accountsStore,
+      lastAccountPersistence: lastAccountStore,
+    );
+  });
+
+  final credentialsList = [
+    Credentials((b) {
+      b
+        ..serverURL = Uri.https('serverUrl')
+        ..username = 'username'
+        ..password = 'password';
+    }),
+    Credentials((b) {
+      b
+        ..serverURL = Uri.https('other-serverUrl')
+        ..username = 'username'
+        ..password = 'password';
+    }),
+  ];
+
+  final serializedCredentials = BuiltList<String>([
+    '{"serverURL":"https://serverurl","username":"username","password":"password"}',
+    '{"serverURL":"https://other-serverurl","username":"username","password":"password"}',
+  ]);
+
+  group('AccountStorage', () {
+    group('readCredentials', () {
+      test('returns empty list when no value is stored', () async {
+        when(() => accountsStore.hasValue()).thenReturn(false);
+
+        await expectLater(
+          storage.readCredentials(),
+          completion(isEmpty),
+        );
+
+        verifyNever(() => accountsStore.getStringList());
+      });
+
+      test('returns list with deserialized accounts', () async {
+        when(() => accountsStore.hasValue()).thenReturn(true);
+        when(() => accountsStore.getStringList()).thenReturn(serializedCredentials);
+
+        await expectLater(
+          storage.readCredentials(),
+          completion(credentialsList),
+        );
+
+        verify(() => accountsStore.getStringList()).called(1);
+      });
+    });
+
+    group('saveCredentials', () {
+      test('persists accounts to storage', () async {
+        when(() => accountsStore.setStringList(any())).thenAnswer((_) async => true);
+
+        await storage.saveCredentials(credentialsList);
+
+        verify(() => accountsStore.setStringList(any(that: equals(serializedCredentials)))).called(1);
+      });
+    });
+
+    group('readLastAccount', () {
+      test('returns null when no value is stored', () async {
+        when(() => lastAccountStore.getString()).thenReturn(null);
+
+        await expectLater(
+          storage.readLastAccount(),
+          completion(isNull),
+        );
+
+        verify(() => lastAccountStore.getString()).called(1);
+      });
+
+      test('returns account id for the stored value', () async {
+        when(() => lastAccountStore.getString()).thenReturn('accountID');
+
+        await expectLater(
+          storage.readLastAccount(),
+          completion('accountID'),
+        );
+
+        verify(() => lastAccountStore.getString()).called(1);
+      });
+    });
+
+    group('saveLastAccount', () {
+      test('persists account id to disk', () async {
+        when(() => lastAccountStore.setString(any())).thenAnswer((_) async => true);
+
+        await storage.saveLastAccount('accountID');
+
+        verify(() => lastAccountStore.setString('accountID')).called(1);
+      });
+
+      test('deletes last account when id is null', () async {
+        when(() => lastAccountStore.remove()).thenAnswer((_) async => true);
+
+        await storage.saveLastAccount(null);
+
+        verify(() => lastAccountStore.remove()).called(1);
+      });
+    });
+  });
+}
diff --git a/packages/neon_framework/packages/account_repository/test/models/account_test.dart b/packages/neon_framework/packages/account_repository/test/models/account_test.dart
new file mode 100644
index 00000000000..a43e02f2775
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/test/models/account_test.dart
@@ -0,0 +1,94 @@
+import 'package:account_repository/testing.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('Account', () {
+    group('constructor', () {
+      test('works correctly', () {
+        expect(
+          createAccount,
+          returnsNormally,
+        );
+      });
+    });
+
+    test('supports value equality', () {
+      expect(
+        createAccount(),
+        equals(createAccount()),
+      );
+
+      expect(
+        createAccount().hashCode,
+        equals(createAccount().hashCode),
+      );
+
+      expect(
+        createAccount(),
+        isNot(
+          equals(
+            createAccount(
+              credentials: createCredentials(password: null),
+            ),
+          ),
+        ),
+      );
+    });
+
+    group('rebuild', () {
+      test('returns the same object if not attributes are changed', () {
+        expect(
+          createAccount().rebuild((_) {}),
+          equals(createAccount()),
+        );
+      });
+
+      test('replaces every attribute', () {
+        expect(
+          createAccount().rebuild((b) {
+            b.credentials
+              ..serverURL = Uri.https('new-serverURL')
+              ..username = 'new-username'
+              ..password = 'new-password';
+          }),
+          equals(
+            createAccount(
+              credentials: createCredentials(
+                serverURL: Uri.https('new-serverURL'),
+                username: 'new-username',
+                password: 'new-password',
+              ),
+            ),
+          ),
+        );
+      });
+    });
+
+    test('credential getters', () {
+      final account = createAccount(
+        credentials: createCredentials(serverURL: Uri(host: 'server')),
+      );
+
+      expect(account.serverURL, equals(Uri(host: 'server')));
+      expect(account.username, equals('username'));
+      expect(account.password, equals('password'));
+      expect(account.id, equals('43c2c7ec8332735e75756dcb08c4fcc6c2b07071'));
+      expect(account.humanReadableID, equals('username@server'));
+    });
+
+    test('to string', () {
+      expect(
+        createAccount().toString(),
+        equals('''
+Account {
+  credentials=Credentials {
+    serverURL=https://serverurl,
+    username=username,
+    password=password,
+  },
+  client=Instance of 'NextcloudClient',
+}'''),
+      );
+    });
+  });
+}
diff --git a/packages/neon_framework/packages/account_repository/test/models/credentials_test.dart b/packages/neon_framework/packages/account_repository/test/models/credentials_test.dart
new file mode 100644
index 00000000000..1ec04cb11c3
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/test/models/credentials_test.dart
@@ -0,0 +1,132 @@
+import 'package:account_repository/src/models/models.dart';
+import 'package:account_repository/testing.dart';
+import 'package:built_value_test/matcher.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('Credentials', () {
+    group('constructor', () {
+      test('works correctly', () {
+        expect(
+          createCredentials,
+          returnsNormally,
+        );
+      });
+    });
+
+    test('supports value equality', () {
+      expect(
+        createCredentials(),
+        equalsBuilt(createCredentials()),
+      );
+
+      expect(
+        createCredentials().hashCode,
+        equals(createCredentials().hashCode),
+      );
+
+      expect(
+        createCredentials(),
+        isNot(equalsBuilt(createCredentials(password: null))),
+      );
+    });
+
+    group('rebuild', () {
+      test('returns the same object if not attributes are changed', () {
+        expect(
+          createCredentials().rebuild((_) {}),
+          equalsBuilt(createCredentials()),
+        );
+      });
+
+      test('replaces every attribute', () {
+        expect(
+          createCredentials().rebuild((b) {
+            b
+              ..serverURL = Uri.https('new-serverURL')
+              ..username = 'new-username'
+              ..password = 'new-password';
+          }),
+          equalsBuilt(
+            createCredentials(
+              serverURL: Uri.https('new-serverURL'),
+              username: 'new-username',
+              password: 'new-password',
+            ),
+          ),
+        );
+      });
+    });
+
+    test('creates id', () {
+      expect(
+        createCredentials().id,
+        equals('b0682d652840ef50a4115cc77109bedd8c577ccc'),
+      );
+    });
+
+    test('creates humanReadableID', () {
+      final credentials = createCredentials(
+        serverURL: Uri.https('example.com'),
+        username: 'JohnDoe',
+      );
+
+      expect(
+        credentials.humanReadableID,
+        'JohnDoe@example.com',
+      );
+
+      final credentialsWithDefaultPort = createCredentials(
+        serverURL: Uri(scheme: 'http', host: 'example.com', port: 80),
+        username: 'JohnDoe',
+        password: 'super_secret',
+      );
+
+      expect(credentialsWithDefaultPort.humanReadableID, 'JohnDoe@example.com');
+
+      final credentialsWithPort = createCredentials(
+        serverURL: Uri(scheme: 'http', host: 'example.com', port: 8080),
+        username: 'JohnDoe',
+        password: 'super_secret',
+      );
+
+      expect(credentialsWithPort.humanReadableID, 'JohnDoe@example.com:8080');
+    });
+
+    group('serialization', () {
+      test('fromJson works correctly', () {
+        expect(
+          Credentials.fromJson({
+            'serverURL': 'https://serverurl',
+            'username': 'username',
+            'password': 'password',
+          }),
+          equalsBuilt(createCredentials()),
+        );
+      });
+
+      test('toJson works correctly', () {
+        expect(
+          createCredentials().toJson(),
+          equals({
+            'serverURL': 'https://serverurl',
+            'username': 'username',
+            'password': 'password',
+          }),
+        );
+      });
+    });
+
+    test('to string', () {
+      expect(
+        createCredentials().toString(),
+        equals('''
+Credentials {
+  serverURL=https://serverurl,
+  username=username,
+  password=password,
+}'''),
+      );
+    });
+  });
+}
diff --git a/packages/neon_framework/packages/account_repository/test/models/login_qr_code_test.dart b/packages/neon_framework/packages/account_repository/test/models/login_qr_code_test.dart
new file mode 100644
index 00000000000..da3d3e39842
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/test/models/login_qr_code_test.dart
@@ -0,0 +1,41 @@
+import 'package:account_repository/src/models/models.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('LoginQRcode', () {
+    const qrCodePath = '/user:JohnDoe&password:super_secret&server:example.com';
+    const qrCode = 'nc://login$qrCodePath';
+    const invalidUrl = '::Not valid LoginQRcode::';
+    final credentials = LoginQRcode(
+      credentials: Credentials((b) {
+        b
+          ..serverURL = Uri.parse('example.com')
+          ..username = 'JohnDoe'
+          ..password = 'super_secret';
+      }),
+    );
+
+    test('parse', () {
+      expect(LoginQRcode.parse(qrCode), equals(credentials));
+      expect(LoginQRcode.parse(qrCodePath), equals(credentials));
+      expect(() => LoginQRcode.parse(invalidUrl), throwsFormatException);
+    });
+
+    test('tryParse', () {
+      expect(LoginQRcode.tryParse(qrCode), equals(credentials));
+      expect(LoginQRcode.tryParse(qrCodePath), equals(credentials));
+      expect(LoginQRcode.tryParse(invalidUrl), null);
+    });
+
+    test('equality', () {
+      expect(credentials, equals(credentials));
+    });
+
+    test('toString', () {
+      expect(
+        credentials.toString(),
+        equals(qrCode),
+      );
+    });
+  });
+}
diff --git a/packages/neon_framework/packages/account_repository/test/utils/authentication_client_test.dart b/packages/neon_framework/packages/account_repository/test/utils/authentication_client_test.dart
new file mode 100644
index 00000000000..70444837404
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/test/utils/authentication_client_test.dart
@@ -0,0 +1,16 @@
+import 'package:account_repository/src/utils/utils.dart';
+import 'package:nextcloud/nextcloud.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('AuthenticationClient', () {
+    test('AuthenticationClientExtension', () {
+      final client = NextcloudClient(Uri());
+
+      expect(
+        client.authentication,
+        isA<AuthenticationClient>(),
+      );
+    });
+  });
+}
diff --git a/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart b/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart
new file mode 100644
index 00000000000..cc21a2b82a9
--- /dev/null
+++ b/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart
@@ -0,0 +1,95 @@
+import 'package:account_repository/src/models/models.dart';
+import 'package:account_repository/src/utils/utils.dart';
+import 'package:http/http.dart' as http;
+import 'package:mocktail/mocktail.dart';
+import 'package:neon_framework/storage.dart';
+import 'package:neon_http_client/neon_http_client.dart';
+import 'package:nextcloud/nextcloud.dart';
+import 'package:test/test.dart';
+
+class _FakeClient extends Fake implements http.Client {}
+
+class _FakeUri extends Fake implements Uri {}
+
+// ignore: subtype_of_sealed_class
+class _NeonStorageMock extends Mock implements NeonStorage {}
+
+void main() {
+  final httpClient = _FakeClient();
+  const userAgent = 'neon';
+  late NeonStorage storageMock;
+
+  setUpAll(() {
+    registerFallbackValue(_FakeUri());
+
+    storageMock = _NeonStorageMock();
+    NeonStorage.mocked(storageMock);
+    when(
+      () => storageMock.cookieStore(
+        accountID: any(named: 'accountID'),
+        serverURL: any(named: 'serverURL'),
+      ),
+    ).thenReturn(null);
+  });
+
+  test('buildClient', () {
+    final credentials = Credentials((b) {
+      b
+        ..username = 'username'
+        ..password = 'password'
+        ..serverURL = Uri.https('serverURL');
+    });
+
+    final client = buildClient(
+      httpClient: httpClient,
+      userAgent: userAgent,
+      credentials: credentials,
+    );
+
+    expect(
+      client,
+      isA<NextcloudClient>()
+          .having((c) => c.authentications, 'not empty authentications', isNotEmpty)
+          .having((c) => c.baseURL, 'baseURL', equals(Uri.https('serverURL')))
+          .having(
+            (c) => c.httpClient,
+            'httpClient',
+            isA<NeonHttpClient>().having((c) => c.interceptors, 'interceptors', hasLength(2)),
+          ),
+    );
+
+    verify(
+      () => storageMock.cookieStore(
+        accountID: any(named: 'accountID', that: equals(credentials.id)),
+        serverURL: any(named: 'serverURL', that: equals(Uri.https('serverURL'))),
+      ),
+    ).called(1);
+  });
+
+  test('buildUnauthenticatedClient', () {
+    final client = buildUnauthenticatedClient(
+      httpClient: httpClient,
+      userAgent: userAgent,
+      serverURL: Uri.https('serverURL'),
+    );
+
+    expect(
+      client,
+      isA<NextcloudClient>()
+          .having((c) => c.authentications, 'empty authentications', isEmpty)
+          .having((c) => c.baseURL, 'baseURL', equals(Uri.https('serverURL')))
+          .having(
+            (c) => c.httpClient,
+            'httpClient',
+            isA<NeonHttpClient>().having((c) => c.interceptors, 'interceptors', hasLength(2)),
+          ),
+    );
+
+    verifyNever(
+      () => storageMock.cookieStore(
+        accountID: any(named: 'accountID'),
+        serverURL: any(named: 'serverURL'),
+      ),
+    );
+  });
+}