diff --git a/poc/flutter-poc/assets/poc-simulation.json b/poc/flutter-poc/assets/poc-simulation.json new file mode 100644 index 0000000..50432a0 --- /dev/null +++ b/poc/flutter-poc/assets/poc-simulation.json @@ -0,0 +1,107 @@ +{ + "version": 1, + "description": "PoC bundle simulator: ordered mock + Morph host steps. Edit to add POST bodies, paths, or conditional blocks.", + "mockApi": { + "baseUrl": "http://localhost:3000", + "envOverride": "VITE_MOCK_API_BASE" + }, + "sessionDeadCheck": { + "authIds": ["morph-auth/1fa", "morph-auth/2fa"], + "message": "Keycloak session is dead (refresh: invalid_grant / Token is not active). Simulation stopped — sign in again from Home." + }, + "steps": [ + { "type": "fetch", "label": "GET /public/config", "path": "/public/config" }, + { "type": "fetch", "label": "GET /health", "path": "/health" }, + { + "type": "host", + "label": "GET /profile (1fa)", + "hostKey": "main-api", + "method": "GET", + "path": "/profile", + "auth": "morph-auth/1fa" + }, + { + "type": "host", + "label": "GET /accounts (2fa)", + "hostKey": "main-api", + "method": "GET", + "path": "/accounts", + "auth": "morph-auth/2fa" + }, + { + "type": "host", + "label": "POST /transfers (2fa)", + "hostKey": "main-api", + "method": "POST", + "path": "/transfers", + "auth": "morph-auth/2fa", + "body": { + "fromAccount": "ACC-001", + "toAccount": "ACC-002", + "amount": 1, + "currency": "TRY" + } + }, + { + "type": "host", + "label": "GET /profile + per-call headers", + "hostKey": "main-api", + "method": "GET", + "path": "/profile", + "auth": "morph-auth/1fa", + "headers": { "X-Poc-Sim-Extra": "static-from-json" }, + "tickHeaderName": "X-Poc-Sim-Tick" + }, + { + "type": "host", + "label": "GET /public/config (device bearer)", + "hostKey": "main-api", + "method": "GET", + "path": "/public/config", + "auth": "morph-auth/device" + }, + { + "type": "logout_provider", + "label": "Logout morph-auth", + "providerKey": "morph-auth" + } + ], + "conditionalBlocks": [ + { + "id": "google_verify", + "when": { + "type": "all", + "all": [ + { "type": "provider_env_ready", "providerKey": "google-auth" }, + { "type": "has_valid_token", "authId": "google-auth/google" } + ] + }, + "skipRow": { + "label": "GET /identity/verify (Google)", + "detail": "No Google session — use “Google login” on Home first (AUTH here would be normal)." + }, + "steps": [ + { + "type": "host", + "label": "GET /identity/verify (Google)", + "hostKey": "main-api", + "method": "GET", + "path": "/identity/verify", + "auth": "google-auth/google" + } + ] + }, + { + "id": "probe_404", + "when": { "type": "ui_flag_probe_404" }, + "steps": [ + { + "type": "fetch", + "label": "GET /sim/not-found (expect 404)", + "path": "/sim/not-found", + "expectStatus": 404 + } + ] + } + ] +} diff --git a/poc/flutter-poc/lib/main.dart b/poc/flutter-poc/lib/main.dart index 94e4983..5ea0964 100644 --- a/poc/flutter-poc/lib/main.dart +++ b/poc/flutter-poc/lib/main.dart @@ -1,45 +1,104 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:morph_core/morph_core.dart'; import 'morph_init.dart'; import 'screens/home_screen.dart'; +/// Captures the OAuth params from the URL **synchronously** before runApp, +/// so we can clear them from `Uri.base` and process them after the UI mounts. +({String? code, String? state})? _captureWebOAuthParams() { + if (!kIsWeb) return null; + final uri = Uri.base; + final code = uri.queryParameters['code']; + final state = uri.queryParameters['state']; + if (code == null || state == null) return null; + return (code: code, state: state); +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); + // ignore: avoid_print + print('[morph-poc] main() start — ${Uri.base}'); + + final pendingOAuth = _captureWebOAuthParams(); + // ignore: avoid_print + print('[morph-poc] OAuth params present? ${pendingOAuth != null}'); + final morph = await initMorph(); + // ignore: avoid_print + print('[morph-poc] initMorph() done — runApp now'); - runApp(MorphPocApp(morph: morph)); + // Render UI immediately; HomeScreen will process the OAuth callback in initState. + runApp(MorphPocApp(morph: morph, pendingOAuth: pendingOAuth)); + // ignore: avoid_print + print('[morph-poc] runApp returned'); } class MorphPocApp extends StatefulWidget { - const MorphPocApp({super.key, required this.morph}); + const MorphPocApp({super.key, required this.morph, this.pendingOAuth}); final MorphClient morph; + final ({String? code, String? state})? pendingOAuth; @override State createState() => _MorphPocAppState(); } class _MorphPocAppState extends State { - late final AppLinks _appLinks; + AppLinks? _appLinks; StreamSubscription? _linkSubscription; String? _pendingOAuthMessage; + final GlobalKey _homeKey = GlobalKey(); @override void initState() { super.initState(); - _appLinks = AppLinks(); - _listenForDeepLinks(); + // ignore: avoid_print + print('[morph-poc] _MorphPocAppState.initState'); + if (!kIsWeb) { + _appLinks = AppLinks(); + _linkSubscription = _appLinks!.uriLinkStream.listen(_handleIncomingUri); + } else if (widget.pendingOAuth != null) { + // Process web OAuth callback AFTER the UI mounted, so a hang/error + // here does not prevent runApp from rendering. + WidgetsBinding.instance.addPostFrameCallback((_) { + _processPendingWebOAuth(); + }); + } } - void _listenForDeepLinks() { - _linkSubscription = _appLinks.uriLinkStream.listen(_handleIncomingUri); + Future _processPendingWebOAuth() async { + final p = widget.pendingOAuth!; + // ignore: avoid_print + print('[morph-poc] _processPendingWebOAuth: calling completeOAuthCallback…'); + try { + final result = await widget.morph.completeOAuthCallback( + code: p.code, + state: p.state, + ); + // ignore: avoid_print + print('[morph-poc] completeOAuthCallback DONE: ${result.status} / ${result.message}'); + if (mounted) { + setState(() => _pendingOAuthMessage = + 'OAuth complete: ${result.status}${result.message != null ? ' — ${result.message}' : ''}'); + // Trigger HomeScreen to re-read token status after OAuth completes. + await _homeKey.currentState?.refreshStatus(); + } + } catch (e, st) { + // ignore: avoid_print + print('[morph-poc] completeOAuthCallback THREW: $e\n$st'); + if (mounted) { + setState(() => _pendingOAuthMessage = 'OAuth callback error: $e'); + } + } } + /// Mobile/desktop: handles deep-link OAuth callbacks via app_links. Future _handleIncomingUri(Uri uri) async { if (!uri.toString().startsWith(kOAuthCallbackUri)) return; @@ -93,7 +152,7 @@ class _MorphPocAppState extends State { setState(() => _pendingOAuthMessage = null); }); } - return HomeScreen(morph: widget.morph); + return HomeScreen(key: _homeKey, morph: widget.morph); }, ), ); diff --git a/poc/flutter-poc/lib/morph_init.dart b/poc/flutter-poc/lib/morph_init.dart index 03b6e6b..b26da67 100644 --- a/poc/flutter-poc/lib/morph_init.dart +++ b/poc/flutter-poc/lib/morph_init.dart @@ -1,6 +1,7 @@ import 'dart:convert'; -import 'dart:io'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; import 'package:flutter/services.dart'; import 'package:morph_core/morph_core.dart'; import 'package:morph_core_storage/morph_core_storage.dart'; @@ -11,9 +12,22 @@ import 'package:morph_oauth2/morph_oauth2.dart'; import 'package:morph_storage/memory_storage_plugin.dart'; import 'package:shared_preferences/shared_preferences.dart'; -/// Deep-link URI scheme used by the Flutter PoC. +/// Deep-link URI scheme used by the Flutter PoC on mobile/desktop. const String kOAuthCallbackUri = 'morphpoc://oauth/callback'; +/// Returns the mock API base URL for the current platform. +/// Android emulators use 10.0.2.2 to reach the host machine's localhost. +String getMockApiBase() { + final host = (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + ? '10.0.2.2' + : 'localhost'; + return 'http://$host:3000'; +} + +/// Redirect URI used when running as a Flutter web app on Chrome. +/// Must be registered in Keycloak's morph-login client redirectUris. +const String kWebOAuthCallbackUri = 'http://localhost:4200/'; + /// Prefix for SharedPreferences keys. const String _kPrefsPrefix = 'morph-poc:'; @@ -23,10 +37,13 @@ final List morphLogLines = []; /// HTTP trace events collected via [MorphOptions.onHttpTrace]; consumed by the UI. final List morphHttpTraces = []; -/// Shared log appender — consistent format used by both MorphOptions.onLog -/// and ContextStoreOptions.onLog. +/// Shared log appender — writes to browser console AND keeps the in-memory +/// ring buffer (consumed by UI widgets). +// ignore: avoid_print void _appendLog(String level, String message, [Object? error]) { - final entry = '[$level] $message${error != null ? ' — $error' : ''}'; + final entry = '[morph][$level] $message${error != null ? ' — $error' : ''}'; + // ignore: avoid_print + print(entry); morphLogLines.add(entry); if (morphLogLines.length > 300) morphLogLines.removeAt(0); } @@ -40,22 +57,32 @@ Future initMorph() async { final logger = loggerPlugin(const LoggerPluginOptions(level: 'debug')); - // Persistent, boundary-scoped, optionally encrypted storage via morph_data_store. - // Falls back to in-memory storage if ContextStore initialization fails - // (e.g., Keychain unavailable in simulator without entitlements). + // Storage strategy: + // • Web: in-memory only. ContextStore requires an active user identity + // (Boundary.user) to build storage keys, but on web the OAuth redirect + // reloads the page — so the identity is never set before the token write. + // In-memory storage is correct here: completeOAuthCallback() runs in main() + // before runApp(), so the token lives in the same JS heap the app reads from. + // • Native: ContextStore (persistent, boundary-scoped). Deep-link callbacks + // do NOT reload the process, so identity can be set before token storage. MorphPlugin storagePlugin; - try { - final contextStore = await ContextStore.create(ContextStoreOptions( - onRequestServerTime: (_, __) async => null, - timeServerUrls: [], - onLog: (level, message, [err, ctx]) => - _appendLog(level.name, message, err), - )); - storagePlugin = contextStoreStoragePlugin(contextStore); - _appendLog('info', 'Storage: ContextStore (persistent)'); - } catch (e) { + if (kIsWeb) { storagePlugin = memoryStorageMorphPlugin(); - _appendLog('warn', 'Storage: fallback to in-memory — ContextStore init failed', e); + _appendLog('info', 'Storage: in-memory (web — ContextStore requires user identity)'); + } else { + try { + final contextStore = await ContextStore.create(ContextStoreOptions( + onRequestServerTime: (_, __) async => null, + timeServerUrls: [], + onLog: (level, message, [err, ctx]) => + _appendLog(level.name, message, err), + )); + storagePlugin = contextStoreStoragePlugin(contextStore); + _appendLog('info', 'Storage: ContextStore (persistent)'); + } catch (e) { + storagePlugin = memoryStorageMorphPlugin(); + _appendLog('warn', 'Storage: fallback to in-memory — ContextStore init failed', e); + } } final options = MorphOptions( @@ -95,7 +122,10 @@ Future> _buildVariables() async { // On Android emulators, localhost refers to the emulator's own loopback. // Use 10.0.2.2 to reach the host machine's localhost instead. - final host = Platform.isAndroid ? '10.0.2.2' : 'localhost'; + // On web (Chrome), localhost is always correct since the browser runs on the host. + final host = (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + ? '10.0.2.2' + : 'localhost'; final keycloakBase = 'http://$host:8080/realms/morph/protocol/openid-connect'; final mockApiBase = 'http://$host:3000'; @@ -126,8 +156,8 @@ Future> _buildVariables() async { 'pocGoogleTokenEndpoint': 'https://oauth2.googleapis.com/token', 'googleClientId': const String.fromEnvironment('GOOGLE_CLIENT_ID'), 'googleClientSecret': const String.fromEnvironment('GOOGLE_CLIENT_SECRET'), - // OAuth redirect — custom scheme handled by app_links. - 'oauthCallbackUri': kOAuthCallbackUri, + // OAuth redirect — HTTP redirect on web, custom scheme on mobile/desktop. + 'oauthCallbackUri': kIsWeb ? kWebOAuthCallbackUri : kOAuthCallbackUri, }; } diff --git a/poc/flutter-poc/lib/poc_simulation.dart b/poc/flutter-poc/lib/poc_simulation.dart new file mode 100644 index 0000000..372f40d --- /dev/null +++ b/poc/flutter-poc/lib/poc_simulation.dart @@ -0,0 +1,262 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:morph_core/morph_core.dart'; + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +sealed class PocSimStep { + const PocSimStep({required this.label}); + final String label; +} + +final class PocSimFetchStep extends PocSimStep { + const PocSimFetchStep({ + required super.label, + required this.path, + this.expectStatus, + this.skipInAutoSim = false, + }); + final String path; + final int? expectStatus; + final bool skipInAutoSim; +} + +final class PocSimHostStep extends PocSimStep { + const PocSimHostStep({ + required super.label, + required this.hostKey, + required this.method, + required this.path, + required this.auth, + this.body, + this.headers, + this.skipInAutoSim = false, + }); + final String hostKey; + final String method; + final String path; + final String auth; + final Object? body; + final Map? headers; + final bool skipInAutoSim; +} + +final class PocSimLogoutStep extends PocSimStep { + const PocSimLogoutStep({ + required super.label, + required this.providerKey, + }); + final String providerKey; +} + +final class PocSimStepResult { + const PocSimStepResult({ + required this.label, + required this.status, + this.detail, + this.body, + }); + final String label; + + /// HTTP status code, or one of: 'OK', 'ERR', 'NET', 'AUTH' + final Object status; + final String? detail; + final Object? body; + + bool get isError { + if (status is int) return (status as int) >= 400; + return status == 'ERR' || status == 'NET' || status == 'AUTH'; + } +} + +// --------------------------------------------------------------------------- +// Config loader +// --------------------------------------------------------------------------- + +class PocSimulationConfig { + PocSimulationConfig({ + required this.mockApiBaseUrl, + required this.steps, + required this.sessionDeadAuthIds, + required this.sessionDeadMessage, + }); + + final String mockApiBaseUrl; + final List steps; + final List sessionDeadAuthIds; + final String sessionDeadMessage; +} + +Future loadPocSimulation(String mockApiBase) async { + final raw = await rootBundle.loadString('assets/poc-simulation.json'); + final json = jsonDecode(raw) as Map; + + final mockApi = (json['mockApi'] as Map?) ?? {}; + final baseUrl = (mockApi['baseUrl'] as String?) ?? mockApiBase; + + final sessionDeadCheck = + (json['sessionDeadCheck'] as Map?) ?? {}; + final sessionDeadAuthIds = (sessionDeadCheck['authIds'] as List?) + ?.cast() ?? + const []; + final sessionDeadMessage = + (sessionDeadCheck['message'] as String?) ?? 'Session expired.'; + + final steps = []; + for (final raw in (json['steps'] as List? ?? [])) { + final s = _parseStep(raw as Map); + if (s != null) steps.add(s); + } + + // Conditional blocks (skip Google, include 404 probe as optional) + for (final block + in (json['conditionalBlocks'] as List? ?? [])) { + final b = block as Map; + final id = b['id'] as String? ?? ''; + // 404 probe included without condition check — SimulationPanel handles enable/disable + if (id == 'probe_404') { + for (final raw in (b['steps'] as List? ?? [])) { + final s = _parseStep(raw as Map); + if (s != null) steps.add(s); + } + } + // Google block: skip (not configured in the PoC env) + } + + return PocSimulationConfig( + mockApiBaseUrl: baseUrl, + steps: steps, + sessionDeadAuthIds: sessionDeadAuthIds, + sessionDeadMessage: sessionDeadMessage, + ); +} + +PocSimStep? _parseStep(Map m) { + final type = m['type'] as String?; + final label = m['label'] as String? ?? ''; + switch (type) { + case 'fetch': + return PocSimFetchStep( + label: label, + path: m['path'] as String? ?? '', + expectStatus: m['expectStatus'] as int?, + skipInAutoSim: m['skipInAutoSim'] as bool? ?? false, + ); + case 'host': + final rawHeaders = m['headers'] as Map?; + return PocSimHostStep( + label: label, + hostKey: m['hostKey'] as String? ?? 'main-api', + method: (m['method'] as String? ?? 'GET').toUpperCase(), + path: m['path'] as String? ?? '', + auth: m['auth'] as String? ?? '', + body: m['body'], + headers: rawHeaders?.cast(), + skipInAutoSim: m['skipInAutoSim'] as bool? ?? false, + ); + case 'logout_provider': + return PocSimLogoutStep( + label: label, + providerKey: m['providerKey'] as String? ?? '', + ); + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// Step executor +// --------------------------------------------------------------------------- + +Future runPocSimStep( + MorphClient morph, + PocSimulationConfig cfg, + PocSimStep step, +) async { + return switch (step) { + PocSimFetchStep s => _runFetch(s, cfg.mockApiBaseUrl), + PocSimHostStep s => _runHost(morph, s), + PocSimLogoutStep s => _runLogout(morph, s), + }; +} + +Future _runFetch( + PocSimFetchStep step, String mockApiBase) async { + final url = '$mockApiBase${step.path}'; + try { + final res = await http + .get(Uri.parse(url)) + .timeout(const Duration(seconds: 10)); + final expectedStatus = step.expectStatus; + final ok = expectedStatus == null + ? res.statusCode < 400 + : res.statusCode == expectedStatus; + Object? body; + try { + body = jsonDecode(res.body); + } catch (_) { + body = res.body; + } + return PocSimStepResult( + label: step.label, + status: res.statusCode, + detail: ok ? null : 'Unexpected status ${res.statusCode}', + body: body, + ); + } catch (e) { + return PocSimStepResult( + label: step.label, status: 'NET', detail: e.toString()); + } +} + +Future _runHost( + MorphClient morph, PocSimHostStep step) async { + try { + final host = morph.runtime.getHost(step.hostKey); + final res = await morph.runtime.http.hostFetch( + host, + step.path, + method: step.method, + body: step.body != null ? jsonEncode(step.body) : null, + auth: step.auth, + headers: step.headers, + ); + return PocSimStepResult( + label: step.label, + status: res.statusCode, + body: res.body, + ); + } catch (e) { + // PoC: morph_core/hostFetch errors are surfaced as opaque strings until + // typed error parity exists; keep heuristic aligned with Gemini review (#28). + final msg = e.toString(); + final isAuth = msg.contains('401') || + msg.contains('403') || + msg.contains('Unauthorized') || + msg.contains('invalid_grant'); + return PocSimStepResult( + label: step.label, + status: isAuth ? 'AUTH' : 'ERR', + detail: msg, + ); + } +} + +Future _runLogout( + MorphClient morph, PocSimLogoutStep step) async { + try { + await morph.auth(step.providerKey).logout(); + return PocSimStepResult( + label: step.label, + status: 'OK', + detail: 'Logged out ${step.providerKey}', + ); + } catch (e) { + return PocSimStepResult( + label: step.label, status: 'ERR', detail: e.toString()); + } +} diff --git a/poc/flutter-poc/lib/screens/home_screen.dart b/poc/flutter-poc/lib/screens/home_screen.dart index 58b4687..30a2b9f 100644 --- a/poc/flutter-poc/lib/screens/home_screen.dart +++ b/poc/flutter-poc/lib/screens/home_screen.dart @@ -1,38 +1,74 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:morph_core/morph_core.dart'; import 'package:url_launcher/url_launcher.dart'; import '../morph_init.dart'; -import '../widgets/http_trace_log.dart'; +import '../poc_simulation.dart'; +import '../widgets/mock_api_sheet.dart'; +import '../widgets/provider_config_sheet.dart'; +import '../widgets/simulation_panel.dart'; import '../widgets/token_status_card.dart'; + +const _kStatusLabels = { + 'morph-auth/device': 'Device', + 'morph-auth/2fa': 'Login (2fa)', + 'morph-auth/1fa': 'Session (1fa)', + 'google-auth/google': 'Google', +}; + +String _labelFor(MorphTokenStatus s) => + _kStatusLabels[s.authId] ?? s.authId; + class HomeScreen extends StatefulWidget { const HomeScreen({super.key, required this.morph}); final MorphClient morph; @override - State createState() => _HomeScreenState(); + State createState() => HomeScreenState(); } -class _HomeScreenState extends State { +class HomeScreenState extends State { + /// Public entrypoint so the parent app can request a refresh after OAuth. + Future refreshStatus() => _refreshStatus(); + List _tokenStatus = []; String _message = ''; bool _busy = false; + PocSimulationConfig? _simCfg; - MorphRuntime get _rt => widget.morph.runtime; + MorphClient get _morph => widget.morph; @override void initState() { super.initState(); - _refreshStatus(); + _init(); + } + + Future _init() async { + try { + print('[morph-poc] _init start'); + await _refreshStatus(); + print('[morph-poc] _init: loading sim config…'); + final cfg = await loadPocSimulation(getMockApiBase()); + print('[morph-poc] _init: sim config loaded (${cfg.steps.length} steps)'); + if (mounted) setState(() => _simCfg = cfg); + } catch (e, st) { + print('[morph-poc] _init THREW: $e\n$st'); + if (mounted) setState(() => _message = 'Init error: $e'); + } } Future _refreshStatus() async { try { - final status = await _rt.getTokenStatus(); + print('[morph-poc] _refreshStatus start'); + final status = await _morph.getTokenStatus(); + print('[morph-poc] _refreshStatus: ${status.length} entries — ${status.map((s) => "${s.authId}:${s.hasAccessToken ? 'token' : 'none'}").join(", ")}'); if (mounted) setState(() => _tokenStatus = status); - } catch (e) { + } catch (e, st) { + print('[morph-poc] _refreshStatus THREW: $e\n$st'); _setMessage('Error refreshing status: $e'); } } @@ -45,108 +81,152 @@ class _HomeScreenState extends State { if (mounted) setState(() => _busy = v); } - // ── Actions ────────────────────────────────────────────────────────────── + // ── Per-row dynamic actions (mirrors TS buildActionsForRow) ─────────────── + + List<_ContextAction> _actionsForRow(MorphTokenStatus row) { + final actions = <_ContextAction>[]; + final gh = row.grantHint; + + if (gh == 'client_credentials') { + actions.add(_ContextAction( + label: 'Acquire token', + onPressed: _busy ? null : () => _runAcquire(row.authId), + )); + } + if (gh == 'authorization_code') { + if (row.providerKey == 'morph-auth') { + actions.add(_ContextAction( + label: 'Keycloak login', + onPressed: _busy ? null : _startLogin, + )); + } + // Google: show disabled button with hint + if (row.providerKey == 'google-auth') { + actions.add(_ContextAction( + label: 'Google login', + onPressed: null, + tooltip: 'Configure GOOGLE_CLIENT_ID env (not set in PoC)', + )); + } + } + if (row.hasAccessToken || row.hasRefreshToken) { + actions.add(_ContextAction( + label: 'Logout', + danger: true, + onPressed: _busy ? null : () => _runLogout(row.authId), + )); + } + return actions; + } + + // ── Auth actions ────────────────────────────────────────────────────────── - Future _acquireDeviceToken() async { + Future _runAcquire(String authId) async { _setBusy(true); _setMessage(''); try { - await _rt.tokens.acquireWithClientCredentials( - 'morph-auth/device', - _rt.resolved.contextByAuthId['morph-auth/device']!, - ); - _setMessage('Device token acquired.'); + await _morph.auth(authId).acquireWithClientCredentials(); + _setMessage('Token acquired ($authId).'); } catch (e) { - _setMessage('Acquire device token failed: $e'); + _setMessage('Acquire failed: $e'); } finally { _setBusy(false); await _refreshStatus(); } } - Future _login() async { + Future _startLogin() async { const authId = 'morph-auth/2fa'; + _setMessage(''); try { - final url = _rt.getAuthorizationUrl(authId); - final uri = Uri.parse(url); - if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { - _setMessage('Could not open browser for login.'); + final url = _morph.getAuthorizationUrl(authId); + if (kIsWeb) { + // On web: navigate the CURRENT tab so Keycloak redirects back here + // with ?code=...&state=... — handled in main() before runApp. + // webOnlyWindowName: '_self' replaces the current tab instead of opening a new one. + await launchUrl(Uri.parse(url), webOnlyWindowName: '_self'); + } else { + if (!await launchUrl(Uri.parse(url), + mode: LaunchMode.externalApplication)) { + _setMessage('Could not open browser for login.'); + } } } catch (e) { _setMessage('Login failed: $e'); } } - Future _exchangeToken() async { + Future _runLogout(String authId) async { _setBusy(true); _setMessage(''); try { - await _rt.tokens.exchangeToken( - 'morph-auth/2fa', - _rt.resolved.contextByAuthId['morph-auth/2fa']!, - 'morph-auth/1fa', - ); - _setMessage('Token exchange complete (2fa → 1fa).'); + await _morph.auth(authId).logout(); + _setMessage('Logged out ($authId).'); } catch (e) { - _setMessage('Token exchange failed: $e'); + _setMessage('Logout failed: $e'); } finally { _setBusy(false); await _refreshStatus(); } } - Future _logout(String authId) async { + Future _runExchange(String sourceAuthId, String targetAuthId) async { _setBusy(true); _setMessage(''); try { - final ref = _rt.resolved.contextByAuthId[authId]; - if (ref != null) { - await _rt.tokens.logout(authId, ref, 'user-initiated'); - _setMessage('Logged out ($authId).'); - } + await _morph.auth(sourceAuthId).exchangeToken(targetAuthId); + _setMessage('Exchanged $sourceAuthId → $targetAuthId.'); } catch (e) { - _setMessage('Logout failed: $e'); + _setMessage('Exchange failed: $e'); } finally { _setBusy(false); await _refreshStatus(); } } - Future _callMockApi() async { - _setBusy(true); - _setMessage(''); - try { - final host = _rt.getHost('main-api'); - final response = await _rt.http.hostFetch( - host, - '/ping', - method: 'GET', - ); - _setMessage('Mock API → ${response.statusCode}: ${response.body}'); - } catch (e) { - _setMessage('Mock API call failed: $e'); - } finally { - _setBusy(false); - await _refreshStatus(); - } + // ── Open sheets ─────────────────────────────────────────────────────────── + + void _openMockApi() { + final cfg = _simCfg; + if (cfg == null) return; + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => MockApiSheet(morph: _morph, cfg: cfg), + ).then((_) => _refreshStatus()); + } + + void _openProviderConfig(String providerKey) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => + ProviderConfigSheet(morph: _morph, providerKey: providerKey), + ); } // ── Build ───────────────────────────────────────────────────────────────── + /// Group token statuses by providerKey preserving order. + Map> get _byProvider { + final out = >{}; + for (final s in _tokenStatus) { + out.putIfAbsent(s.providerKey, () => []).add(s); + } + return out; + } + @override Widget build(BuildContext context) { - final hasDevice = _tokenStatus - .any((s) => s.authId == 'morph-auth/device' && s.hasAccessToken); - final has2fa = _tokenStatus - .any((s) => s.authId == 'morph-auth/2fa' && s.hasAccessToken); - return Scaffold( appBar: AppBar( title: const Text('Morph Flutter PoC'), actions: [ IconButton( icon: const Icon(Icons.refresh), - tooltip: 'Refresh token status', + tooltip: 'Reload token snapshot', onPressed: _refreshStatus, ), ], @@ -158,66 +238,39 @@ class _HomeScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── Token status ── - _SectionHeader('Token Status'), + // ── Status ── + _SectionHeader('Status'), if (_tokenStatus.isEmpty) const Padding( padding: EdgeInsets.all(12), child: Text('Loading…'), ) else - ..._tokenStatus.map( - (s) => TokenStatusCard( - status: s, - onTap: () => showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => TokenClaimsSheet(status: s), - ), - ), - ), - - // ── Actions ── - _SectionHeader('Actions'), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Wrap( - spacing: 8, - runSpacing: 4, - children: [ - _ActionButton( - label: 'Acquire Device Token', - icon: Icons.devices, - onPressed: _busy ? null : _acquireDeviceToken, - ), - _ActionButton( - label: 'Login (2fa)', - icon: Icons.login, - onPressed: _busy ? null : _login, - ), - _ActionButton( - label: 'Exchange → 1fa', - icon: Icons.swap_horiz, - onPressed: (_busy || !has2fa) ? null : _exchangeToken, - ), - _ActionButton( - label: 'Logout 2fa', - icon: Icons.logout, - onPressed: (_busy || !has2fa) - ? null - : () => _logout('morph-auth/2fa'), - ), - _ActionButton( - label: 'Logout Device', - icon: Icons.device_unknown, - onPressed: (_busy || !hasDevice) - ? null - : () => _logout('morph-auth/device'), - ), - ], - ), - ), + ..._byProvider.entries.map((entry) { + try { + return _ProviderSection( + providerKey: entry.key, + rows: entry.value, + busy: _busy, + morph: _morph, + actionsForRow: _actionsForRow, + onExchange: _runExchange, + onConfigTap: () => _openProviderConfig(entry.key), + onJwtTap: (s) => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => TokenClaimsSheet(status: s), + ), + ); + } catch (e, st) { + print('[morph-poc] _ProviderSection build THREW for ${entry.key}: $e\n$st'); + return Padding( + padding: const EdgeInsets.all(12), + child: Text('UI error (${entry.key}): $e', + style: const TextStyle(color: Colors.red, fontSize: 11)), + ); + } + }), // ── Mock API ── _SectionHeader('Mock API'), @@ -225,9 +278,10 @@ class _HomeScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: ElevatedButton.icon( - icon: const Icon(Icons.cloud_outlined), - label: const Text('GET /ping'), - onPressed: _busy ? null : _callMockApi, + icon: const Icon(Icons.api_outlined), + label: const Text('Open Mock API & HTTP log'), + onPressed: + (_simCfg == null || _busy) ? null : _openMockApi, ), ), @@ -239,24 +293,26 @@ class _HomeScreenState extends State { child: Text(_message, style: const TextStyle(fontSize: 13)), ), - if (_busy) const Padding( padding: EdgeInsets.all(8), child: LinearProgressIndicator(), ), - // ── HTTP trace ── - _SectionHeader('HTTP Trace'), - StatefulBuilder( - builder: (_, refresh) => HttpTraceLog( - traces: List.from(morphHttpTraces), - onClear: () { - morphHttpTraces.clear(); - refresh(() {}); - }, + // ── Simulation ── + const _SectionHeader('Simulation'), + if (_simCfg == null) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text('Loading simulation config…', + style: TextStyle(fontSize: 13)), + ) + else + SimulationPanel( + morph: _morph, + cfg: _simCfg!, + onStatusChanged: _refreshStatus, ), - ), const SizedBox(height: 32), ], @@ -267,42 +323,302 @@ class _HomeScreenState extends State { } } -class _SectionHeader extends StatelessWidget { - const _SectionHeader(this.title); - final String title; +// --------------------------------------------------------------------------- +// Provider section widget +// --------------------------------------------------------------------------- + +class _ProviderSection extends StatefulWidget { + const _ProviderSection({ + required this.providerKey, + required this.rows, + required this.busy, + required this.morph, + required this.actionsForRow, + required this.onExchange, + required this.onConfigTap, + required this.onJwtTap, + }); + + final String providerKey; + final List rows; + final bool busy; + final MorphClient morph; + final List<_ContextAction> Function(MorphTokenStatus) actionsForRow; + final Future Function(String source, String target) onExchange; + final VoidCallback onConfigTap; + final void Function(MorphTokenStatus) onJwtTap; + + @override + State<_ProviderSection> createState() => _ProviderSectionState(); +} + +class _ProviderSectionState extends State<_ProviderSection> { + /// Selected exchange source per target authId. + final Map _exchangePick = {}; + + @override + void didUpdateWidget(_ProviderSection oldWidget) { + super.didUpdateWidget(oldWidget); + _syncExchangePicks(); + } + + @override + void initState() { + super.initState(); + _syncExchangePicks(); + } + + void _syncExchangePicks() { + for (final row in widget.rows) { + final sources = widget.morph.getExchangeSources(row.authId); + if (sources.isEmpty) { + _exchangePick.remove(row.authId); + } else { + final cur = _exchangePick[row.authId]; + if (cur == null || !sources.contains(cur)) { + _exchangePick[row.authId] = sources.first; + } + } + } + } @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(12, 16, 12, 4), - child: Text( - title, - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith(color: Theme.of(context).colorScheme.primary), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Provider header row + Row( + children: [ + Text( + widget.providerKey.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.06, + color: Colors.grey.shade600, + ), + ), + const Spacer(), + TextButton( + onPressed: widget.onConfigTap, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle(fontSize: 11)), + child: const Text('Config'), + ), + ], + ), + // Context rows + Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Column( + children: widget.rows.asMap().entries.map((e) { + final idx = e.key; + final row = e.value; + final isLast = idx == widget.rows.length - 1; + final sources = + widget.morph.getExchangeSources(row.authId); + final actions = widget.actionsForRow(row); + + return Column( + children: [ + // Token summary button (tap → JWT claims) + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: TokenStatusCard( + status: row, + label: _labelFor(row), + onTap: () => widget.onJwtTap(row), + ), + ), + // Action buttons + if (actions.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: actions.map((a) { + return _SmallButton( + label: a.label, + danger: a.danger, + tooltip: a.tooltip, + onPressed: widget.busy ? null : a.onPressed, + ); + }).toList(), + ), + ), + // Exchange dropdown (only for target contexts) + if (sources.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: _ExchangeRow( + targetAuthId: row.authId, + sources: sources, + picked: _exchangePick[row.authId] ?? sources.first, + busy: widget.busy, + onPickChanged: (v) => + setState(() => _exchangePick[row.authId] = v), + onExchange: () { + final src = _exchangePick[row.authId]; + if (src != null) { + widget.onExchange(src, row.authId); + } + }, + ), + ), + if (!isLast) + const Divider(height: 12, indent: 8, endIndent: 8), + if (isLast) const SizedBox(height: 8), + ], + ); + }).toList(), + ), + ), + ], ), ); } } -class _ActionButton extends StatelessWidget { - const _ActionButton({ +class _ExchangeRow extends StatelessWidget { + const _ExchangeRow({ + required this.targetAuthId, + required this.sources, + required this.picked, + required this.busy, + required this.onPickChanged, + required this.onExchange, + }); + + final String targetAuthId; + final List sources; + final String picked; + final bool busy; + final void Function(String) onPickChanged; + final VoidCallback onExchange; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Text('Subject', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Color(0xFF6B21A8))), + const SizedBox(width: 6), + Expanded( + child: DropdownButton( + value: picked, + isExpanded: true, + isDense: true, + style: const TextStyle(fontSize: 11, color: Color(0xFF1E1B4B)), + items: sources + .map((s) => DropdownMenuItem( + value: s, + child: Text( + '${_kStatusLabels[s] ?? s} ($s)', + overflow: TextOverflow.ellipsis), + )) + .toList(), + onChanged: busy ? null : (v) => v != null ? onPickChanged(v) : null, + ), + ), + const SizedBox(width: 6), + _SmallButton( + label: 'Exchange', + onPressed: busy ? null : onExchange, + ), + const SizedBox(width: 4), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Small shared widgets +// --------------------------------------------------------------------------- + +class _ContextAction { + const _ContextAction({ required this.label, - required this.icon, this.onPressed, + this.danger = false, + this.tooltip, }); + final String label; + final VoidCallback? onPressed; + final bool danger; + final String? tooltip; +} +class _SmallButton extends StatelessWidget { + const _SmallButton({ + required this.label, + this.onPressed, + this.danger = false, + this.tooltip, + }); final String label; - final IconData icon; final VoidCallback? onPressed; + final bool danger; + final String? tooltip; @override Widget build(BuildContext context) { - return FilledButton.tonalIcon( - icon: Icon(icon, size: 16), - label: Text(label), + final btn = OutlinedButton( onPressed: onPressed, + style: danger + ? OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + side: BorderSide( + color: Theme.of(context).colorScheme.error.withValues(alpha: 0.4)), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle(fontSize: 11)) + : OutlinedButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle(fontSize: 11)), + child: Text(label), ); + if (tooltip != null) { + return Tooltip(message: tooltip!, child: btn); + } + return btn; } } + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.title); + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 16, 12, 4), + child: Text( + title, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(color: Theme.of(context).colorScheme.primary), + ), + ); + } +} + diff --git a/poc/flutter-poc/lib/widgets/mock_api_sheet.dart b/poc/flutter-poc/lib/widgets/mock_api_sheet.dart new file mode 100644 index 0000000..3bf0fb2 --- /dev/null +++ b/poc/flutter-poc/lib/widgets/mock_api_sheet.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:morph_core/morph_core.dart'; + +import '../../morph_init.dart'; +import '../poc_simulation.dart'; +import 'http_trace_log.dart'; + +class MockApiSheet extends StatefulWidget { + const MockApiSheet({super.key, required this.morph, required this.cfg}); + + final MorphClient morph; + final PocSimulationConfig cfg; + + @override + State createState() => _MockApiSheetState(); +} + +class _MockApiSheetState extends State { + bool _busy = false; + String _message = ''; + bool _lastIsError = false; + Object? _lastBody; + + Future _run(PocSimStep step) async { + setState(() { + _busy = true; + _message = ''; + _lastBody = null; + }); + final result = await runPocSimStep(widget.morph, widget.cfg, step); + if (!mounted) return; + setState(() { + _busy = false; + _lastIsError = result.isError; + _message = + '${result.status}${result.detail != null ? ' — ${result.detail}' : ''}'; + _lastBody = result.body; + }); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + // Handle + Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + // Header + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const Expanded( + child: Text( + 'Mock API', + style: + TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + Text( + 'docs/poc/poc-simulation.json', + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.grey.shade600), + ), + ], + ), + ), + const Divider(height: 1), + // Step buttons + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: widget.cfg.steps.map((step) { + final isDanger = step is PocSimLogoutStep; + return FilledButton.tonal( + onPressed: _busy ? null : () => _run(step), + style: isDanger + ? FilledButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.errorContainer, + foregroundColor: + Theme.of(context).colorScheme.onErrorContainer, + ) + : null, + child: Text(step.label, + style: const TextStyle(fontSize: 12)), + ); + }).toList(), + ), + ), + // Status message + if (_busy) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: LinearProgressIndicator(), + ), + if (_message.isNotEmpty) + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + _message, + style: TextStyle( + fontSize: 12, + color: _lastIsError + ? Colors.red.shade700 + : Colors.green.shade700, + fontFamily: 'monospace', + ), + ), + ), + if (_lastBody != null) + Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + constraints: const BoxConstraints(maxHeight: 120), + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(6), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(10), + child: SelectableText( + _lastBody is Map || _lastBody is List + ? const JsonEncoder.withIndent(' ') + .convert(_lastBody) + : _lastBody.toString(), + style: const TextStyle( + color: Color(0xFFE2E8F0), + fontSize: 11, + fontFamily: 'monospace'), + ), + ), + ), + const Divider(height: 1), + // HTTP trace log + Expanded( + child: StatefulBuilder( + builder: (_, refresh) => HttpTraceLog( + traces: List.from(morphHttpTraces), + onClear: () { + morphHttpTraces.clear(); + refresh(() {}); + }, + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/poc/flutter-poc/lib/widgets/provider_config_sheet.dart b/poc/flutter-poc/lib/widgets/provider_config_sheet.dart new file mode 100644 index 0000000..91c5d2e --- /dev/null +++ b/poc/flutter-poc/lib/widgets/provider_config_sheet.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:morph_core/morph_core.dart'; + +class ProviderConfigSheet extends StatelessWidget { + const ProviderConfigSheet({ + super.key, + required this.morph, + required this.providerKey, + }); + + final MorphClient morph; + final String providerKey; + + @override + Widget build(BuildContext context) { + Object meta; + try { + final m = morph.getProviderMeta(providerKey); + meta = { + 'key': m.key, + 'type': m.type, + 'baseUrl': m.baseUrl, + if (m.authorizationBrowserBaseUrl != null) + 'authorizationBrowserBaseUrl': m.authorizationBrowserBaseUrl, + if (m.tokenHttpBaseUrl != null) + 'tokenHttpBaseUrl': m.tokenHttpBaseUrl, + 'contexts': m.contexts + .map((c) => { + 'key': c.key, + 'authId': c.authId, + if (c.clientId != null) 'clientId': c.clientId, + if (c.clientAuth != null) 'clientAuth': c.clientAuth, + if (c.audience != null) 'audience': c.audience, + if (c.scopes != null) 'scopes': c.scopes, + }) + .toList(), + }; + } catch (e) { + meta = {'_error': e.toString()}; + } + + final formatted = + const JsonEncoder.withIndent(' ').convert(meta); + + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Provider config', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700), + ), + Text( + "morph.getProviderMeta('$providerKey')", + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.grey.shade600), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(16), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(12), + child: SelectableText( + formatted, + style: const TextStyle( + color: Color(0xFFE2E8F0), + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/poc/flutter-poc/lib/widgets/simulation_panel.dart b/poc/flutter-poc/lib/widgets/simulation_panel.dart new file mode 100644 index 0000000..fc33e0a --- /dev/null +++ b/poc/flutter-poc/lib/widgets/simulation_panel.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:morph_core/morph_core.dart'; + +import '../poc_simulation.dart'; + +class SimulationPanel extends StatefulWidget { + const SimulationPanel({ + super.key, + required this.morph, + required this.cfg, + this.onStatusChanged, + }); + + final MorphClient morph; + final PocSimulationConfig cfg; + final VoidCallback? onStatusChanged; + + @override + State createState() => _SimulationPanelState(); +} + +class _SimulationPanelState extends State { + bool _running = false; + bool _probe404Enabled = false; + final List _results = []; + String _sessionDeadMessage = ''; + + List get _autoSteps => widget.cfg.steps + .where((s) => + s is! PocSimFetchStep || !s.skipInAutoSim) + .where((s) => + s is! PocSimHostStep || !s.skipInAutoSim) + .where((s) { + if (s is PocSimFetchStep && s.path == '/sim/not-found') { + return _probe404Enabled; + } + return true; + }) + .toList(); + + Future _runAll() async { + setState(() { + _running = true; + _results.clear(); + _sessionDeadMessage = ''; + }); + + for (final step in _autoSteps) { + if (!mounted) break; + final result = await runPocSimStep(widget.morph, widget.cfg, step); + + if (!mounted) break; + setState(() => _results.add(result)); + + // Session dead check + if (result.status == 'AUTH') { + final detail = result.detail ?? ''; + final isSessionDead = widget.cfg.sessionDeadAuthIds.any( + (id) => step is PocSimHostStep && step.auth == id, + ); + if (isSessionDead && + (detail.contains('invalid_grant') || + detail.contains('Token is not active'))) { + setState(() => _sessionDeadMessage = widget.cfg.sessionDeadMessage); + break; + } + } + } + + if (mounted) { + setState(() => _running = false); + widget.onStatusChanged?.call(); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: Row( + children: [ + // Title lives in HomeScreen (`_SectionHeader('Simulation')`). + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('404 probe', + style: TextStyle( + fontSize: 11, color: Colors.grey.shade600)), + Switch.adaptive( + value: _probe404Enabled, + onChanged: _running + ? null + : (v) => setState(() => _probe404Enabled = v), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + const Spacer(), + FilledButton.icon( + icon: _running + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.play_arrow, size: 16), + label: Text(_running ? 'Running…' : 'Run simulation'), + onPressed: _running ? null : _runAll, + ), + if (_results.isNotEmpty) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: _running + ? null + : () => setState(() { + _results.clear(); + _sessionDeadMessage = ''; + }), + child: const Text('Clear'), + ), + ], + ], + ), + ), + if (_sessionDeadMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 0), + child: Text( + _sessionDeadMessage, + style: TextStyle( + fontSize: 12, color: colorScheme.error), + ), + ), + if (_results.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 0), + child: Column( + children: _results + .map((r) => _ResultRow(result: r)) + .toList(), + ), + ), + if (_running && _results.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: LinearProgressIndicator(), + ), + ], + ); + } +} + +class _ResultRow extends StatelessWidget { + const _ResultRow({required this.result}); + final PocSimStepResult result; + + @override + Widget build(BuildContext context) { + final isOk = !result.isError; + final statusStr = result.status.toString(); + + return Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Row( + children: [ + Icon( + isOk ? Icons.check_circle_outline : Icons.cancel_outlined, + size: 14, + color: isOk ? Colors.green.shade700 : Colors.red.shade700, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + result.label, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Text( + statusStr, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + color: isOk ? Colors.green.shade800 : Colors.red.shade800, + ), + ), + if (result.detail != null) ...[ + const SizedBox(width: 4), + Flexible( + child: Text( + result.detail!, + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade600, + fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ); + } +} diff --git a/poc/flutter-poc/lib/widgets/token_status_card.dart b/poc/flutter-poc/lib/widgets/token_status_card.dart index 5f48196..414312d 100644 --- a/poc/flutter-poc/lib/widgets/token_status_card.dart +++ b/poc/flutter-poc/lib/widgets/token_status_card.dart @@ -13,20 +13,24 @@ const _kLabels = { /// A card displaying the status of one [MorphTokenStatus] entry. class TokenStatusCard extends StatelessWidget { - const TokenStatusCard({super.key, required this.status, this.onTap}); + const TokenStatusCard({super.key, required this.status, this.label, this.onTap}); final MorphTokenStatus status; + + /// Optional display label; falls back to [_kLabels] map then [authId]. + final String? label; final VoidCallback? onTap; @override Widget build(BuildContext context) { - final label = _kLabels[status.authId] ?? status.authId; + final label = this.label ?? _kLabels[status.authId] ?? status.authId; final hasToken = status.hasAccessToken; final valid = status.accessLikelyValid; final expiry = _formatExp(status); return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + margin: EdgeInsets.zero, + elevation: 0, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), diff --git a/poc/flutter-poc/pubspec.lock b/poc/flutter-poc/pubspec.lock index dca74cb..5e132ab 100644 --- a/poc/flutter-poc/pubspec.lock +++ b/poc/flutter-poc/pubspec.lock @@ -233,7 +233,7 @@ packages: source: hosted version: "1.0.3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/poc/flutter-poc/pubspec.yaml b/poc/flutter-poc/pubspec.yaml index 7269bfa..e0bdd60 100644 --- a/poc/flutter-poc/pubspec.yaml +++ b/poc/flutter-poc/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: path: ../../../morph-data-store/adapters/dart-morph-core-storage morph_data_store: path: ../../../morph-data-store/core/dart-morph-data-store + http: ^1.2.0 url_launcher: ^6.3.0 app_links: ^6.4.0 shared_preferences: ^2.3.0 @@ -37,3 +38,4 @@ flutter: uses-material-design: true assets: - assets/poc-config.json + - assets/poc-simulation.json diff --git a/poc/flutter-poc/run_web.sh b/poc/flutter-poc/run_web.sh new file mode 100755 index 0000000..115a03c --- /dev/null +++ b/poc/flutter-poc/run_web.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# run_web.sh — start all PoC backends and the Flutter web app on Chrome +# +# Usage: ./run_web.sh [--no-keycloak] [--no-mock-api] [--debug] +# +# Ports: +# 8080 — Keycloak (Docker) +# 3000 — Mock API (Node) +# 4200 — Flutter web app (Chrome) +# +# By default runs in --profile mode (single bundled JS, loads in ~2s). +# Pass --debug for hot-reload at the cost of a 30-60s load on each page +# reload (596 separate JS files), which can cause the OAuth code to expire. +# --------------------------------------------------------------------------- +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +POC_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +FLUTTER_PORT=4200 + +# --- flags --- +START_KEYCLOAK=true +START_MOCK_API=true +FLUTTER_MODE="--profile" +for arg in "$@"; do + case "$arg" in + --no-keycloak) START_KEYCLOAK=false ;; + --no-mock-api) START_MOCK_API=false ;; + --debug) FLUTTER_MODE="" ;; + esac +done + +# --- cleanup on exit --- +MOCK_API_PID="" +cleanup() { + echo "" + echo "Shutting down..." + [ -n "$MOCK_API_PID" ] && kill "$MOCK_API_PID" 2>/dev/null || true + if $START_KEYCLOAK; then + docker compose -f "$POC_DIR/keycloak/docker-compose.yml" stop 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +# --- 1. Keycloak --- +if $START_KEYCLOAK; then + echo "▶ Starting Keycloak..." + if ! docker compose -f "$POC_DIR/keycloak/docker-compose.yml" up -d 2>&1; then + echo " ⚠ Docker not available — skipping Keycloak." + echo " Start OrbStack (or Docker Desktop) and re-run, or pass --no-keycloak." + START_KEYCLOAK=false + else + echo -n " Waiting for Keycloak to be ready" + until curl -sf http://localhost:8080/realms/morph > /dev/null 2>&1; do + echo -n "." + sleep 2 + done + echo " ready!" + fi +else + echo "⏭ Skipping Keycloak (--no-keycloak)" +fi + +# --- 2. Mock API --- +if $START_MOCK_API; then + MOCK_API_DIR="$POC_DIR/mock-api" + if [ ! -d "$MOCK_API_DIR/node_modules" ]; then + echo "▶ Installing mock-api dependencies..." + npm install --prefix "$MOCK_API_DIR" --silent + fi + + echo "▶ Starting Mock API on http://localhost:3000..." + node "$MOCK_API_DIR/server.js" & + MOCK_API_PID=$! + sleep 1 + echo " Mock API running (PID: $MOCK_API_PID)" +else + echo "⏭ Skipping Mock API (--no-mock-api)" +fi + +# --- 3. Flutter web --- +echo "" +echo "▶ Starting Flutter web app on http://localhost:$FLUTTER_PORT ..." +echo " OAuth callback: http://localhost:$FLUTTER_PORT/" + +cd "$SCRIPT_DIR" +if [ -n "$FLUTTER_MODE" ]; then + echo " Mode: profile (fast load, no hot-reload — use --debug for hot-reload)" +else + echo " Mode: debug (hot-reload enabled, but page reloads are slow ~30-60s)" +fi +echo "" + +# shellcheck disable=SC2086 +flutter run \ + $FLUTTER_MODE \ + --device-id chrome \ + --web-port "$FLUTTER_PORT" \ + --web-browser-flag "--disable-web-security" \ + --dart-define DEVICE_CLIENT_SECRET=morph-device-secret \ + --dart-define LOGIN_CLIENT_SECRET=morph-login-secret \ + --dart-define SESSION_CLIENT_SECRET=morph-session-secret diff --git a/poc/flutter-poc/web/favicon.png b/poc/flutter-poc/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/poc/flutter-poc/web/favicon.png differ diff --git a/poc/flutter-poc/web/icons/Icon-192.png b/poc/flutter-poc/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/poc/flutter-poc/web/icons/Icon-192.png differ diff --git a/poc/flutter-poc/web/icons/Icon-512.png b/poc/flutter-poc/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/poc/flutter-poc/web/icons/Icon-512.png differ diff --git a/poc/flutter-poc/web/icons/Icon-maskable-192.png b/poc/flutter-poc/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/poc/flutter-poc/web/icons/Icon-maskable-192.png differ diff --git a/poc/flutter-poc/web/icons/Icon-maskable-512.png b/poc/flutter-poc/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/poc/flutter-poc/web/icons/Icon-maskable-512.png differ diff --git a/poc/flutter-poc/web/index.html b/poc/flutter-poc/web/index.html new file mode 100644 index 0000000..de27731 --- /dev/null +++ b/poc/flutter-poc/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + morph_flutter_poc + + + + + + + diff --git a/poc/flutter-poc/web/manifest.json b/poc/flutter-poc/web/manifest.json new file mode 100644 index 0000000..2df1494 --- /dev/null +++ b/poc/flutter-poc/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "morph_flutter_poc", + "short_name": "morph_flutter_poc", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/poc/keycloak/morph-realm.json b/poc/keycloak/morph-realm.json index a50aa9c..31c93e3 100644 --- a/poc/keycloak/morph-realm.json +++ b/poc/keycloak/morph-realm.json @@ -10,8 +10,14 @@ "refreshTokenMaxReuse": 0, "roles": { "realm": [ - { "name": "user", "description": "Standard user" }, - { "name": "admin", "description": "Admin user" } + { + "name": "user", + "description": "Standard user" + }, + { + "name": "admin", + "description": "Admin user" + } ] }, "users": [ @@ -29,7 +35,9 @@ "temporary": false } ], - "realmRoles": ["user"] + "realmRoles": [ + "user" + ] }, { "username": "admin", @@ -45,7 +53,10 @@ "temporary": false } ], - "realmRoles": ["user", "admin"] + "realmRoles": [ + "user", + "admin" + ] } ], "clients": [ @@ -63,13 +74,16 @@ "protocol": "openid-connect", "fullScopeAllowed": true, "attributes": { - "access.token.lifespan": "15" - } + "access.token.lifespan": "30" + }, + "webOrigins": [ + "http://localhost:4200" + ] }, { "clientId": "morph-login", "name": "Morph Login Client (2FA)", - "description": "User login via authorization_code. 30s access, 60s client session idle (refresh window). Token exchange enabled for 2FA→1FA flow.", + "description": "User login via authorization_code. 30s access, 60s client session idle (refresh window). Token exchange enabled for 2FA\u21921FA flow.", "enabled": true, "clientAuthenticatorType": "client-secret", "secret": "morph-login-secret", @@ -89,15 +103,18 @@ "http://localhost:5173/auth/callback", "http://127.0.0.1:5173/auth/callback", "http://localhost:5173/*", - "http://127.0.0.1:5173/*" + "http://127.0.0.1:5173/*", + "http://localhost:4200/", + "http://localhost:4200/*" ], "webOrigins": [ "http://localhost:3000", "http://localhost:5173", - "http://127.0.0.1:5173" + "http://127.0.0.1:5173", + "http://localhost:4200" ], "attributes": { - "access.token.lifespan": "30", + "access.token.lifespan": "60", "client.session.idle.timeout": "60", "oauth2.token.exchange.grant.enabled": "true" }, @@ -120,7 +137,7 @@ { "clientId": "morph-session", "name": "Morph Session Client (1FA)", - "description": "Session token via 2FA→1FA token exchange. 20s access, 60s client session idle. Token exchange enabled.", + "description": "Session token via 2FA\u21921FA token exchange. 20s access, 60s client session idle. Token exchange enabled.", "enabled": true, "clientAuthenticatorType": "client-secret", "secret": "morph-session-secret", @@ -131,10 +148,13 @@ "protocol": "openid-connect", "fullScopeAllowed": true, "attributes": { - "access.token.lifespan": "20", + "access.token.lifespan": "40", "client.session.idle.timeout": "60", "oauth2.token.exchange.grant.enabled": "true" - } + }, + "webOrigins": [ + "http://localhost:4200" + ] }, { "clientId": "morph-api", @@ -146,5 +166,8 @@ "protocol": "openid-connect", "fullScopeAllowed": true } - ] -} + ], + "accessCodeLifespan": 300, + "accessCodeLifespanLogin": 300, + "accessCodeLifespanUserAction": 300 +} \ No newline at end of file