diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 40823eab..4d99e49a 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4602,6 +4602,48 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { } } + // Consent session payload may omit metadata fields such as logo_url. + // Rehydrate missing display fields from the full Hydra client object. + for clientID, record := range records { + if record == nil { + continue + } + needsHydraLookup := record.Logo == "" || record.URL == "" || record.InitURL == "" + if !needsHydraLookup { + continue + } + + client, err := h.Hydra.GetClient(c.Context(), clientID) + if err != nil { + continue + } + + if record.Name == "" { + name := strings.TrimSpace(client.ClientName) + if name == "" { + name = client.ClientID + } + record.Name = name + } + if record.Logo == "" { + record.Logo = extractHydraClientLogo(client.Metadata) + } + if record.URL == "" { + record.URL = resolveLinkedRPURL( + client.ClientID, + client.ClientURI, + client.RedirectURIs, + ) + } + if record.InitURL == "" { + record.InitURL = resolveLinkedRPInitURL( + client.ClientID, + record.Scopes, + client.RedirectURIs, + ) + } + } + // [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함) if h.ConsentRepo != nil { for _, subject := range subjects { diff --git a/backend/internal/handler/auth_handler_linked_test.go b/backend/internal/handler/auth_handler_linked_test.go index 0c7a9c07..f4ec811a 100644 --- a/backend/internal/handler/auth_handler_linked_test.go +++ b/backend/internal/handler/auth_handler_linked_test.go @@ -165,3 +165,88 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { assert.Equal(t, "1", parsedInitURL.Query().Get("auto")) assert.Equal(t, "/clients", parsedInitURL.Query().Get("returnTo")) } + +func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadata(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "user-123", + }, + }), nil + } + case "hydra.test": + if r.URL.Path == "/oauth2/auth/sessions/consent" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + { + "client": map[string]interface{}{ + "client_id": "gitea-client", + "client_name": "Gitea", + "redirect_uris": []string{ + "https://gitea.example.com/callback", + }, + }, + "grant_scope": []string{"openid", "profile"}, + "handled_at": time.Now().Format(time.RFC3339), + }, + }), nil + } + if r.URL.Path == "/clients/gitea-client" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "gitea-client", + "client_name": "Gitea", + "redirect_uris": []string{ + "https://gitea.example.com/callback", + }, + "metadata": map[string]interface{}{ + "logo_url": "https://cdn.example.com/gitea.svg", + }, + }), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { + http.DefaultClient = origDefault + }() + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + } + + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test") + t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc") + + app := newLinkedRpTestApp(h) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/linked", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res struct { + Items []struct { + ID string `json:"id"` + Logo string `json:"logo"` + } `json:"items"` + } + json.NewDecoder(resp.Body).Decode(&res) + + assert.Len(t, res.Items, 1) + assert.Equal(t, "gitea-client", res.Items[0].ID) + assert.Equal(t, "https://cdn.example.com/gitea.svg", res.Items[0].Logo) +} diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 4dea1f97..09649860 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -1384,6 +1385,7 @@ class _DashboardScreenState extends ConsumerState { _ActivityItem( clientId: rp.id, appName: name, + logo: rp.logo.trim(), lastAuthAt: lastAuthLabel, status: statusCode, scopes: rp.scopes, @@ -1522,37 +1524,7 @@ class _DashboardScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: Text( - item.appName, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: _ink, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: statusColor, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - item.status == 'active' - ? tr('ui.userfront.dashboard.activity.linked') - : tr('ui.userfront.dashboard.status.revoked'), - style: const TextStyle( - fontSize: 11, - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), + _buildActivityCardHeader(item, statusColor), const SizedBox(height: 10), Text( tr('ui.userfront.dashboard.last_auth_label'), @@ -1658,6 +1630,115 @@ class _DashboardScreenState extends ConsumerState { return opaqueCard; } + Widget _buildActivityCardHeader(_ActivityItem item, Color statusColor) { + final statusBadge = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + item.status == 'active' + ? tr('ui.userfront.dashboard.activity.linked') + : tr('ui.userfront.dashboard.status.revoked'), + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ); + + return SizedBox( + height: 40, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (item.logo.isNotEmpty) ...[ + _buildActivityLogo(item.logo), + const SizedBox(width: 10), + ], + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + item.appName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: _ink, + height: 1.25, + ), + ), + ), + ), + const SizedBox(width: 8), + statusBadge, + ], + ), + ); + } + + Widget _buildActivityLogo(String logoUrl) { + return SizedBox( + width: 40, + height: 40, + child: _buildActivityLogoImage(logoUrl), + ); + } + + Widget _buildActivityLogoImage(String logoUrl) { + final isSvg = _isSvgLogoUrl(logoUrl); + return isSvg + ? SvgPicture.network( + logoUrl, + fit: BoxFit.contain, + placeholderBuilder: (context) => _buildActivityLogoLoading(), + ) + : Image.network( + logoUrl, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return _buildActivityLogoFallback(); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } + return _buildActivityLogoLoading(); + }, + ); + } + + bool _isSvgLogoUrl(String logoUrl) { + final normalized = logoUrl.trim().toLowerCase(); + if (normalized.isEmpty) { + return false; + } + final uri = Uri.tryParse(normalized); + final path = uri?.path.toLowerCase() ?? normalized; + return path.endsWith('.svg'); + } + + Widget _buildActivityLogoLoading() { + return Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey[400], + ), + ), + ); + } + + Widget _buildActivityLogoFallback() { + return Icon(Icons.apps_rounded, size: 20, color: Colors.grey[500]); + } + Widget _buildAccessHistory(AuthTimelineState state, bool isWide) { final sessionsState = ref.watch(userSessionsProvider); if (state.isLoading && state.items.isEmpty) { @@ -2470,6 +2551,7 @@ enum _HistorySessionStatus { current, active, inactive } class _ActivityItem { final String clientId; final String appName; + final String logo; final String lastAuthAt; final String status; final String? url; @@ -2482,6 +2564,7 @@ class _ActivityItem { _ActivityItem({ required this.clientId, required this.appName, + required this.logo, required this.lastAuthAt, required this.status, required this.scopes, diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index da86790b..238c821f 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -184,6 +184,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" + url: "https://pub.dev" + source: hosted + version: "2.2.4" flutter_test: dependency: "direct dev" description: flutter @@ -388,6 +396,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider_linux: dependency: transitive description: @@ -753,6 +769,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373" + url: "https://pub.dev" + source: hosted + version: "1.1.21" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" + url: "https://pub.dev" + source: hosted + version: "1.2.0" vector_math: dependency: transitive description: @@ -825,6 +865,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yaml: dependency: transitive description: diff --git a/userfront/pubspec.yaml b/userfront/pubspec.yaml index 71552d8c..cc71655e 100644 --- a/userfront/pubspec.yaml +++ b/userfront/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: go_router: ^17.0.1 http: ^1.6.0 flutter_dotenv: ^6.0.0 + flutter_svg: ^2.2.1 url_launcher: ^6.3.2 logging: ^1.2.0 logger: ^2.0.0