diff --git a/.env.example b/.env.example index fb6d661..6cbadc3 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,13 @@ # Defaults to "https://api.sendgrid.com" if not set. # Use "https://api.eu.sendgrid.com" for EU-based accounts. # SENDGRID_API_URL="https://api.sendgrid.com" + +# ADMIN OVERRIDE: Sets the single administrator account for the application. +# On server startup, the system ensures that the user with this email is the +# one and only administrator. +# - If no admin exists, one will be created with this email. +# - If an admin with a DIFFERENT email exists, they will be REMOVED and +# replaced by a new admin with this email. +# - If an admin with this email already exists, nothing changes. +# This provides a secure way to set or recover the admin account. +# OVERRIDE_ADMIN_EMAIL="admin@example.com" diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 0f9be7d..d65fc70 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -133,4 +133,10 @@ abstract final class EnvironmentConfig { /// /// Returns `null` if the `SENDGRID_API_URL` is not set. static String? get sendGridApiUrl => _env['SENDGRID_API_URL']; + + /// Retrieves the override admin email from the environment, if provided. + /// + /// This is used to set or replace the single administrator account on startup. + /// Returns `null` if the `OVERRIDE_ADMIN_EMAIL` is not set. + static String? get overrideAdminEmail => _env['OVERRIDE_ADMIN_EMAIL']; } diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 29b102a..7559967 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -1,4 +1,5 @@ import 'package:core/core.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_token_blacklist_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_verification_code_storage_service.dart'; import 'package:logging/logging.dart'; @@ -25,6 +26,7 @@ class DatabaseSeedingService { _log.info('Starting database seeding process...'); await _ensureIndexes(); + await _seedOverrideAdminUser(); await _seedCollection( collectionName: 'countries', @@ -73,6 +75,120 @@ class DatabaseSeedingService { _log.info('Database seeding process completed.'); } + /// Ensures the single administrator account is correctly configured based on + /// the `OVERRIDE_ADMIN_EMAIL` environment variable. + Future _seedOverrideAdminUser() async { + _log.info('Checking for admin user override...'); + final overrideEmail = EnvironmentConfig.overrideAdminEmail; + + if (overrideEmail == null || overrideEmail.isEmpty) { + _log.info( + 'OVERRIDE_ADMIN_EMAIL not set. Skipping admin user override.', + ); + return; + } + + final usersCollection = _db.collection('users'); + final existingAdmin = await usersCollection.findOne( + where.eq('dashboardRole', DashboardUserRole.admin.name), + ); + + // Case 1: An admin exists. + if (existingAdmin != null) { + final existingAdminEmail = existingAdmin['email'] as String; + // If the existing admin's email is the same as the override, do nothing. + if (existingAdminEmail == overrideEmail) { + _log.info( + 'Admin user with email $overrideEmail already exists and matches ' + 'override. No action needed.', + ); + return; + } + + // If emails differ, delete the old admin and their data. + _log.warning( + 'Found existing admin with email "$existingAdminEmail". It will be ' + 'replaced by the override email "$overrideEmail".', + ); + final oldAdminId = existingAdmin['_id'] as ObjectId; + await _deleteUserAndData(oldAdminId); + } + + // Case 2: No admin exists, or the old one was just deleted. + // Create the new admin. + _log.info('Creating admin user for email: $overrideEmail'); + final newAdminId = ObjectId(); + final newAdminUser = User( + id: newAdminId.oid, + email: overrideEmail, + appRole: AppUserRole.standardUser, + dashboardRole: DashboardUserRole.admin, + createdAt: DateTime.now(), + feedActionStatus: Map.fromEntries( + FeedActionType.values.map( + (type) => + MapEntry(type, const UserFeedActionStatus(isCompleted: false)), + ), + ), + ); + + await usersCollection.insertOne( + {'_id': newAdminId, ...newAdminUser.toJson()..remove('id')}, + ); + + // Create default settings and preferences for the new admin. + await _createUserSubDocuments(newAdminId); + + _log.info('Successfully created admin user for $overrideEmail.'); + } + + /// Deletes a user and their associated sub-documents. + Future _deleteUserAndData(ObjectId userId) async { + await _db.collection('users').deleteOne(where.eq('_id', userId)); + await _db + .collection('user_app_settings') + .deleteOne(where.eq('_id', userId)); + await _db + .collection('user_content_preferences') + .deleteOne(where.eq('_id', userId)); + _log.info('Deleted user and associated data for ID: ${userId.oid}'); + } + + /// Creates the default sub-documents (settings, preferences) for a new user. + Future _createUserSubDocuments(ObjectId userId) async { + final defaultAppSettings = UserAppSettings( + id: userId.oid, + displaySettings: const DisplaySettings( + baseTheme: AppBaseTheme.system, + accentTheme: AppAccentTheme.defaultBlue, + fontFamily: 'SystemDefault', + textScaleFactor: AppTextScaleFactor.medium, + fontWeight: AppFontWeight.regular, + ), + language: 'en', + feedPreferences: const FeedDisplayPreferences( + headlineDensity: HeadlineDensity.standard, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ); + await _db.collection('user_app_settings').insertOne( + {'_id': userId, ...defaultAppSettings.toJson()..remove('id')}, + ); + + final defaultUserPreferences = UserContentPreferences( + id: userId.oid, + followedCountries: const [], + followedSources: const [], + followedTopics: const [], + savedHeadlines: const [], + ); + await _db.collection('user_content_preferences').insertOne( + {'_id': userId, ...defaultUserPreferences.toJson()..remove('id')}, + ); + } + /// Seeds a specific collection from a given list of fixture data. Future _seedCollection({ required String collectionName,