Add Baron Safe app features through session block skeleton
ci / flutter-check (push) Successful in 4s
ci / flutter-check (push) Successful in 4s
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'features/home/home_screen.dart';
|
||||
import 'features/session/session_screens.dart';
|
||||
import 'features/webview/baron_safe_webview_screen.dart';
|
||||
|
||||
final _router = GoRouter(
|
||||
@@ -16,6 +17,22 @@ final _router = GoRouter(
|
||||
name: BaronSafeWebViewScreen.routeName,
|
||||
builder: (context, state) => const BaronSafeWebViewScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/sessions',
|
||||
name: SessionListScreen.routeName,
|
||||
builder: (context, state) => const SessionListScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':sessionId',
|
||||
name: SessionDetailScreen.routeName,
|
||||
builder: (context, state) {
|
||||
return SessionDetailScreen(
|
||||
sessionId: state.pathParameters['sessionId']!,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'baron_safe_bridge_command.dart';
|
||||
import 'baron_safe_bridge_models.dart';
|
||||
import 'baron_safe_bridge_service.dart';
|
||||
|
||||
class BaronSafeBridgeMessageHandler {
|
||||
const BaronSafeBridgeMessageHandler(this._bridgeService);
|
||||
|
||||
final BaronSafeBridgeService _bridgeService;
|
||||
|
||||
Future<String> handle(String rawMessage) async {
|
||||
final decoded = _decode(rawMessage);
|
||||
if (decoded == null) {
|
||||
return _failure(
|
||||
requestId: null,
|
||||
code: 'invalid_json',
|
||||
message: 'Bridge message must be a JSON object.',
|
||||
);
|
||||
}
|
||||
|
||||
final requestId = decoded['requestId'] as String?;
|
||||
final commandName = decoded['command'] as String?;
|
||||
if (commandName == null) {
|
||||
return _failure(
|
||||
requestId: requestId,
|
||||
code: 'missing_command',
|
||||
message: 'Bridge command is required.',
|
||||
);
|
||||
}
|
||||
|
||||
final command = BaronSafeBridgeCommand.tryParse(commandName);
|
||||
if (command == null) {
|
||||
return _failure(
|
||||
requestId: requestId,
|
||||
code: 'unsupported_command',
|
||||
message: '$commandName is not an allowed bridge command.',
|
||||
);
|
||||
}
|
||||
|
||||
final payload = decoded['payload'];
|
||||
if (payload != null && payload is! Map<String, Object?>) {
|
||||
return _failure(
|
||||
requestId: requestId,
|
||||
code: 'invalid_payload',
|
||||
message: 'Bridge payload must be an object.',
|
||||
);
|
||||
}
|
||||
|
||||
final response = await _bridgeService.handle(
|
||||
BaronSafeBridgeRequest(
|
||||
command: command,
|
||||
payload: payload as Map<String, Object?>? ?? const <String, Object?>{},
|
||||
),
|
||||
);
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return _failure(
|
||||
requestId: requestId,
|
||||
code: response.error!.code,
|
||||
message: response.error!.message,
|
||||
);
|
||||
}
|
||||
|
||||
return jsonEncode({
|
||||
'requestId': requestId,
|
||||
'ok': true,
|
||||
'data': response.data,
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, Object?>? _decode(String rawMessage) {
|
||||
try {
|
||||
final decoded = jsonDecode(rawMessage);
|
||||
if (decoded is Map<String, Object?>) {
|
||||
return decoded;
|
||||
}
|
||||
return null;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _failure({
|
||||
required String? requestId,
|
||||
required String code,
|
||||
required String message,
|
||||
}) {
|
||||
return jsonEncode({
|
||||
'requestId': requestId,
|
||||
'ok': false,
|
||||
'error': {'code': code, 'message': message},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'baron_safe_bridge_command.dart';
|
||||
import 'baron_safe_bridge_models.dart';
|
||||
import '../push/baron_safe_push_service.dart';
|
||||
import '../session/baron_safe_session_block_service.dart';
|
||||
|
||||
abstract interface class BaronSafeBridgeService {
|
||||
Future<BaronSafeBridgeResponse> handle(BaronSafeBridgeRequest request);
|
||||
@@ -19,11 +20,13 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService {
|
||||
this.deviceInfoProvider = const PlaceholderBaronSafeDeviceInfoProvider(),
|
||||
this.biometricPrompt = const PlaceholderBaronSafeBiometricPrompt(),
|
||||
this.pushService = const PlaceholderBaronSafePushService(),
|
||||
this.sessionBlockService,
|
||||
});
|
||||
|
||||
final BaronSafeDeviceInfoProvider deviceInfoProvider;
|
||||
final BaronSafeBiometricPrompt biometricPrompt;
|
||||
final BaronSafePushService pushService;
|
||||
final BaronSafeSessionBlockService? sessionBlockService;
|
||||
|
||||
@override
|
||||
Future<BaronSafeBridgeResponse> handle(BaronSafeBridgeRequest request) async {
|
||||
@@ -33,7 +36,7 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService {
|
||||
request.payload,
|
||||
),
|
||||
BaronSafeBridgeCommand.registerPushToken => _registerPushToken(),
|
||||
BaronSafeBridgeCommand.blockSession => _notImplemented(request.command),
|
||||
BaronSafeBridgeCommand.blockSession => _blockSession(request.payload),
|
||||
BaronSafeBridgeCommand.blockLinkedApp => _notImplemented(request.command),
|
||||
BaronSafeBridgeCommand.openSettings => _notImplemented(request.command),
|
||||
};
|
||||
@@ -67,6 +70,31 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService {
|
||||
final result = await pushService.registerCurrentToken();
|
||||
return BaronSafeBridgeResponse.success(data: result.toJson());
|
||||
}
|
||||
|
||||
Future<BaronSafeBridgeResponse> _blockSession(
|
||||
Map<String, Object?> payload,
|
||||
) async {
|
||||
final service = sessionBlockService;
|
||||
if (service == null) {
|
||||
return _notImplemented(BaronSafeBridgeCommand.blockSession);
|
||||
}
|
||||
|
||||
final sessionId = payload['sessionId'] as String?;
|
||||
if (sessionId == null || sessionId.isEmpty) {
|
||||
return const BaronSafeBridgeResponse.failure(
|
||||
BaronSafeBridgeError(
|
||||
code: 'invalid_payload',
|
||||
message: 'blockSession requires a sessionId.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final result = await service.blockSession(
|
||||
sessionId: sessionId,
|
||||
reason: payload['reason'] as String? ?? 'Confirm session block',
|
||||
);
|
||||
return BaronSafeBridgeResponse.success(data: result.toJson());
|
||||
}
|
||||
}
|
||||
|
||||
class PlaceholderBaronSafeDeviceInfoProvider
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import '../push/baron_safe_push_models.dart';
|
||||
|
||||
class BaronSafeDeviceRegistrationRequest {
|
||||
const BaronSafeDeviceRegistrationRequest({
|
||||
required this.deviceId,
|
||||
required this.platform,
|
||||
required this.appVersion,
|
||||
this.pushToken,
|
||||
});
|
||||
|
||||
final String deviceId;
|
||||
final BaronSafePushPlatform platform;
|
||||
final String appVersion;
|
||||
final String? pushToken;
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'deviceId': deviceId,
|
||||
'platform': platform.value,
|
||||
'appVersion': appVersion,
|
||||
'pushToken': pushToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class BaronSafeDeviceRegistrationResult {
|
||||
const BaronSafeDeviceRegistrationResult({
|
||||
required this.deviceId,
|
||||
required this.registered,
|
||||
});
|
||||
|
||||
final String deviceId;
|
||||
final bool registered;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'baron_safe_device_models.dart';
|
||||
|
||||
abstract interface class BaronSafeDeviceRegistrationClient {
|
||||
Future<BaronSafeDeviceRegistrationResult> registerDevice(
|
||||
BaronSafeDeviceRegistrationRequest request,
|
||||
);
|
||||
}
|
||||
|
||||
class NoopBaronSafeDeviceRegistrationClient
|
||||
implements BaronSafeDeviceRegistrationClient {
|
||||
const NoopBaronSafeDeviceRegistrationClient();
|
||||
|
||||
@override
|
||||
Future<BaronSafeDeviceRegistrationResult> registerDevice(
|
||||
BaronSafeDeviceRegistrationRequest request,
|
||||
) async {
|
||||
return BaronSafeDeviceRegistrationResult(
|
||||
deviceId: request.deviceId,
|
||||
registered: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import '../push/baron_safe_push_models.dart';
|
||||
import '../push/baron_safe_push_service.dart';
|
||||
import '../secure_storage/baron_safe_secure_storage_service.dart';
|
||||
import 'baron_safe_device_models.dart';
|
||||
import 'baron_safe_device_registration_client.dart';
|
||||
|
||||
typedef BaronSafeDeviceIdGenerator = String Function();
|
||||
|
||||
class BaronSafeDeviceRegistrationService {
|
||||
const BaronSafeDeviceRegistrationService({
|
||||
required BaronSafeSecureStorageService secureStorage,
|
||||
required BaronSafeDeviceRegistrationClient registrationClient,
|
||||
BaronSafePushTokenProvider pushTokenProvider =
|
||||
const PlaceholderBaronSafePushTokenProvider(),
|
||||
BaronSafeDeviceIdGenerator deviceIdGenerator = _defaultDeviceIdGenerator,
|
||||
BaronSafePushPlatform platform = BaronSafePushPlatform.unknown,
|
||||
String appVersion = '0.1.0',
|
||||
}) : _secureStorage = secureStorage,
|
||||
_registrationClient = registrationClient,
|
||||
_pushTokenProvider = pushTokenProvider,
|
||||
_deviceIdGenerator = deviceIdGenerator,
|
||||
_platform = platform,
|
||||
_appVersion = appVersion;
|
||||
|
||||
final BaronSafeSecureStorageService _secureStorage;
|
||||
final BaronSafeDeviceRegistrationClient _registrationClient;
|
||||
final BaronSafePushTokenProvider _pushTokenProvider;
|
||||
final BaronSafeDeviceIdGenerator _deviceIdGenerator;
|
||||
final BaronSafePushPlatform _platform;
|
||||
final String _appVersion;
|
||||
|
||||
Future<BaronSafeDeviceRegistrationResult> registerCurrentDevice() async {
|
||||
final deviceId = await _readOrCreateDeviceId();
|
||||
final pushToken = await _pushTokenProvider.readCurrentToken();
|
||||
|
||||
return _registrationClient.registerDevice(
|
||||
BaronSafeDeviceRegistrationRequest(
|
||||
deviceId: deviceId,
|
||||
platform: pushToken?.platform ?? _platform,
|
||||
appVersion: pushToken?.appVersion ?? _appVersion,
|
||||
pushToken: pushToken?.token,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _readOrCreateDeviceId() async {
|
||||
final existingDeviceId = await _secureStorage.readDeviceId();
|
||||
if (existingDeviceId != null && existingDeviceId.isNotEmpty) {
|
||||
return existingDeviceId;
|
||||
}
|
||||
|
||||
final newDeviceId = _deviceIdGenerator();
|
||||
await _secureStorage.saveDeviceId(newDeviceId);
|
||||
return newDeviceId;
|
||||
}
|
||||
}
|
||||
|
||||
String _defaultDeviceIdGenerator() {
|
||||
return 'baron-safe-${DateTime.now().microsecondsSinceEpoch}';
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../session/session_screens.dart';
|
||||
import '../webview/baron_safe_webview_screen.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
@@ -38,6 +39,12 @@ class HomeScreen extends StatelessWidget {
|
||||
subtitle: 'Route container placeholder',
|
||||
onTap: () => context.goNamed(BaronSafeWebViewScreen.routeName),
|
||||
),
|
||||
_HomeActionTile(
|
||||
icon: Icons.history,
|
||||
title: 'Recent sessions',
|
||||
subtitle: 'Mock session list and detail',
|
||||
onTap: () => context.goNamed(SessionListScreen.routeName),
|
||||
),
|
||||
const _HomeActionTile(
|
||||
icon: Icons.fingerprint,
|
||||
title: 'Biometric bridge',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import '../bridge/baron_safe_bridge_service.dart';
|
||||
import 'baron_safe_session_models.dart';
|
||||
import 'baron_safe_session_repository.dart';
|
||||
|
||||
abstract interface class BaronSafeSessionBlockService {
|
||||
Future<BaronSafeSessionBlockResult> blockSession({
|
||||
required String sessionId,
|
||||
required String reason,
|
||||
});
|
||||
}
|
||||
|
||||
class BiometricBaronSafeSessionBlockService
|
||||
implements BaronSafeSessionBlockService {
|
||||
const BiometricBaronSafeSessionBlockService({
|
||||
required BaronSafeSessionRepository repository,
|
||||
required BaronSafeBiometricPrompt biometricPrompt,
|
||||
}) : _repository = repository,
|
||||
_biometricPrompt = biometricPrompt;
|
||||
|
||||
final BaronSafeSessionRepository _repository;
|
||||
final BaronSafeBiometricPrompt _biometricPrompt;
|
||||
|
||||
@override
|
||||
Future<BaronSafeSessionBlockResult> blockSession({
|
||||
required String sessionId,
|
||||
required String reason,
|
||||
}) async {
|
||||
final biometricResult = await _biometricPrompt.verify(reason: reason);
|
||||
if (!biometricResult.verified) {
|
||||
return BaronSafeSessionBlockResult(
|
||||
sessionId: sessionId,
|
||||
blocked: false,
|
||||
biometricVerified: false,
|
||||
message: biometricResult.reason,
|
||||
);
|
||||
}
|
||||
|
||||
final response = await _repository.blockSession(
|
||||
BaronSafeSessionBlockRequest(sessionId: sessionId, reason: reason),
|
||||
);
|
||||
|
||||
return BaronSafeSessionBlockResult(
|
||||
sessionId: response.sessionId,
|
||||
blocked: response.blocked,
|
||||
biometricVerified: true,
|
||||
message: response.blocked ? 'Session blocked' : 'Session block failed',
|
||||
blockedAt: response.blockedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'baron_safe_session_models.dart';
|
||||
|
||||
abstract interface class BaronSafeSessionClient {
|
||||
Future<List<BaronSafeSessionSummary>> listSessions();
|
||||
|
||||
Future<BaronSafeSessionDetail> getSession(String sessionId);
|
||||
|
||||
Future<BaronSafeSessionBlockResponse> blockSession(
|
||||
BaronSafeSessionBlockRequest request,
|
||||
);
|
||||
}
|
||||
|
||||
class MockBaronSafeSessionClient implements BaronSafeSessionClient {
|
||||
MockBaronSafeSessionClient({List<BaronSafeSessionDetail>? sessions})
|
||||
: _sessions = sessions ?? _defaultSessions;
|
||||
|
||||
final List<BaronSafeSessionDetail> _sessions;
|
||||
|
||||
@override
|
||||
Future<List<BaronSafeSessionSummary>> listSessions() async {
|
||||
return List<BaronSafeSessionSummary>.unmodifiable(_sessions);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BaronSafeSessionDetail> getSession(String sessionId) async {
|
||||
return _sessions.firstWhere(
|
||||
(session) => session.id == sessionId,
|
||||
orElse: () => throw BaronSafeSessionNotFoundException(sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BaronSafeSessionBlockResponse> blockSession(
|
||||
BaronSafeSessionBlockRequest request,
|
||||
) async {
|
||||
await getSession(request.sessionId);
|
||||
return BaronSafeSessionBlockResponse(
|
||||
sessionId: request.sessionId,
|
||||
blocked: true,
|
||||
blockedAt: DateTime.utc(2026, 6, 30, 10),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BaronSafeSessionNotFoundException implements Exception {
|
||||
const BaronSafeSessionNotFoundException(this.sessionId);
|
||||
|
||||
final String sessionId;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BaronSafeSessionNotFoundException: $sessionId';
|
||||
}
|
||||
}
|
||||
|
||||
final _defaultSessions = [
|
||||
BaronSafeSessionDetail(
|
||||
id: 'session-1',
|
||||
serviceName: 'Baron Portal',
|
||||
status: BaronSafeSessionStatus.active,
|
||||
riskLevel: BaronSafeSessionRiskLevel.low,
|
||||
createdAt: DateTime.utc(2026, 6, 30, 9),
|
||||
ipAddress: '203.0.113.10',
|
||||
deviceLabel: 'Chrome on Windows',
|
||||
locationLabel: 'Seoul',
|
||||
userAgent: 'Chrome',
|
||||
authMethod: 'phone',
|
||||
),
|
||||
BaronSafeSessionDetail(
|
||||
id: 'session-2',
|
||||
serviceName: 'Admin Console',
|
||||
status: BaronSafeSessionStatus.active,
|
||||
riskLevel: BaronSafeSessionRiskLevel.high,
|
||||
createdAt: DateTime.utc(2026, 6, 30, 9, 15),
|
||||
ipAddress: '198.51.100.22',
|
||||
deviceLabel: 'Unknown browser',
|
||||
locationLabel: 'Unknown',
|
||||
userAgent: 'Unknown',
|
||||
authMethod: 'phone',
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,188 @@
|
||||
enum BaronSafeSessionStatus {
|
||||
active('active'),
|
||||
blocked('blocked'),
|
||||
expired('expired'),
|
||||
unknown('unknown');
|
||||
|
||||
const BaronSafeSessionStatus(this.value);
|
||||
|
||||
final String value;
|
||||
|
||||
static BaronSafeSessionStatus fromValue(String value) {
|
||||
return values.firstWhere(
|
||||
(status) => status.value == value,
|
||||
orElse: () => unknown,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum BaronSafeSessionRiskLevel {
|
||||
low('low'),
|
||||
medium('medium'),
|
||||
high('high'),
|
||||
unknown('unknown');
|
||||
|
||||
const BaronSafeSessionRiskLevel(this.value);
|
||||
|
||||
final String value;
|
||||
|
||||
static BaronSafeSessionRiskLevel fromValue(String value) {
|
||||
return values.firstWhere(
|
||||
(riskLevel) => riskLevel.value == value,
|
||||
orElse: () => unknown,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BaronSafeSessionSummary {
|
||||
const BaronSafeSessionSummary({
|
||||
required this.id,
|
||||
required this.serviceName,
|
||||
required this.status,
|
||||
required this.riskLevel,
|
||||
required this.createdAt,
|
||||
this.ipAddress,
|
||||
this.deviceLabel,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String serviceName;
|
||||
final BaronSafeSessionStatus status;
|
||||
final BaronSafeSessionRiskLevel riskLevel;
|
||||
final DateTime createdAt;
|
||||
final String? ipAddress;
|
||||
final String? deviceLabel;
|
||||
|
||||
factory BaronSafeSessionSummary.fromJson(Map<String, Object?> json) {
|
||||
return BaronSafeSessionSummary(
|
||||
id: json['id'] as String,
|
||||
serviceName: json['serviceName'] as String? ?? 'Unknown service',
|
||||
status: BaronSafeSessionStatus.fromValue(
|
||||
json['status'] as String? ?? 'unknown',
|
||||
),
|
||||
riskLevel: BaronSafeSessionRiskLevel.fromValue(
|
||||
json['riskLevel'] as String? ?? 'unknown',
|
||||
),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
ipAddress: json['ipAddress'] as String?,
|
||||
deviceLabel: json['deviceLabel'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'serviceName': serviceName,
|
||||
'status': status.value,
|
||||
'riskLevel': riskLevel.value,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'ipAddress': ipAddress,
|
||||
'deviceLabel': deviceLabel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class BaronSafeSessionDetail extends BaronSafeSessionSummary {
|
||||
const BaronSafeSessionDetail({
|
||||
required super.id,
|
||||
required super.serviceName,
|
||||
required super.status,
|
||||
required super.riskLevel,
|
||||
required super.createdAt,
|
||||
super.ipAddress,
|
||||
super.deviceLabel,
|
||||
this.locationLabel,
|
||||
this.userAgent,
|
||||
this.authMethod,
|
||||
});
|
||||
|
||||
final String? locationLabel;
|
||||
final String? userAgent;
|
||||
final String? authMethod;
|
||||
|
||||
factory BaronSafeSessionDetail.fromJson(Map<String, Object?> json) {
|
||||
final summary = BaronSafeSessionSummary.fromJson(json);
|
||||
return BaronSafeSessionDetail(
|
||||
id: summary.id,
|
||||
serviceName: summary.serviceName,
|
||||
status: summary.status,
|
||||
riskLevel: summary.riskLevel,
|
||||
createdAt: summary.createdAt,
|
||||
ipAddress: summary.ipAddress,
|
||||
deviceLabel: summary.deviceLabel,
|
||||
locationLabel: json['locationLabel'] as String?,
|
||||
userAgent: json['userAgent'] as String?,
|
||||
authMethod: json['authMethod'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
...super.toJson(),
|
||||
'locationLabel': locationLabel,
|
||||
'userAgent': userAgent,
|
||||
'authMethod': authMethod,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class BaronSafeSessionBlockRequest {
|
||||
const BaronSafeSessionBlockRequest({
|
||||
required this.sessionId,
|
||||
required this.reason,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final String reason;
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {'sessionId': sessionId, 'reason': reason};
|
||||
}
|
||||
}
|
||||
|
||||
class BaronSafeSessionBlockResponse {
|
||||
const BaronSafeSessionBlockResponse({
|
||||
required this.sessionId,
|
||||
required this.blocked,
|
||||
this.blockedAt,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final bool blocked;
|
||||
final DateTime? blockedAt;
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'sessionId': sessionId,
|
||||
'blocked': blocked,
|
||||
'blockedAt': blockedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class BaronSafeSessionBlockResult {
|
||||
const BaronSafeSessionBlockResult({
|
||||
required this.sessionId,
|
||||
required this.blocked,
|
||||
required this.biometricVerified,
|
||||
required this.message,
|
||||
this.blockedAt,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final bool blocked;
|
||||
final bool biometricVerified;
|
||||
final String message;
|
||||
final DateTime? blockedAt;
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'sessionId': sessionId,
|
||||
'blocked': blocked,
|
||||
'biometricVerified': biometricVerified,
|
||||
'message': message,
|
||||
'blockedAt': blockedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'baron_safe_session_client.dart';
|
||||
import 'baron_safe_session_models.dart';
|
||||
|
||||
abstract interface class BaronSafeSessionRepository {
|
||||
Future<List<BaronSafeSessionSummary>> listRecentSessions();
|
||||
|
||||
Future<BaronSafeSessionDetail> getSessionDetail(String sessionId);
|
||||
|
||||
Future<BaronSafeSessionBlockResponse> blockSession(
|
||||
BaronSafeSessionBlockRequest request,
|
||||
);
|
||||
}
|
||||
|
||||
class ClientBaronSafeSessionRepository implements BaronSafeSessionRepository {
|
||||
const ClientBaronSafeSessionRepository(this._client);
|
||||
|
||||
final BaronSafeSessionClient _client;
|
||||
|
||||
@override
|
||||
Future<List<BaronSafeSessionSummary>> listRecentSessions() {
|
||||
return _client.listSessions();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BaronSafeSessionDetail> getSessionDetail(String sessionId) {
|
||||
return _client.getSession(sessionId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BaronSafeSessionBlockResponse> blockSession(
|
||||
BaronSafeSessionBlockRequest request,
|
||||
) {
|
||||
return _client.blockSession(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../bridge/baron_safe_bridge_service.dart';
|
||||
import 'baron_safe_session_block_service.dart';
|
||||
import 'baron_safe_session_client.dart';
|
||||
import 'baron_safe_session_models.dart';
|
||||
import 'baron_safe_session_repository.dart';
|
||||
|
||||
final _sessionRepository = ClientBaronSafeSessionRepository(
|
||||
MockBaronSafeSessionClient(),
|
||||
);
|
||||
final _sessionBlockService = BiometricBaronSafeSessionBlockService(
|
||||
repository: _sessionRepository,
|
||||
biometricPrompt: const PlaceholderBaronSafeBiometricPrompt(),
|
||||
);
|
||||
|
||||
class SessionListScreen extends StatelessWidget {
|
||||
const SessionListScreen({super.key});
|
||||
|
||||
static const routeName = 'sessions';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Recent sessions')),
|
||||
body: SafeArea(
|
||||
child: FutureBuilder<List<BaronSafeSessionSummary>>(
|
||||
future: _sessionRepository.listRecentSessions(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final sessions = snapshot.data ?? const <BaronSafeSessionSummary>[];
|
||||
if (sessions.isEmpty) {
|
||||
return const Center(child: Text('No recent sessions'));
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemBuilder: (context, index) {
|
||||
final session = sessions[index];
|
||||
return _SessionSummaryTile(session: session);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 10),
|
||||
itemCount: sessions.length,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SessionDetailScreen extends StatefulWidget {
|
||||
const SessionDetailScreen({required this.sessionId, super.key});
|
||||
|
||||
static const routeName = 'session-detail';
|
||||
|
||||
final String sessionId;
|
||||
|
||||
@override
|
||||
State<SessionDetailScreen> createState() => _SessionDetailScreenState();
|
||||
}
|
||||
|
||||
class _SessionDetailScreenState extends State<SessionDetailScreen> {
|
||||
bool _isBlocking = false;
|
||||
|
||||
Future<void> _blockSession(BaronSafeSessionDetail session) async {
|
||||
setState(() {
|
||||
_isBlocking = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await _sessionBlockService.blockSession(
|
||||
sessionId: session.id,
|
||||
reason: 'Confirm blocking ${session.serviceName}',
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(result.message)));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isBlocking = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Session detail')),
|
||||
body: SafeArea(
|
||||
child: FutureBuilder<BaronSafeSessionDetail>(
|
||||
future: _sessionRepository.getSessionDetail(widget.sessionId),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError || !snapshot.hasData) {
|
||||
return const Center(child: Text('Session not found'));
|
||||
}
|
||||
|
||||
final session = snapshot.data!;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Text(
|
||||
session.serviceName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_StatusRow(
|
||||
status: session.status,
|
||||
riskLevel: session.riskLevel,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_DetailItem(label: 'Session ID', value: session.id),
|
||||
_DetailItem(
|
||||
label: 'Created',
|
||||
value: session.createdAt.toUtc().toIso8601String(),
|
||||
),
|
||||
_DetailItem(label: 'IP address', value: session.ipAddress),
|
||||
_DetailItem(label: 'Device', value: session.deviceLabel),
|
||||
_DetailItem(label: 'Location', value: session.locationLabel),
|
||||
_DetailItem(label: 'User agent', value: session.userAgent),
|
||||
_DetailItem(label: 'Auth method', value: session.authMethod),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _isBlocking ? null : () => _blockSession(session),
|
||||
icon: const Icon(Icons.block),
|
||||
label: Text(_isBlocking ? 'Blocking...' : 'Block session'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SessionSummaryTile extends StatelessWidget {
|
||||
const _SessionSummaryTile({required this.session});
|
||||
|
||||
final BaronSafeSessionSummary session;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(session.serviceName),
|
||||
subtitle: Text(
|
||||
[
|
||||
session.deviceLabel,
|
||||
session.ipAddress,
|
||||
].whereType<String>().join(' · '),
|
||||
),
|
||||
leading: Icon(
|
||||
_riskIcon(session.riskLevel),
|
||||
color: _riskColor(colorScheme, session.riskLevel),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.goNamed(
|
||||
SessionDetailScreen.routeName,
|
||||
pathParameters: {'sessionId': session.id},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusRow extends StatelessWidget {
|
||||
const _StatusRow({required this.status, required this.riskLevel});
|
||||
|
||||
final BaronSafeSessionStatus status;
|
||||
final BaronSafeSessionRiskLevel riskLevel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text('Status: ${status.value}')),
|
||||
Chip(label: Text('Risk: ${riskLevel.value}')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailItem extends StatelessWidget {
|
||||
const _DetailItem({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String? value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(value == null || value!.isEmpty ? '-' : value!),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData _riskIcon(BaronSafeSessionRiskLevel riskLevel) {
|
||||
return switch (riskLevel) {
|
||||
BaronSafeSessionRiskLevel.high => Icons.warning_amber,
|
||||
BaronSafeSessionRiskLevel.medium => Icons.info_outline,
|
||||
BaronSafeSessionRiskLevel.low => Icons.check_circle_outline,
|
||||
BaronSafeSessionRiskLevel.unknown => Icons.help_outline,
|
||||
};
|
||||
}
|
||||
|
||||
Color _riskColor(ColorScheme colorScheme, BaronSafeSessionRiskLevel riskLevel) {
|
||||
return switch (riskLevel) {
|
||||
BaronSafeSessionRiskLevel.high => colorScheme.error,
|
||||
BaronSafeSessionRiskLevel.medium => colorScheme.tertiary,
|
||||
BaronSafeSessionRiskLevel.low => colorScheme.primary,
|
||||
BaronSafeSessionRiskLevel.unknown => colorScheme.onSurfaceVariant,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
class BaronSafeWebViewPolicy {
|
||||
const BaronSafeWebViewPolicy({this.allowedHosts = defaultAllowedHosts});
|
||||
|
||||
static const defaultAllowedHosts = {
|
||||
'safe.baron.hmac.kr',
|
||||
'safe-staging.baron.hmac.kr',
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'10.0.2.2',
|
||||
};
|
||||
|
||||
final Set<String> allowedHosts;
|
||||
|
||||
bool allows(Uri uri) {
|
||||
if (!uri.hasScheme || !uri.hasAuthority) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (uri.scheme != 'https' && uri.scheme != 'http') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (uri.scheme == 'http' && !_isLocalHost(uri.host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowedHosts.contains(uri.host.toLowerCase());
|
||||
}
|
||||
|
||||
bool _isLocalHost(String host) {
|
||||
final normalizedHost = host.toLowerCase();
|
||||
return normalizedHost == 'localhost' ||
|
||||
normalizedHost == '127.0.0.1' ||
|
||||
normalizedHost == '10.0.2.2';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../bridge/baron_safe_bridge_message_handler.dart';
|
||||
import '../bridge/baron_safe_bridge_service.dart';
|
||||
import 'baron_safe_web_config.dart';
|
||||
import 'baron_safe_webview_policy.dart';
|
||||
|
||||
class BaronSafeWebViewScreen extends StatefulWidget {
|
||||
const BaronSafeWebViewScreen({super.key});
|
||||
@@ -14,8 +17,13 @@ class BaronSafeWebViewScreen extends StatefulWidget {
|
||||
|
||||
class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
||||
late final WebViewController _controller;
|
||||
final _policy = const BaronSafeWebViewPolicy();
|
||||
final _bridgeMessageHandler = const BaronSafeBridgeMessageHandler(
|
||||
PlaceholderBaronSafeBridgeService(),
|
||||
);
|
||||
var _loadingProgress = 0;
|
||||
WebResourceError? _lastError;
|
||||
Uri? _blockedUri;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -23,14 +31,30 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'BaronSafeBridge',
|
||||
onMessageReceived: (message) {
|
||||
_handleBridgeMessage(message.message);
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (_) {
|
||||
setState(() {
|
||||
_loadingProgress = 0;
|
||||
_lastError = null;
|
||||
_blockedUri = null;
|
||||
});
|
||||
},
|
||||
onNavigationRequest: (request) {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null || !_policy.allows(uri)) {
|
||||
setState(() => _blockedUri = uri);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
onProgress: (progress) {
|
||||
setState(() => _loadingProgress = progress);
|
||||
},
|
||||
@@ -45,6 +69,14 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
||||
..loadRequest(Uri.parse(BaronSafeWebConfig.initialUrl));
|
||||
}
|
||||
|
||||
Future<void> _handleBridgeMessage(String message) async {
|
||||
final response = await _bridgeMessageHandler.handle(message);
|
||||
await _controller.runJavaScript(
|
||||
'window.dispatchEvent(new CustomEvent("BaronSafeBridgeResponse", '
|
||||
'{ detail: $response }));',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -69,6 +101,11 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
||||
description: _lastError!.description,
|
||||
onRetry: _controller.reload,
|
||||
),
|
||||
if (_blockedUri != null)
|
||||
_BlockedNavigationState(
|
||||
uri: _blockedUri!,
|
||||
onDismiss: () => setState(() => _blockedUri = null),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -76,6 +113,57 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockedNavigationState extends StatelessWidget {
|
||||
const _BlockedNavigationState({required this.uri, required this.onDismiss});
|
||||
|
||||
final Uri uri;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ColoredBox(
|
||||
color: colorScheme.surface,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.gpp_maybe_outlined,
|
||||
size: 40,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Blocked navigation',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
uri.toString(),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: onDismiss,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WebViewErrorState extends StatelessWidget {
|
||||
const _WebViewErrorState({required this.description, required this.onRetry});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user