From f5c4ffa92ff3ad5f500176fbc02a71ac25a178c5 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 10:47:57 +0900 Subject: [PATCH 01/14] =?UTF-8?q?linked=20RP=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=201st-party=20=EC=95=B1=20=EC=9E=90=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20init=5Furl=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 100 +++++++++++++++--- .../handler/auth_handler_linked_test.go | 41 +++++-- 2 files changed, 119 insertions(+), 22 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index d1195a30..40823eab 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4483,7 +4483,8 @@ type linkedRpSummary struct { ID string `json:"id"` Name string `json:"name"` Logo string `json:"logo,omitempty"` - URL string `json:"url,omitempty"` // Added + URL string `json:"url,omitempty"` + InitURL string `json:"init_url,omitempty"` LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"` Status string `json:"status"` Scopes []string `json:"scopes,omitempty"` @@ -4564,17 +4565,19 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" { scopes = strings.Fields(client.Scope) } + initURL := resolveLinkedRPInitURL(client.ClientID, scopes, client.RedirectURIs) existing := records[clientID] if existing == nil { records[clientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ - ID: clientID, - Name: name, - Logo: extractHydraClientLogo(client.Metadata), - URL: clientURL, - Status: "active", // Hydra 세션이 있으면 활성 - Scopes: scopes, + ID: clientID, + Name: name, + Logo: extractHydraClientLogo(client.Metadata), + URL: clientURL, + InitURL: initURL, + Status: "active", // Hydra 세션이 있으면 활성 + Scopes: scopes, }, lastAuth: lastAuth, } @@ -4590,6 +4593,9 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { if existing.URL == "" { existing.URL = clientURL } + if existing.InitURL == "" { + existing.InitURL = initURL + } existing.Scopes = mergeScopes(existing.Scopes, scopes) if lastAuth.After(existing.lastAuth) { existing.lastAuth = lastAuth @@ -4644,15 +4650,21 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { client.ClientURI, client.RedirectURIs, ) + initURL := resolveLinkedRPInitURL( + client.ClientID, + dc.GrantedScopes, + client.RedirectURIs, + ) records[dc.ClientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ - ID: dc.ClientID, - Name: name, - Logo: extractHydraClientLogo(client.Metadata), - URL: clientURL, - Status: status, - Scopes: dc.GrantedScopes, + ID: dc.ClientID, + Name: name, + Logo: extractHydraClientLogo(client.Metadata), + URL: clientURL, + InitURL: initURL, + Status: status, + Scopes: dc.GrantedScopes, }, lastAuth: dc.UpdatedAt, } @@ -4726,6 +4738,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { } } record.URL = clientURL + record.InitURL = resolveLinkedRPInitURL( + client.ClientID, + scopes, + client.RedirectURIs, + ) } else { // Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체 if record.Name == "" { @@ -6778,6 +6795,63 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string return "" } +func resolveLinkedRPInitURL(clientID string, scopes []string, redirectURIs []string) string { + clientID = strings.TrimSpace(clientID) + if clientID == "" { + return "" + } + + switch clientID { + case "adminfront": + if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ADMINFRONT_URL")), "/"); value != "" { + return value + "/login?auto=1" + } + case "devfront": + if value := strings.TrimRight(strings.TrimSpace(os.Getenv("DEVFRONT_URL")), "/"); value != "" { + return value + "/login?auto=1&returnTo=%2Fclients" + } + } + + hydraPublicURL := strings.TrimRight(os.Getenv("HYDRA_PUBLIC_URL"), "/") + if hydraPublicURL == "" { + userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") + if userfrontURL == "" { + userfrontURL = "https://sso.hmac.kr" + } + hydraPublicURL = userfrontURL + "/oidc" + } + + redirectURI := "" + if len(redirectURIs) > 0 { + redirectURI = strings.TrimSpace(redirectURIs[0]) + } + + mergedScopes := make([]string, 0, len(scopes)+1) + seen := map[string]struct{}{} + for _, scope := range append([]string{"openid"}, scopes...) { + scope = strings.TrimSpace(scope) + if scope == "" { + continue + } + if _, ok := seen[scope]; ok { + continue + } + seen[scope] = struct{}{} + mergedScopes = append(mergedScopes, scope) + } + + params := url.Values{} + params.Set("client_id", clientID) + params.Set("response_type", "code") + params.Set("scope", strings.Join(mergedScopes, " ")) + params.Set("state", GenerateSecureAlnumToken(16)) + if redirectURI != "" { + params.Set("redirect_uri", redirectURI) + } + + return fmt.Sprintf("%s/oauth2/auth?%s", hydraPublicURL, params.Encode()) +} + func mergeScopes(current []string, next []string) []string { if len(next) == 0 { return current diff --git a/backend/internal/handler/auth_handler_linked_test.go b/backend/internal/handler/auth_handler_linked_test.go index b9618d77..0c7a9c07 100644 --- a/backend/internal/handler/auth_handler_linked_test.go +++ b/backend/internal/handler/auth_handler_linked_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -45,11 +46,14 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ { "client": map[string]interface{}{ - "client_id": "client-active", - "client_name": "Active App", + "client_id": "devfront", + "client_name": "DevFront", + "redirect_uris": []string{ + "https://active.example.com/callback", + }, }, - "granted_scope": []string{"openid"}, - "handled_at": time.Now().Format(time.RFC3339), + "grant_scope": []string{"openid", "profile"}, + "handled_at": time.Now().Format(time.RFC3339), }, }), nil } @@ -111,6 +115,8 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { 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") + t.Setenv("DEVFRONT_URL", "http://localhost:5174") app := newLinkedRpTestApp(h) @@ -123,10 +129,11 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { var res struct { Items []struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Scopes []string `json:"scopes"` + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Scopes []string `json:"scopes"` + InitURL string `json:"init_url"` } `json:"items"` } json.NewDecoder(resp.Body).Decode(&res) @@ -138,7 +145,23 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { statusMap[item.ID] = item.Status } - assert.Equal(t, "active", statusMap["client-active"]) + assert.Equal(t, "active", statusMap["devfront"]) assert.Equal(t, "inactive", statusMap["client-consent"]) assert.Equal(t, "inactive", statusMap["client-audit"]) + + var activeInitURL string + for _, item := range res.Items { + if item.ID == "devfront" { + activeInitURL = item.InitURL + break + } + } + + parsedInitURL, err := url.Parse(activeInitURL) + assert.NoError(t, err) + assert.Equal(t, "http", parsedInitURL.Scheme) + assert.Equal(t, "localhost:5174", parsedInitURL.Host) + assert.Equal(t, "/login", parsedInitURL.Path) + assert.Equal(t, "1", parsedInitURL.Query().Get("auto")) + assert.Equal(t, "/clients", parsedInitURL.Query().Get("returnTo")) } From c3605cc86b259e08717c8e94f62484bfa3f7030d Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 10:48:39 +0900 Subject: [PATCH 02/14] =?UTF-8?q?App=20=ED=98=84=ED=99=A9=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20init=5Furl=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20=EC=A7=84=EC=9E=85=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/domain/linked_rp_launch.dart | 21 ++++++ .../lib/features/dashboard/domain/models.dart | 3 + .../domain/providers/linked_rps_provider.dart | 3 + .../presentation/dashboard_screen.dart | 6 +- userfront/test/linked_rp_launch_test.dart | 72 +++++++++++++++++++ 5 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 userfront/lib/features/dashboard/domain/linked_rp_launch.dart create mode 100644 userfront/test/linked_rp_launch_test.dart diff --git a/userfront/lib/features/dashboard/domain/linked_rp_launch.dart b/userfront/lib/features/dashboard/domain/linked_rp_launch.dart new file mode 100644 index 00000000..cb7cb716 --- /dev/null +++ b/userfront/lib/features/dashboard/domain/linked_rp_launch.dart @@ -0,0 +1,21 @@ +import 'providers/linked_rps_provider.dart'; + +String? resolveLinkedRpLaunchUrl(LinkedRp rp) { + final normalizedStatus = rp.status.trim().toLowerCase(); + final isActive = normalizedStatus.isEmpty || normalizedStatus == 'active'; + if (!isActive) { + return null; + } + + final initUrl = rp.initUrl.trim(); + if (initUrl.isNotEmpty) { + return initUrl; + } + + final url = rp.url.trim(); + if (url.isNotEmpty) { + return url; + } + + return null; +} diff --git a/userfront/lib/features/dashboard/domain/models.dart b/userfront/lib/features/dashboard/domain/models.dart index 3f633490..0fa73d4e 100644 --- a/userfront/lib/features/dashboard/domain/models.dart +++ b/userfront/lib/features/dashboard/domain/models.dart @@ -96,6 +96,7 @@ class LinkedRp { final String name; final String logo; final String url; + final String initUrl; final String status; final List scopes; final DateTime? lastAuthenticatedAt; @@ -105,6 +106,7 @@ class LinkedRp { required this.name, required this.logo, required this.url, + required this.initUrl, required this.status, required this.scopes, this.lastAuthenticatedAt, @@ -126,6 +128,7 @@ class LinkedRp { name: json['name']?.toString() ?? '', logo: json['logo']?.toString() ?? '', url: json['url']?.toString() ?? '', + initUrl: json['init_url']?.toString() ?? '', status: json['status']?.toString() ?? '', scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], lastAuthenticatedAt: parsedLastAuth, diff --git a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart index b571351c..2c8ddbd3 100644 --- a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart +++ b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart @@ -10,6 +10,7 @@ class LinkedRp { final String name; final String logo; final String url; + final String initUrl; final String status; final List scopes; final DateTime? lastAuthenticatedAt; @@ -19,6 +20,7 @@ class LinkedRp { required this.name, required this.logo, required this.url, + required this.initUrl, required this.status, required this.scopes, required this.lastAuthenticatedAt, @@ -40,6 +42,7 @@ class LinkedRp { name: json['name']?.toString() ?? '', logo: json['logo']?.toString() ?? '', url: json['url']?.toString() ?? '', + initUrl: json['init_url']?.toString() ?? '', status: json['status']?.toString() ?? 'unknown', scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], lastAuthenticatedAt: parsedLastAuth, diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index d968c49d..d06ef9c2 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import '../domain/linked_rp_launch.dart'; import '../domain/session_time_resolver.dart'; import '../domain/providers/linked_rps_provider.dart'; import '../domain/providers/user_sessions_provider.dart'; @@ -1216,6 +1217,7 @@ class _DashboardScreenState extends ConsumerState { isRevoked: isRevoked, onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), url: rp.url, + launchUrl: resolveLinkedRpLaunchUrl(rp), lastAuthDateTime: rp.lastAuthenticatedAt, ), ); @@ -1460,7 +1462,7 @@ class _DashboardScreenState extends ConsumerState { cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () async { - final itemUrl = item.url; + final itemUrl = item.launchUrl; if (itemUrl != null && itemUrl.isNotEmpty) { final uri = Uri.parse(itemUrl); final canOpen = await canLaunchUrl(uri); @@ -2291,6 +2293,7 @@ class _ActivityItem { final String lastAuthAt; final String status; final String? url; + final String? launchUrl; final List scopes; final bool isRevoked; final VoidCallback? onRevoke; @@ -2303,6 +2306,7 @@ class _ActivityItem { required this.status, required this.scopes, this.url, + this.launchUrl, this.isRevoked = false, this.onRevoke, this.lastAuthDateTime, diff --git a/userfront/test/linked_rp_launch_test.dart b/userfront/test/linked_rp_launch_test.dart new file mode 100644 index 00000000..3e06e01c --- /dev/null +++ b/userfront/test/linked_rp_launch_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart'; +import 'package:userfront/features/dashboard/domain/providers/linked_rps_provider.dart'; + +LinkedRp _linkedRp({ + required String status, + String url = '', + String initUrl = '', +}) { + return LinkedRp( + id: 'client-1', + name: 'Example App', + logo: '', + url: url, + initUrl: initUrl, + status: status, + scopes: const ['openid', 'profile'], + lastAuthenticatedAt: null, + ); +} + +void main() { + test('LinkedRp.fromJson은 init_url을 읽는다', () { + final rp = LinkedRp.fromJson({ + 'id': 'client-1', + 'name': 'Example App', + 'status': 'active', + 'url': 'https://example.com', + 'init_url': 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + }); + + expect( + rp.initUrl, + 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ); + }); + + test('활성 앱은 initUrl을 우선 진입 URL로 사용한다', () { + final launchUrl = resolveLinkedRpLaunchUrl( + _linkedRp( + status: 'active', + url: 'https://example.com', + initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ), + ); + + expect( + launchUrl, + 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ); + }); + + test('활성 앱은 initUrl이 없으면 기존 url로 폴백한다', () { + final launchUrl = resolveLinkedRpLaunchUrl( + _linkedRp(status: 'active', url: 'https://example.com'), + ); + + expect(launchUrl, 'https://example.com'); + }); + + test('비활성 앱은 진입 URL을 만들지 않는다', () { + final launchUrl = resolveLinkedRpLaunchUrl( + _linkedRp( + status: 'inactive', + url: 'https://example.com', + initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ), + ); + + expect(launchUrl, isNull); + }); +} From c7b213bf1775ea9c9e88f1532962fd1f046687c6 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 10:49:08 +0900 Subject: [PATCH 03/14] =?UTF-8?q?devfront=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20auto=20redirect=20SSO=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/AuthCallbackPage.tsx | 11 ++++- devfront/src/features/auth/LoginPage.tsx | 41 ++++++++++++++++--- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/devfront/src/features/auth/AuthCallbackPage.tsx b/devfront/src/features/auth/AuthCallbackPage.tsx index 929bc7b4..1cf9be59 100644 --- a/devfront/src/features/auth/AuthCallbackPage.tsx +++ b/devfront/src/features/auth/AuthCallbackPage.tsx @@ -17,12 +17,19 @@ export default function AuthCallbackPage() { } if (auth.isAuthenticated) { - navigate("/", { replace: true }); + const returnTo = + typeof auth.user?.state === "object" && + auth.user?.state !== null && + "returnTo" in auth.user.state && + typeof auth.user.state.returnTo === "string" + ? auth.user.state.returnTo + : "/clients"; + navigate(returnTo, { replace: true }); } else if (auth.error) { console.error("Auth Error:", auth.error); navigate("/login", { replace: true }); } - }, [auth.isAuthenticated, auth.error, navigate]); + }, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]); return
Loading Auth...
; } diff --git a/devfront/src/features/auth/LoginPage.tsx b/devfront/src/features/auth/LoginPage.tsx index 3c87a65e..cd9f8ca1 100644 --- a/devfront/src/features/auth/LoginPage.tsx +++ b/devfront/src/features/auth/LoginPage.tsx @@ -1,7 +1,8 @@ import { ExternalLink, LogIn, ShieldHalf } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useAuth } from "react-oidc-context"; import { useNavigate } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { Button } from "../../components/ui/button"; import { Card, @@ -14,18 +15,48 @@ import { function LoginPage() { const auth = useAuth(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const autoStartedRef = useRef(false); + const returnTo = searchParams.get("returnTo") || "/clients"; + const shouldAutoLogin = searchParams.get("auto") === "1"; useEffect(() => { if (auth.isAuthenticated) { - navigate("/clients", { replace: true }); + navigate(returnTo, { replace: true }); } - }, [auth.isAuthenticated, navigate]); + }, [auth.isAuthenticated, navigate, returnTo]); + + useEffect(() => { + if (!shouldAutoLogin) { + return; + } + if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) { + return; + } + + autoStartedRef.current = true; + void auth.signinRedirect({ + state: { + returnTo, + }, + }); + }, [ + auth, + auth.activeNavigator, + auth.isLoading, + returnTo, + shouldAutoLogin, + ]); const handleSSOLogin = async () => { try { - await auth.signinPopup(); + await auth.signinRedirect({ + state: { + returnTo: "/clients", + }, + }); } catch (error) { - console.error("Popup login failed", error); + console.error("Redirect login failed", error); } }; From 24f477a28e74ce12c76b4cd43ca48d7350cb1eaf Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 10:49:39 +0900 Subject: [PATCH 04/14] =?UTF-8?q?adminfront=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20auto=20redirect=20SSO?= =?UTF-8?q?=20=EC=A7=84=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/AuthCallbackPage.tsx | 9 +++- adminfront/src/features/auth/LoginPage.tsx | 42 ++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/adminfront/src/features/auth/AuthCallbackPage.tsx b/adminfront/src/features/auth/AuthCallbackPage.tsx index ed8889d8..ed1e0630 100644 --- a/adminfront/src/features/auth/AuthCallbackPage.tsx +++ b/adminfront/src/features/auth/AuthCallbackPage.tsx @@ -14,7 +14,14 @@ function AuthCallbackPage() { if (user?.access_token) { window.localStorage.setItem("admin_session", user.access_token); } - navigate("/", { replace: true }); + const returnTo = + typeof auth.user?.state === "object" && + auth.user?.state !== null && + "returnTo" in auth.user.state && + typeof auth.user.state.returnTo === "string" + ? auth.user.state.returnTo + : "/"; + navigate(returnTo, { replace: true }); } else if (auth.error) { console.error("Auth Error:", auth.error); navigate("/login", { replace: true }); diff --git a/adminfront/src/features/auth/LoginPage.tsx b/adminfront/src/features/auth/LoginPage.tsx index 07da4600..a9edebb5 100644 --- a/adminfront/src/features/auth/LoginPage.tsx +++ b/adminfront/src/features/auth/LoginPage.tsx @@ -1,5 +1,7 @@ import { ExternalLink, LogIn, ShieldHalf } from "lucide-react"; +import { useEffect, useRef } from "react"; import { useAuth } from "react-oidc-context"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { Button } from "../../components/ui/button"; import { Card, @@ -11,10 +13,46 @@ import { function LoginPage() { const auth = useAuth(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const autoStartedRef = useRef(false); + const returnTo = searchParams.get("returnTo") || "/"; + const shouldAutoLogin = searchParams.get("auto") === "1"; + + useEffect(() => { + if (auth.isAuthenticated) { + navigate(returnTo, { replace: true }); + } + }, [auth.isAuthenticated, navigate, returnTo]); + + useEffect(() => { + if (!shouldAutoLogin) { + return; + } + if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) { + return; + } + + autoStartedRef.current = true; + void auth.signinRedirect({ + state: { + returnTo, + }, + }); + }, [ + auth, + auth.activeNavigator, + auth.isLoading, + returnTo, + shouldAutoLogin, + ]); const handleSSOLogin = () => { - // OIDC client-side authentication flow started here - auth.signinRedirect(); + void auth.signinRedirect({ + state: { + returnTo: "/", + }, + }); }; return ( From f4b1c449b157051f4c21e0eaf66be03517907324 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 14:19:35 +0900 Subject: [PATCH 05/14] =?UTF-8?q?App=20=EC=B9=B4=EB=93=9C=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=A1=B0=EC=A0=95=20=EB=B0=8F=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en.toml | 6 +- locales/ko.toml | 6 +- userfront/assets/translations/en.toml | 6 +- userfront/assets/translations/ko.toml | 6 +- .../presentation/dashboard_screen.dart | 301 +++++++++++++----- userfront/lib/i18n_data.dart | 13 +- 6 files changed, 251 insertions(+), 87 deletions(-) diff --git a/locales/en.toml b/locales/en.toml index bc5d4d6a..35d8e8c0 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -551,6 +551,7 @@ client_id = "Client ID: {{id}}" client_id_missing = "No client ID available." current_status = "Current status: {{status}}" last_auth = "Last signed in: {{value}}" +link_status = "Link status: {{status}}" link_missing = "This app does not have a launch URL configured." link_open_error = "Could not open the app link." render_error = "Dashboard render error: {{error}}" @@ -2084,7 +2085,8 @@ title = "Cancel consent" [ui.userfront.dashboard] last_auth_label = "Last sign-in" -status_history = "Activity history" +link_status_label = "Link status" +status_history = "Link details" [ui.userfront.dashboard.activity] linked = "Linked" @@ -2109,7 +2111,7 @@ confirm_button = "Disconnect" title = "Disconnect app" [ui.userfront.dashboard.scopes] -title = "Permission (Scopes)" +title = "Consent scopes" [ui.userfront.dashboard.status] revoked = "Revoked" diff --git a/locales/ko.toml b/locales/ko.toml index 680b34f8..a89cea92 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -192,6 +192,7 @@ client_id = "Client ID: {{id}}" client_id_missing = "Client ID 없음" current_status = "현재 상태: {{status}}" last_auth = "최근 인증: {{value}}" +link_status = "연동 상태: {{status}}" link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." link_open_error = "해당 링크를 열 수 없습니다." render_error = "대시보드 렌더링 오류: {{error}}" @@ -402,7 +403,8 @@ session = "세션" [ui.userfront.dashboard] last_auth_label = "최근 인증" -status_history = "상태 이력" +link_status_label = "연동 상태" +status_history = "연동 정보" [ui.userfront.device] android = "Mobile(Android)" @@ -2505,7 +2507,7 @@ confirm_button = "해지하기" title = "연동 해지" [ui.userfront.dashboard.scopes] -title = "권한 (Scopes)" +title = "동의 범위" [ui.userfront.dashboard.status] revoked = "해지됨" diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index f7eafeb2..acc22ea8 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -86,6 +86,7 @@ client_id = "Client ID: {id}" client_id_missing = "No client ID available." current_status = "Current status: {status}" last_auth = "Last signed in: {value}" +link_status = "Link status: {status}" link_missing = "This app does not have a launch URL configured." link_open_error = "Could not open the app link." render_error = "Dashboard render error: {error}" @@ -464,7 +465,8 @@ title = "Cancel consent" [ui.userfront.dashboard] last_auth_label = "Last sign-in" -status_history = "Activity history" +link_status_label = "Link status" +status_history = "Link details" [ui.userfront.dashboard.activity] linked = "Linked" @@ -489,7 +491,7 @@ confirm_button = "Disconnect" title = "Disconnect app" [ui.userfront.dashboard.scopes] -title = "Permission (Scopes)" +title = "Consent scopes" [ui.userfront.dashboard.status] revoked = "Revoked" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 244414a5..24f7ce3b 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -62,6 +62,7 @@ client_id = "Client ID: {id}" client_id_missing = "Client ID 없음" current_status = "현재 상태: {status}" last_auth = "최근 인증: {value}" +link_status = "연동 상태: {status}" link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." link_open_error = "해당 링크를 열 수 없습니다." render_error = "대시보드 렌더링 오류: {error}" @@ -176,7 +177,8 @@ session = "세션" [ui.userfront.dashboard] last_auth_label = "최근 인증" -status_history = "상태 이력" +link_status_label = "연동 상태" +status_history = "연동 정보" [ui.userfront.device] android = "Mobile(Android)" @@ -694,7 +696,7 @@ confirm_button = "해지하기" title = "연동 해지" [ui.userfront.dashboard.scopes] -title = "권한 (Scopes)" +title = "동의 범위" [ui.userfront.dashboard.status] revoked = "해지됨" diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index d06ef9c2..5d46255c 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -38,6 +38,8 @@ class _DashboardScreenState extends ConsumerState { static const _border = Color(0xFFE5E7EB); static const _subtle = Color(0xFFF7F8FA); static const double _dashboardCardSpacing = 12; + static const double _dashboardCardMaxWidth = 228; + static const double _activityDialogMaxWidth = 360; static const double _historySessionMinWidth = 92; static const double _historyOtherColumnsBaselineWidth = 780; static const int _historySessionMinVisibleChars = 8; @@ -235,85 +237,158 @@ class _DashboardScreenState extends ConsumerState { context: context, builder: (context) => Consumer( builder: (context, ref, _) { + final dialogWidth = math.min( + MediaQuery.sizeOf(context).width - 48, + _activityDialogMaxWidth, + ); + final statusLabel = item.status == 'active' + ? tr('ui.userfront.dashboard.activity.linked') + : tr('ui.userfront.dashboard.status.revoked'); + final statusColor = _activityStatusColor(item.status); + return AlertDialog( - title: Text(item.appName), + backgroundColor: _surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + insetPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 24, + ), + contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 8), content: SizedBox( - width: double.maxFinite, + width: dialogWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - tr('ui.userfront.dashboard.scopes.title'), - style: const TextStyle(fontWeight: FontWeight.bold), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.appName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: _ink, + ), + ), + const SizedBox(height: 4), + Text( + tr('ui.common.details'), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + ], + ), ), - const SizedBox(height: 8), - if (item.scopes.isEmpty) - Text( - tr('msg.userfront.dashboard.scopes.empty'), - style: const TextStyle(color: Colors.grey), - ) - else - Wrap( - spacing: 8, - runSpacing: 4, - children: item.scopes - .map( - (s) => Chip( - label: Text( - s, - style: const TextStyle(fontSize: 12), - ), - visualDensity: VisualDensity.compact, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, + const SizedBox(height: 16), + _buildActivityDetailSection( + title: tr('ui.userfront.dashboard.status_history'), + child: Row( + children: [ + Expanded( + child: _buildActivityDetailField( + label: tr( + 'ui.userfront.dashboard.link_status_label', + ), + value: statusLabel, + valueColor: statusColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildActivityDetailField( + label: tr('ui.userfront.dashboard.last_auth_label'), + value: item.lastAuthAt, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + _buildActivityDetailSection( + title: tr('ui.userfront.dashboard.scopes.title'), + child: item.scopes.isEmpty + ? Text( + tr('msg.userfront.dashboard.scopes.empty'), + style: TextStyle( + fontSize: 13, + color: Colors.grey[600], ), ) - .toList(), - ), - const SizedBox(height: 24), - Text( - tr('ui.userfront.dashboard.status_history'), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tr( - 'msg.userfront.dashboard.last_auth', - params: {'value': item.lastAuthAt}, - ), - ), - const SizedBox(height: 4), - Builder( - builder: (context) { - final statusLabel = item.status == 'active' - ? tr('ui.common.status.active') - : tr('ui.userfront.dashboard.status.revoked'); - return Text( - tr( - 'msg.userfront.dashboard.current_status', - params: {'status': statusLabel}, - ), - style: TextStyle( - color: item.status == 'active' - ? Colors.green - : Colors.grey, - ), - ); - }, - ), - ], + : Wrap( + spacing: 8, + runSpacing: 8, + children: item.scopes + .map( + (scope) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.shield_outlined, + size: 14, + color: _ink, + ), + const SizedBox(width: 6), + Text( + scope, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _ink, + ), + ), + ], + ), + ), + ) + .toList(), + ), ), ], ), ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(tr('ui.common.close')), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + foregroundColor: _ink, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + backgroundColor: _subtle, + ), + child: Text( + tr('ui.common.close'), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), ), ], ); @@ -322,6 +397,73 @@ class _DashboardScreenState extends ConsumerState { ); } + Widget _buildActivityDetailSection({ + required String title, + required Widget child, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: _ink, + ), + ), + const SizedBox(height: 10), + child, + ], + ), + ); + } + + Widget _buildActivityDetailField({ + required String label, + required String value, + Color? valueColor, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 6), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: valueColor ?? _ink, + ), + ), + ], + ), + ); + } + Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) { return SafeArea( child: Column( @@ -1319,7 +1461,7 @@ class _DashboardScreenState extends ConsumerState { Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) { final isActive = item.status == 'active'; - final statusColor = isActive ? Colors.green : Colors.grey; + final statusColor = _activityStatusColor(item.status); final borderColor = isActive ? Colors.green.withValues(alpha: 128) : _border; @@ -1331,10 +1473,10 @@ class _DashboardScreenState extends ConsumerState { // 카드 컨텐츠 final cardContent = Container( width: cardWidth ?? 260, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: _surface, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(12), border: Border.all(color: borderColor, width: borderWidth), boxShadow: isActive ? [ @@ -1355,7 +1497,7 @@ class _DashboardScreenState extends ConsumerState { child: Text( item.appName, style: const TextStyle( - fontSize: 16, + fontSize: 15, fontWeight: FontWeight.w600, color: _ink, ), @@ -1380,7 +1522,7 @@ class _DashboardScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 10), Text( tr('ui.userfront.dashboard.last_auth_label'), style: TextStyle(fontSize: 12, color: Colors.grey[600]), @@ -1389,12 +1531,12 @@ class _DashboardScreenState extends ConsumerState { Text( item.lastAuthAt, style: const TextStyle( - fontSize: 14, + fontSize: 13, fontWeight: FontWeight.w600, color: _ink, ), ), - const SizedBox(height: 16), + const SizedBox(height: 14), Row( children: [ Expanded( @@ -1403,7 +1545,7 @@ class _DashboardScreenState extends ConsumerState { style: OutlinedButton.styleFrom( foregroundColor: _ink, side: const BorderSide(color: _border), - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 7), ), child: Text( tr('ui.common.details'), @@ -1425,7 +1567,7 @@ class _DashboardScreenState extends ConsumerState { color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5, ), - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 7), ), child: _isRevoking && !item.isRevoked ? const SizedBox( @@ -1783,8 +1925,15 @@ class _DashboardScreenState extends ConsumerState { } double _dashboardCardWidth(double maxWidth, int crossAxisCount) { - return (maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) / - crossAxisCount; + return math.min( + (maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) / + crossAxisCount, + _dashboardCardMaxWidth, + ); + } + + Color _activityStatusColor(String status) { + return status == 'active' ? Colors.green : Colors.grey; } Widget _buildCenteredHistoryHeader(String label, {double? width}) { diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index 7974493c..46f24bf9 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -418,6 +418,7 @@ const Map koStrings = { "msg.userfront.audit.device": "접속환경: {{value}}", "msg.userfront.audit.end": "더 이상 항목이 없습니다.", "msg.userfront.audit.filter.description": "활성화된 세션만 보려면 토글을 켜주세요.", + "msg.userfront.audit.filtered_empty": "활성 세션으로 필터링된 접속 이력이 없습니다.", "msg.userfront.audit.ip": "접속 IP: {{value}}", "msg.userfront.audit.load_more_error": "더 불러오지 못했습니다.", "msg.userfront.audit.result": "인증결과: {{value}}", @@ -460,6 +461,7 @@ const Map koStrings = { "msg.userfront.dashboard.last_auth": "최근 인증: {{value}}", "msg.userfront.dashboard.link_missing": "이동할 페이지 주소(Client URI)가 설정되지 않았습니다.", "msg.userfront.dashboard.link_open_error": "해당 링크를 열 수 없습니다.", + "msg.userfront.dashboard.link_status": "연동 상태: {{status}}", "msg.userfront.dashboard.render_error": "대시보드 렌더링 오류: {{error}}", "msg.userfront.dashboard.revoke.confirm": "{{app}} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.", @@ -1717,9 +1719,10 @@ const Map koStrings = { "ui.userfront.dashboard.approved_session.default": "승인한 세션 ID", "ui.userfront.dashboard.approved_session.userfront": "승인한 Userfront 세션 ID", "ui.userfront.dashboard.last_auth_label": "최근 인증", + "ui.userfront.dashboard.link_status_label": "연동 상태", "ui.userfront.dashboard.revoke.confirm_button": "해지하기", "ui.userfront.dashboard.revoke.title": "연동 해지", - "ui.userfront.dashboard.scopes.title": "권한 (Scopes)", + "ui.userfront.dashboard.scopes.title": "동의 범위", "ui.userfront.dashboard.sessions.active_badge": "활성화", "ui.userfront.dashboard.sessions.current_badge": "접속중", "ui.userfront.dashboard.sessions.current_disabled": "현재 세션", @@ -2324,6 +2327,8 @@ const Map enStrings = { "msg.userfront.audit.end": "No more items to show.", "msg.userfront.audit.filter.description": "Toggle to view only active sessions.", + "msg.userfront.audit.filtered_empty": + "No sign-in history matches the active session filter.", "msg.userfront.audit.ip": "IP address: {{value}}", "msg.userfront.audit.load_more_error": "Could not load more history.", "msg.userfront.audit.result": "Result: {{value}}", @@ -2376,6 +2381,7 @@ const Map enStrings = { "msg.userfront.dashboard.link_missing": "This app does not have a launch URL configured.", "msg.userfront.dashboard.link_open_error": "Could not open the app link.", + "msg.userfront.dashboard.link_status": "Link status: {{status}}", "msg.userfront.dashboard.render_error": "Dashboard render error: {{error}}", "msg.userfront.dashboard.revoke.confirm": "Disconnect {{app}}?\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in.", @@ -3728,9 +3734,10 @@ const Map enStrings = { "ui.userfront.dashboard.approved_session.userfront": "Approved UserFront session ID", "ui.userfront.dashboard.last_auth_label": "Last sign-in", + "ui.userfront.dashboard.link_status_label": "Link status", "ui.userfront.dashboard.revoke.confirm_button": "Disconnect", "ui.userfront.dashboard.revoke.title": "Disconnect app", - "ui.userfront.dashboard.scopes.title": "Permission (Scopes)", + "ui.userfront.dashboard.scopes.title": "Consent scopes", "ui.userfront.dashboard.sessions.active_badge": "Active", "ui.userfront.dashboard.sessions.current_badge": "Current", "ui.userfront.dashboard.sessions.current_disabled": "Current session", @@ -3739,7 +3746,7 @@ const Map enStrings = { "ui.userfront.dashboard.sessions.unknown_device": "Unknown device", "ui.userfront.dashboard.sessions.unknown_session": "Session", "ui.userfront.dashboard.status.revoked": "Revoked", - "ui.userfront.dashboard.status_history": "Activity history", + "ui.userfront.dashboard.status_history": "Link details", "ui.userfront.device.android": "Mobile(Android)", "ui.userfront.device.ios": "Mobile(iOS)", "ui.userfront.device.linux": "Desktop(Linux)", From 3d7d4767bff7e413ffd307fbe4e223ccebc66560 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 16:10:46 +0900 Subject: [PATCH 06/14] =?UTF-8?q?=ED=85=8C=EB=A7=88=20=ED=86=A0=EA=B8=80?= =?UTF-8?q?=20=EB=9D=BC=EB=B2=A8=20=EB=B2=88=EC=97=AD=EA=B3=BC=20=EC=98=81?= =?UTF-8?q?=EB=AC=B8=20=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/assets/translations/en.toml | 2 +- userfront/assets/translations/ko.toml | 4 ++-- userfront/lib/i18n_data.dart | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index acc22ea8..9083bdb2 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -488,7 +488,7 @@ userfront = "Approved UserFront session ID" [ui.userfront.dashboard.revoke] confirm_button = "Disconnect" -title = "Disconnect app" +title = "Disconnect" [ui.userfront.dashboard.scopes] title = "Consent scopes" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 24f7ce3b..c39dd77c 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -613,8 +613,8 @@ select_file = "파일 선택" select_placeholder = "선택하세요" show_more = "+ 더보기" success = "성공" -theme_dark = "Dark" -theme_light = "Light" +theme_dark = "다크" +theme_light = "라이트" theme_toggle = "테마 전환" unknown = "Unknown" view = "보기" diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index 46f24bf9..f28934f8 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -1424,8 +1424,8 @@ const Map koStrings = { "ui.common.status.pending": "준비 중", "ui.common.status.success": "성공", "ui.common.success": "성공", - "ui.common.theme_dark": "Dark", - "ui.common.theme_light": "Light", + "ui.common.theme_dark": "다크", + "ui.common.theme_light": "라이트", "ui.common.theme_toggle": "테마 전환", "ui.common.unknown": "Unknown", "ui.common.view": "보기", @@ -3736,7 +3736,7 @@ const Map enStrings = { "ui.userfront.dashboard.last_auth_label": "Last sign-in", "ui.userfront.dashboard.link_status_label": "Link status", "ui.userfront.dashboard.revoke.confirm_button": "Disconnect", - "ui.userfront.dashboard.revoke.title": "Disconnect app", + "ui.userfront.dashboard.revoke.title": "Disconnect", "ui.userfront.dashboard.scopes.title": "Consent scopes", "ui.userfront.dashboard.sessions.active_badge": "Active", "ui.userfront.dashboard.sessions.current_badge": "Current", From dce418d0b9df1d3d095b2cb840e8048a1afc5029 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 16:11:40 +0900 Subject: [PATCH 07/14] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=8B=A4=ED=81=AC=20=EB=AA=A8=EB=93=9C/=ED=85=8C=EB=A7=88=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/error_screen.dart | 39 +- .../auth/presentation/login_screen.dart | 1007 +++++++++-------- .../presentation/dashboard_screen.dart | 75 +- .../presentation/pages/profile_page.dart | 65 +- 4 files changed, 649 insertions(+), 537 deletions(-) diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index b2ebc876..7977c0b6 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/constants/error_whitelist.dart'; import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/widgets/theme_toggle_button.dart'; import 'package:userfront/i18n.dart'; class ErrorScreen extends StatelessWidget { @@ -22,6 +23,7 @@ class ErrorScreen extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final colorScheme = theme.colorScheme; final isProd = isProdOverride ?? AuthProxyService.isProdEnv; final normalizedCode = (errorCode ?? '').trim(); final hasCode = normalizedCode.isNotEmpty; @@ -62,7 +64,7 @@ class ErrorScreen extends StatelessWidget { : tr('msg.userfront.error.detail_request'))); return Scaffold( - backgroundColor: const Color(0xFFF7F8FA), + backgroundColor: colorScheme.surfaceContainerLowest, body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), @@ -71,7 +73,7 @@ class ErrorScreen extends StatelessWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: Color(0xFFE5E7EB)), + side: BorderSide(color: colorScheme.outlineVariant), ), child: Padding( padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), @@ -79,18 +81,25 @@ class ErrorScreen extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - color: const Color(0xFF111827), - ), + Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + ), + ), + const ThemeToggleButton(compact: true), + ], ), const SizedBox(height: 12), Text( detail, style: theme.textTheme.bodyMedium?.copyWith( - color: const Color(0xFF4B5563), + color: colorScheme.onSurfaceVariant, height: 1.5, ), ), @@ -98,7 +107,7 @@ class ErrorScreen extends StatelessWidget { Text( tr('msg.userfront.error.type', params: {'type': errorType}), style: theme.textTheme.bodySmall?.copyWith( - color: const Color(0xFF6B7280), + color: colorScheme.onSurfaceVariant, ), ), if (errorId != null && errorId!.isNotEmpty) ...[ @@ -106,7 +115,7 @@ class ErrorScreen extends StatelessWidget { Text( tr('msg.userfront.error.id', params: {'id': errorId!}), style: theme.textTheme.bodySmall?.copyWith( - color: const Color(0xFF6B7280), + color: colorScheme.onSurfaceVariant, ), ), ], @@ -118,8 +127,8 @@ class ErrorScreen extends StatelessWidget { ElevatedButton( onPressed: () => context.go('/login'), style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF111827), - foregroundColor: Colors.white, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, @@ -134,12 +143,12 @@ class ErrorScreen extends StatelessWidget { onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF111827), + foregroundColor: colorScheme.onSurface, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), - side: const BorderSide(color: Color(0xFFCBD5F5)), + side: BorderSide(color: colorScheme.outline), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 460e56db..8e02a8a8 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:userfront/i18n.dart'; import '../../../core/widgets/language_selector.dart'; +import '../../../core/widgets/theme_toggle_button.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; @@ -1385,6 +1386,10 @@ class _LoginScreenState extends ConsumerState @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final mutedColor = colorScheme.onSurfaceVariant; + if (_verificationOnly && _verificationApproved) { return Scaffold( appBar: AppBar( @@ -1393,12 +1398,14 @@ class _LoginScreenState extends ConsumerState icon: const Icon(Icons.arrow_back), onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), ), + actions: const [ThemeToggleButton(compact: true)], ), body: _buildVerificationResultView(), ); } return Scaffold( + backgroundColor: colorScheme.surfaceContainerLowest, body: LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( @@ -1408,543 +1415,571 @@ class _LoginScreenState extends ConsumerState child: Container( constraints: const BoxConstraints(maxWidth: 400), padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('ui.userfront.app_title'), - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - if (_drySendEnabled) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + child: Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('ui.userfront.app_title'), + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, ), - decoration: BoxDecoration( - color: const Color(0xFFFFF3CD), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFFFC107)), - ), - child: Row( - children: [ - const Icon( - Icons.warning_amber_rounded, - color: Color(0xFF8A6D3B), + if (_drySendEnabled) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, ), - const SizedBox(width: 8), - Expanded( - child: Text( - tr('msg.userfront.login.dry_send'), - style: const TextStyle( - color: Color(0xFF8A6D3B), - fontSize: 12, - ), + decoration: BoxDecoration( + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFFFFC107), ), ), - ], - ), - ), - ], - const SizedBox(height: 40), - - TabBar( - controller: _tabController, - tabs: [ - Tab(text: tr('ui.userfront.login.tabs.password')), - Tab(text: tr('ui.userfront.login.tabs.link')), - Tab(text: tr('ui.userfront.login.tabs.qr')), - ], - ), - const SizedBox(height: 24), - - SizedBox( - height: 350, - child: TabBarView( - controller: _tabController, - children: [ - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Column( + child: Row( children: [ - TextField( - key: const ValueKey( - 'password_login_id_input', - ), - controller: _passwordLoginIdController, - decoration: InputDecoration( - labelText: - _loginIdLabel ?? - tr( - 'ui.userfront.login.field.login_id', - ), - border: const OutlineInputBorder(), - prefixIcon: const Icon( - Icons.person_outline, - ), - ), - onSubmitted: (_) => _handlePasswordLogin(), + const Icon( + Icons.warning_amber_rounded, + color: Color(0xFF8A6D3B), ), - const SizedBox(height: 16), - TextField( - key: const ValueKey( - 'password_login_password_input', - ), - focusNode: _passwordFocusNode, - controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.field.password', - ), - border: const OutlineInputBorder(), - prefixIcon: const Icon( - Icons.lock_outline, - ), - ), - onSubmitted: (_) => _handlePasswordLogin(), - ), - if (_isPasswordCapsLockOn) ...[ - const SizedBox(height: 8), - Row( - children: [ - const Icon( - Icons.keyboard_capslock_rounded, - size: 18, - color: Colors.orange, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _capsLockWarningText(context), - style: const TextStyle( - color: Colors.orange, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ], - const SizedBox(height: 24), - FilledButton( - key: const ValueKey( - 'password_login_submit_button', - ), - onPressed: _handlePasswordLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(50), - ), + const SizedBox(width: 8), + Expanded( child: Text( - tr('ui.userfront.login.action.submit'), + tr('msg.userfront.login.dry_send'), + style: const TextStyle( + color: Color(0xFF8A6D3B), + fontSize: 12, + ), ), ), ], ), ), + ], + const SizedBox(height: 40), - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Column( - children: [ - if (_linkPendingRef == null) ...[ - TextField( - controller: _linkIdController, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.field.login_id', + TabBar( + controller: _tabController, + tabs: [ + Tab(text: tr('ui.userfront.login.tabs.password')), + Tab(text: tr('ui.userfront.login.tabs.link')), + Tab(text: tr('ui.userfront.login.tabs.qr')), + ], + ), + const SizedBox(height: 24), + + SizedBox( + height: 350, + child: TabBarView( + controller: _tabController, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + children: [ + TextField( + key: const ValueKey( + 'password_login_id_input', ), - hintText: '', - border: const OutlineInputBorder(), - prefixIcon: const Icon( - Icons.person_outline, + controller: _passwordLoginIdController, + decoration: InputDecoration( + labelText: + _loginIdLabel ?? + tr( + 'ui.userfront.login.field.login_id', + ), + prefixIcon: const Icon( + Icons.person_outline, + ), ), + onSubmitted: (_) => + _handlePasswordLogin(), ), - onSubmitted: (_) => _handleLinkLogin(), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: _handleLinkLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(50), - ), - child: Text( - tr('ui.userfront.login.link.send'), - ), - ), - const SizedBox(height: 24), - Text( - tr('msg.userfront.login.link.helper'), - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ], - if (_linkPendingRef != null) ...[ - if (_linkExpired) ...[ - Text( - tr('msg.userfront.login.link_timeout'), - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.grey, - fontSize: 12, + const SizedBox(height: 16), + TextField( + key: const ValueKey( + 'password_login_password_input', ), + focusNode: _passwordFocusNode, + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.password', + ), + prefixIcon: const Icon( + Icons.lock_outline, + ), + ), + onSubmitted: (_) => + _handlePasswordLogin(), ), - const SizedBox(height: 12), + if (_isPasswordCapsLockOn) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Icon( + Icons.keyboard_capslock_rounded, + size: 18, + color: Colors.orange, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _capsLockWarningText(context), + style: const TextStyle( + color: Colors.orange, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + const SizedBox(height: 24), FilledButton( - onPressed: () { - setState(_resetLinkLoginState); - }, + key: const ValueKey( + 'password_login_submit_button', + ), + onPressed: _handlePasswordLogin, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight( - 45, + 50, ), ), - child: Text(tr('ui.common.refresh')), - ), - ] else ...[ - Text( - tr( - 'msg.userfront.login.link.short_code_help', + child: Text( + tr( + 'ui.userfront.login.action.submit', + ), ), - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - ), - textAlign: TextAlign.center, ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - flex: 2, - child: TextField( - controller: - _shortCodePrefixController, - textCapitalization: - TextCapitalization.characters, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.short_code.prefix', - ), - border: - const OutlineInputBorder(), - hintText: 'AB', - hintStyle: const TextStyle( - color: Colors.grey, - ), - ), - maxLength: 2, + ], + ), + ), + + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + children: [ + if (_linkPendingRef == null) ...[ + TextField( + controller: _linkIdController, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.login_id', + ), + hintText: '', + prefixIcon: const Icon( + Icons.person_outline, ), ), - const SizedBox(width: 8), - Expanded( - flex: 4, - child: TextField( - controller: - _shortCodeDigitsController, - keyboardType: - TextInputType.number, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.short_code.digits', + onSubmitted: (_) => + _handleLinkLogin(), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _handleLinkLogin, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight( + 50, + ), + ), + child: Text( + tr('ui.userfront.login.link.send'), + ), + ), + const SizedBox(height: 24), + Text( + tr('msg.userfront.login.link.helper'), + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ], + if (_linkPendingRef != null) ...[ + if (_linkExpired) ...[ + Text( + tr( + 'msg.userfront.login.link_timeout', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: () { + setState(_resetLinkLoginState); + }, + style: FilledButton.styleFrom( + minimumSize: + const Size.fromHeight(45), + ), + child: Text( + tr('ui.common.refresh'), + ), + ), + ] else ...[ + Text( + tr( + 'msg.userfront.login.link.short_code_help', + ), + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + flex: 2, + child: TextField( + controller: + _shortCodePrefixController, + textCapitalization: + TextCapitalization + .characters, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.short_code.prefix', + ), + hintText: 'AB', + hintStyle: TextStyle( + color: mutedColor, + ), + ), + maxLength: 2, ), - border: - const OutlineInputBorder(), - hintText: '345678', - hintStyle: const TextStyle( - color: Colors.grey, - ), - suffixText: - _linkExpireSeconds > 0 - ? tr( - 'ui.userfront.login.short_code.expire_time', - params: { - 'time': _formatTime( - _linkExpireSeconds, - ), - }, - ) - : null, ), - maxLength: 6, + const SizedBox(width: 8), + Expanded( + flex: 4, + child: TextField( + controller: + _shortCodeDigitsController, + keyboardType: + TextInputType.number, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.short_code.digits', + ), + hintText: '345678', + hintStyle: TextStyle( + color: mutedColor, + ), + suffixText: + _linkExpireSeconds > 0 + ? tr( + 'ui.userfront.login.short_code.expire_time', + params: { + 'time': _formatTime( + _linkExpireSeconds, + ), + }, + ) + : null, + ), + maxLength: 6, + ), + ), + ], + ), + const SizedBox(height: 12), + FilledButton( + onPressed: () { + final prefix = + _shortCodePrefixController + .text + .trim() + .toUpperCase(); + final digits = + _shortCodeDigitsController + .text + .trim(); + if (prefix.length != 2 || + digits.length != 6) { + _showError( + tr( + 'msg.userfront.login.short_code.invalid', + ), + ); + return; + } + _verifyShortCode(prefix + digits); + }, + style: FilledButton.styleFrom( + minimumSize: + const Size.fromHeight(45), + ), + child: Text( + tr( + 'ui.userfront.login.short_code.submit', + ), + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () { + if (_linkResendSeconds > 0) { + _showInfo( + tr( + 'msg.userfront.login.link.resend_wait', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ), + ); + return; + } + final loginId = + _lastLinkLoginId ?? + _linkIdController.text.trim(); + if (loginId.isEmpty) { + _showError( + tr( + 'msg.userfront.login.link.missing_login_id', + ), + ); + return; + } + _startEnchantedFlow( + loginId, + isEmail: + _lastLinkIsEmail || + loginId.contains('@'), + codeOnly: false, + ); + }, + child: Text( + _linkResendSeconds > 0 + ? tr( + 'ui.userfront.login.link.resend_with_time', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ) + : tr('ui.common.resend'), + ), + ), + if (!_lastLinkIsEmail) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: () { + if (_linkResendSeconds > 0) { + _showInfo( + tr( + 'msg.userfront.login.link.resend_wait', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ), + ); + return; + } + final loginId = + _lastLinkLoginId ?? + _linkIdController.text + .trim(); + if (loginId.isEmpty) { + _showError( + tr( + 'msg.userfront.login.link.missing_phone', + ), + ); + return; + } + _startEnchantedFlow( + loginId, + isEmail: false, + codeOnly: true, + ); + }, + child: Text( + tr( + 'ui.userfront.login.link.code_only', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ), + ), + ), + ], + ], + ], + ], + ), + ), + + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (_isQrLoading) + const CircularProgressIndicator() + else if (_qrExpired) + Column( + children: [ + Text( + tr( + 'msg.userfront.login.qr_expired', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _startQrFlow, + style: FilledButton.styleFrom( + minimumSize: + const Size.fromHeight(45), + ), + child: Text( + tr('ui.common.refresh'), ), ), ], - ), - const SizedBox(height: 12), - FilledButton( - onPressed: () { - final prefix = - _shortCodePrefixController.text - .trim() - .toUpperCase(); - final digits = - _shortCodeDigitsController.text - .trim(); - if (prefix.length != 2 || - digits.length != 6) { - _showError( - tr( - 'msg.userfront.login.short_code.invalid', + ) + else if (_qrImageBase64 != null) + Column( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all( + color: colorScheme.outline, ), - ); - return; - } - _verifyShortCode(prefix + digits); - }, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight( - 45, - ), - ), - child: Text( - tr( - 'ui.userfront.login.short_code.submit', - ), - ), - ), - const SizedBox(height: 12), - TextButton( - onPressed: () { - if (_linkResendSeconds > 0) { - _showInfo( - tr( - 'msg.userfront.login.link.resend_wait', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ), - ); - return; - } - final loginId = - _lastLinkLoginId ?? - _linkIdController.text.trim(); - if (loginId.isEmpty) { - _showError( - tr( - 'msg.userfront.login.link.missing_login_id', - ), - ); - return; - } - _startEnchantedFlow( - loginId, - isEmail: - _lastLinkIsEmail || - loginId.contains('@'), - codeOnly: false, - ); - }, - child: Text( - _linkResendSeconds > 0 - ? tr( - 'ui.userfront.login.link.resend_with_time', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ) - : tr('ui.common.resend'), - ), - ), - if (!_lastLinkIsEmail) ...[ - const SizedBox(height: 4), - TextButton( - onPressed: () { - if (_linkResendSeconds > 0) { - _showInfo( - tr( - 'msg.userfront.login.link.resend_wait', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ), - ); - return; - } - final loginId = - _lastLinkLoginId ?? - _linkIdController.text.trim(); - if (loginId.isEmpty) { - _showError( - tr( - 'msg.userfront.login.link.missing_phone', - ), - ); - return; - } - _startEnchantedFlow( - loginId, - isEmail: false, - codeOnly: true, - ); - }, - child: Text( - tr( - 'ui.userfront.login.link.code_only', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, + borderRadius: + BorderRadius.circular(12), + ), + child: QrImageView( + data: _qrImageBase64!, + version: QrVersions.auto, + size: 200.0, + backgroundColor: Colors.white, ), ), - ), - ], - ], - ], - ], - ), - ), - - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (_isQrLoading) - const CircularProgressIndicator() - else if (_qrExpired) - Column( - children: [ - Text( - tr('msg.userfront.login.qr_expired'), - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: _startQrFlow, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight( - 45, - ), - ), - child: Text(tr('ui.common.refresh')), - ), - ], - ) - else if (_qrImageBase64 != null) - Column( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.shade300, - ), - borderRadius: BorderRadius.circular( - 12, - ), - ), - child: QrImageView( - data: _qrImageBase64!, - version: QrVersions.auto, - size: 200.0, - ), - ), - const SizedBox(height: 12), - Text( - _qrRemainingSeconds > 0 - ? tr( - 'ui.userfront.login.qr.remaining', - params: { - 'time': _formatTime( - _qrRemainingSeconds, + const SizedBox(height: 12), + Text( + _qrRemainingSeconds > 0 + ? tr( + 'ui.userfront.login.qr.remaining', + params: { + 'time': _formatTime( + _qrRemainingSeconds, + ), + }, + ) + : tr( + 'ui.userfront.login.qr.expired', ), - }, - ) - : tr( - 'ui.userfront.login.qr.expired', + textAlign: TextAlign.center, + style: TextStyle( + color: _qrRemainingSeconds > 30 + ? Colors.blue + : Colors.red, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + tr( + 'msg.userfront.login.qr.scan_hint', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + ), + TextButton( + onPressed: _startQrFlow, + child: Text( + tr( + 'ui.userfront.login.qr.refresh', ), - textAlign: TextAlign.center, - style: TextStyle( - color: _qrRemainingSeconds > 30 - ? Colors.blue - : Colors.red, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), + ), + ), + ], + ) + else Text( - tr('msg.userfront.login.qr.scan_hint'), + tr( + 'msg.userfront.login.qr.load_failed', + ), textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - ), ), - TextButton( - onPressed: _startQrFlow, - child: Text( - tr('ui.userfront.login.qr.refresh'), - ), - ), - ], - ) - else - Text( - tr('msg.userfront.login.qr.load_failed'), - textAlign: TextAlign.center, - ), + ], + ), ], ), - ], - ), - ), - const SizedBox(height: 16), - Column( - children: [ - TextButton( - onPressed: () => context.push('/forgot-password'), - child: Text( - tr('ui.userfront.login.forgot_password'), - ), ), - Row( - mainAxisAlignment: MainAxisAlignment.center, + const SizedBox(height: 16), + Column( children: [ - Text( - tr('msg.userfront.login.no_account'), - style: const TextStyle( - color: Colors.grey, - fontSize: 14, + TextButton( + onPressed: () => + context.push('/forgot-password'), + child: Text( + tr('ui.userfront.login.forgot_password'), ), ), - TextButton( - onPressed: () => context.push('/signup'), - child: Text(tr('ui.userfront.login.signup')), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tr('msg.userfront.login.no_account'), + style: TextStyle( + color: mutedColor, + fontSize: 14, + ), + ), + TextButton( + onPressed: () => context.push('/signup'), + child: Text( + tr('ui.userfront.login.signup'), + ), + ), + ], ), ], ), + const SizedBox(height: 6), + const Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: [ThemeToggleButton(), LanguageSelector()], + ), ], ), - const SizedBox(height: 6), - const Align( - alignment: Alignment.center, - child: LanguageSelector(), - ), - ], + ), ), ), ), diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 5d46255c..4dea1f97 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -18,6 +18,7 @@ import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/widgets/language_selector.dart'; +import '../../../../core/widgets/theme_toggle_button.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/toast_service.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; @@ -33,10 +34,6 @@ class DashboardScreen extends ConsumerStatefulWidget { } class _DashboardScreenState extends ConsumerState { - static const _ink = Color(0xFF1A1F2C); - static const _surface = Colors.white; - static const _border = Color(0xFFE5E7EB); - static const _subtle = Color(0xFFF7F8FA); static const double _dashboardCardSpacing = 12; static const double _dashboardCardMaxWidth = 228; static const double _activityDialogMaxWidth = 360; @@ -66,8 +63,14 @@ class _DashboardScreenState extends ConsumerState { bool _showAllActivities = false; bool _showActiveSessionsOnly = false; + bool _isDesktopSideMenuOpen = true; final Set _revokedClientIds = {}; + Color get _ink => Theme.of(context).colorScheme.onSurface; + Color get _surface => Theme.of(context).colorScheme.surface; + Color get _border => Theme.of(context).colorScheme.outlineVariant; + Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest; + String _renderTranslatedText( String key, { String? fallback, @@ -275,7 +278,7 @@ class _DashboardScreenState extends ConsumerState { children: [ Text( item.appName, - style: const TextStyle( + style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: _ink, @@ -346,7 +349,7 @@ class _DashboardScreenState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon( + Icon( Icons.shield_outlined, size: 14, color: _ink, @@ -354,7 +357,7 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(width: 6), Text( scope, - style: const TextStyle( + style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: _ink, @@ -414,7 +417,7 @@ class _DashboardScreenState extends ConsumerState { children: [ Text( title, - style: const TextStyle( + style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: _ink, @@ -519,7 +522,14 @@ class _DashboardScreenState extends ConsumerState { ), const Padding( padding: EdgeInsets.only(bottom: 16), - child: LanguageSelector(compact: true), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ThemeToggleButton(), + SizedBox(height: 8), + LanguageSelector(compact: true), + ], + ), ), ], ), @@ -942,14 +952,35 @@ class _DashboardScreenState extends ConsumerState { return Scaffold( backgroundColor: _subtle, appBar: AppBar( + leading: isWide + ? IconButton( + icon: Icon( + _isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu, + ), + tooltip: _isDesktopSideMenuOpen + ? tr('ui.common.collapse') + : '펼치기', + onPressed: () { + setState(() { + _isDesktopSideMenuOpen = !_isDesktopSideMenuOpen; + }); + }, + ) + : Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + tooltip: MaterialLocalizations.of( + context, + ).openAppDrawerTooltip, + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), title: Text( tr('ui.userfront.app_title'), style: const TextStyle(fontWeight: FontWeight.bold), ), - elevation: 0, - backgroundColor: _surface, - foregroundColor: Colors.black, actions: [ + const ThemeToggleButton(compact: true), IconButton( icon: const Icon(Icons.person_outline), tooltip: tr('ui.userfront.nav.profile'), @@ -972,7 +1003,7 @@ class _DashboardScreenState extends ConsumerState { : Drawer(child: _buildSideMenu(context, closeOnTap: true)), body: Row( children: [ - if (isWide) + if (isWide && _isDesktopSideMenuOpen) SizedBox( width: 240, child: _buildSideMenu(context, closeOnTap: false), @@ -1065,7 +1096,7 @@ class _DashboardScreenState extends ConsumerState { fallback: 'Hello, {{name}}.', values: {'name': userName}, ), - style: const TextStyle( + style: TextStyle( fontSize: 22, fontWeight: FontWeight.bold, color: _ink, @@ -1117,7 +1148,7 @@ class _DashboardScreenState extends ConsumerState { children: [ Text( title, - style: const TextStyle( + style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: _ink, @@ -1271,7 +1302,7 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(width: 6), Text( label, - style: const TextStyle( + style: TextStyle( fontSize: 12, color: _ink, fontWeight: FontWeight.w600, @@ -1496,7 +1527,7 @@ class _DashboardScreenState extends ConsumerState { Expanded( child: Text( item.appName, - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: _ink, @@ -1530,7 +1561,7 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(height: 4), Text( item.lastAuthAt, - style: const TextStyle( + style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: _ink, @@ -1544,7 +1575,7 @@ class _DashboardScreenState extends ConsumerState { onPressed: () => _showRpDetails(item), style: OutlinedButton.styleFrom( foregroundColor: _ink, - side: const BorderSide(color: _border), + side: BorderSide(color: _border), padding: const EdgeInsets.symmetric(vertical: 7), ), child: Text( @@ -1745,7 +1776,7 @@ class _DashboardScreenState extends ConsumerState { children: [ Text( tr('ui.userfront.audit.filter.title'), - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: _ink, @@ -1765,7 +1796,7 @@ class _DashboardScreenState extends ConsumerState { children: [ Text( tr('ui.userfront.audit.filter.toggle_label'), - style: const TextStyle( + style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: _ink, @@ -2224,7 +2255,7 @@ class _DashboardScreenState extends ConsumerState { Expanded( child: _buildAppCell( log, - style: const TextStyle( + style: TextStyle( fontWeight: FontWeight.w600, color: _ink, ), diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 9d25748a..b1b2a04e 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -9,6 +9,7 @@ import '../../../../core/services/logout_service.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/toast_service.dart'; import '../../../../core/widgets/language_selector.dart'; +import '../../../../core/widgets/theme_toggle_button.dart'; import '../../data/models/user_profile_model.dart'; import '../../domain/notifiers/profile_notifier.dart'; @@ -20,10 +21,6 @@ class ProfilePage extends ConsumerStatefulWidget { } class _ProfilePageState extends ConsumerState { - static const _ink = Color(0xFF1A1F2C); - static const _surface = Colors.white; - static const _border = Color(0xFFE5E7EB); - static const _subtle = Color(0xFFF7F8FA); static final _log = Logger('ProfilePage'); UserProfile? _cachedProfile; @@ -54,9 +51,15 @@ class _ProfilePageState extends ConsumerState { bool _showCurrentPassword = false; bool _showNewPassword = false; bool _showConfirmPassword = false; + bool _isDesktopSideMenuOpen = true; Map? _passwordPolicy; bool _isPasswordPolicyLoading = false; + Color get _ink => Theme.of(context).colorScheme.onSurface; + Color get _surface => Theme.of(context).colorScheme.surface; + Color get _border => Theme.of(context).colorScheme.outlineVariant; + Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest; + String _renderTranslatedText( String key, { String? fallback, @@ -615,7 +618,14 @@ class _ProfilePageState extends ConsumerState { ), const Padding( padding: EdgeInsets.only(bottom: 16), - child: LanguageSelector(compact: true), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ThemeToggleButton(), + SizedBox(height: 8), + LanguageSelector(compact: true), + ], + ), ), ], ); @@ -627,7 +637,7 @@ class _ProfilePageState extends ConsumerState { children: [ Text( title, - style: const TextStyle( + style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: _ink, @@ -654,7 +664,7 @@ class _ProfilePageState extends ConsumerState { const SizedBox(width: 6), Text( label, - style: const TextStyle( + style: TextStyle( fontSize: 12, color: _ink, fontWeight: FontWeight.w600, @@ -705,7 +715,7 @@ class _ProfilePageState extends ConsumerState { fallback: 'Hello, {{name}}.', values: {'name': name}, ), - style: const TextStyle( + style: TextStyle( fontSize: 20, fontWeight: FontWeight.w700, color: _ink, @@ -996,12 +1006,17 @@ class _ProfilePageState extends ConsumerState { const SizedBox(height: 8), Text( tr('msg.userfront.profile.password.subtitle'), - style: const TextStyle(color: Color(0xFF6B7280)), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 8), Text( _buildPasswordPolicyDescription(), - style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), ), const SizedBox(height: 16), TextField( @@ -1231,14 +1246,35 @@ class _ProfilePageState extends ConsumerState { return Scaffold( backgroundColor: _subtle, appBar: AppBar( + leading: isWide + ? IconButton( + icon: Icon( + _isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu, + ), + tooltip: _isDesktopSideMenuOpen + ? tr('ui.common.collapse') + : '펼치기', + onPressed: () { + setState(() { + _isDesktopSideMenuOpen = !_isDesktopSideMenuOpen; + }); + }, + ) + : Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + tooltip: MaterialLocalizations.of( + context, + ).openAppDrawerTooltip, + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), title: Text( tr('ui.userfront.app_title'), style: const TextStyle(fontWeight: FontWeight.bold), ), - elevation: 0, - backgroundColor: _surface, - foregroundColor: Colors.black, actions: [ + const ThemeToggleButton(compact: true), IconButton( icon: const Icon(Icons.home_outlined), tooltip: tr('ui.userfront.nav.dashboard'), @@ -1259,7 +1295,8 @@ class _ProfilePageState extends ConsumerState { drawer: isWide ? null : Drawer(child: _buildSideMenu(context)), body: Row( children: [ - if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)), + if (isWide && _isDesktopSideMenuOpen) + SizedBox(width: 240, child: _buildSideMenu(context)), Expanded(child: _buildContent(profile, isUpdating)), ], ), From 873d56e35f354f8275f45ad6f37915f754027cc8 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 16:12:31 +0900 Subject: [PATCH 08/14] =?UTF-8?q?=ED=85=8C=EB=A7=88=20=EC=98=81=EC=86=8D?= =?UTF-8?q?=ED=99=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/test/theme_controller_test.dart | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 userfront/test/theme_controller_test.dart diff --git a/userfront/test/theme_controller_test.dart b/userfront/test/theme_controller_test.dart new file mode 100644 index 00000000..1c829b93 --- /dev/null +++ b/userfront/test/theme_controller_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:userfront/core/theme/theme_controller.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await ThemeController.instance.setThemeMode(ThemeMode.light); + }); + + test('저장된 dark 값을 복원한다', () async { + SharedPreferences.setMockInitialValues({ + ThemeController.storageKey: 'dark', + }); + + await ThemeController.instance.restore(); + + expect(ThemeController.instance.value, ThemeMode.dark); + }); + + test('toggle 결과를 저장한다', () async { + await ThemeController.instance.restore(); + await ThemeController.instance.toggle(); + + final prefs = await SharedPreferences.getInstance(); + expect(ThemeController.instance.value, ThemeMode.dark); + expect(prefs.getString(ThemeController.storageKey), 'dark'); + }); +} From 332b657add5bdfe9db5e69a99f215feb2a2656d2 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 16:14:50 +0900 Subject: [PATCH 09/14] =?UTF-8?q?=EB=8B=A4=ED=81=AC=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20=EC=A0=84=EC=97=AD=20=EC=83=81=ED=83=9C=EC=99=80=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=EA=B8=B0=EB=B0=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/lib/core/theme/app_theme.dart | 148 ++++++++++++++++++ .../lib/core/theme/theme_controller.dart | 32 ++++ .../lib/core/widgets/theme_toggle_button.dart | 44 ++++++ userfront/lib/main.dart | 62 +++----- userfront/pubspec.lock | 2 +- userfront/pubspec.yaml | 1 + 6 files changed, 246 insertions(+), 43 deletions(-) create mode 100644 userfront/lib/core/theme/app_theme.dart create mode 100644 userfront/lib/core/theme/theme_controller.dart create mode 100644 userfront/lib/core/widgets/theme_toggle_button.dart diff --git a/userfront/lib/core/theme/app_theme.dart b/userfront/lib/core/theme/app_theme.dart new file mode 100644 index 00000000..328a6b22 --- /dev/null +++ b/userfront/lib/core/theme/app_theme.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; + +ThemeData buildLightTheme() { + final scheme = + ColorScheme.fromSeed( + seedColor: const Color(0xFF1A1F2C), + brightness: Brightness.light, + ).copyWith( + surface: Colors.white, + surfaceContainerLowest: const Color(0xFFF7F8FA), + surfaceContainerLow: const Color(0xFFF3F4F6), + surfaceContainerHighest: const Color(0xFFE5E7EB), + outline: const Color(0xFFD1D5DB), + outlineVariant: const Color(0xFFE5E7EB), + primary: const Color(0xFF1A1F2C), + onPrimary: Colors.white, + onSurface: const Color(0xFF111827), + onSurfaceVariant: const Color(0xFF6B7280), + ); + return _buildTheme(scheme); +} + +ThemeData buildDarkTheme() { + final scheme = + ColorScheme.fromSeed( + seedColor: const Color(0xFF7DD3FC), + brightness: Brightness.dark, + ).copyWith( + surface: const Color(0xFF0F172A), + surfaceContainerLowest: const Color(0xFF020617), + surfaceContainerLow: const Color(0xFF111827), + surfaceContainerHighest: const Color(0xFF1F2937), + outline: const Color(0xFF334155), + outlineVariant: const Color(0xFF1E293B), + primary: const Color(0xFFBAE6FD), + onPrimary: const Color(0xFF082F49), + onSurface: const Color(0xFFF8FAFC), + onSurfaceVariant: const Color(0xFF94A3B8), + ); + return _buildTheme(scheme); +} + +ThemeData _buildTheme(ColorScheme colorScheme) { + final isDark = colorScheme.brightness == Brightness.dark; + final base = ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + fontFamily: 'NotoSansKR', + ); + + return base.copyWith( + scaffoldBackgroundColor: colorScheme.surfaceContainerLowest, + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: NoTransitionsBuilder(), + TargetPlatform.iOS: NoTransitionsBuilder(), + TargetPlatform.linux: NoTransitionsBuilder(), + TargetPlatform.macOS: NoTransitionsBuilder(), + TargetPlatform.windows: NoTransitionsBuilder(), + TargetPlatform.fuchsia: NoTransitionsBuilder(), + }, + ), + appBarTheme: AppBarTheme( + elevation: 0, + centerTitle: false, + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + surfaceTintColor: Colors.transparent, + ), + cardTheme: CardThemeData( + color: colorScheme.surface, + elevation: 0, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colorScheme.outlineVariant), + ), + ), + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant, + thickness: 1, + ), + drawerTheme: DrawerThemeData( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + ), + dialogTheme: DialogThemeData( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: isDark ? colorScheme.surfaceContainerLow : colorScheme.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.primary, width: 1.4), + ), + labelStyle: TextStyle(color: colorScheme.onSurfaceVariant), + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + prefixIconColor: colorScheme.onSurfaceVariant, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface, + side: BorderSide(color: colorScheme.outline), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + tabBarTheme: TabBarThemeData( + dividerColor: colorScheme.outlineVariant, + labelColor: colorScheme.onSurface, + unselectedLabelColor: colorScheme.onSurfaceVariant, + indicatorColor: colorScheme.primary, + ), + ); +} + +class NoTransitionsBuilder extends PageTransitionsBuilder { + const NoTransitionsBuilder(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return child; + } +} diff --git a/userfront/lib/core/theme/theme_controller.dart b/userfront/lib/core/theme/theme_controller.dart new file mode 100644 index 00000000..02034217 --- /dev/null +++ b/userfront/lib/core/theme/theme_controller.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ThemeController extends ValueNotifier { + ThemeController._() : super(ThemeMode.light); + + static const storageKey = 'userfront_theme'; + static final ThemeController instance = ThemeController._(); + + bool get isDark => value == ThemeMode.dark; + + Future restore() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(storageKey); + value = stored == 'dark' ? ThemeMode.dark : ThemeMode.light; + } + + Future setThemeMode(ThemeMode mode) async { + if (value != mode) { + value = mode; + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + storageKey, + mode == ThemeMode.dark ? 'dark' : 'light', + ); + } + + Future toggle() { + return setThemeMode(isDark ? ThemeMode.light : ThemeMode.dark); + } +} diff --git a/userfront/lib/core/widgets/theme_toggle_button.dart b/userfront/lib/core/widgets/theme_toggle_button.dart new file mode 100644 index 00000000..e0b11d67 --- /dev/null +++ b/userfront/lib/core/widgets/theme_toggle_button.dart @@ -0,0 +1,44 @@ +import 'package:easy_localization/easy_localization.dart' hide tr; +import 'package:flutter/material.dart'; +import 'package:userfront/i18n.dart'; + +import '../theme/theme_controller.dart'; + +class ThemeToggleButton extends StatelessWidget { + const ThemeToggleButton({super.key, this.compact = false}); + + final bool compact; + + @override + Widget build(BuildContext context) { + context.locale; + + return ValueListenableBuilder( + valueListenable: ThemeController.instance, + builder: (context, mode, _) { + final isLight = mode == ThemeMode.light; + final icon = isLight + ? Icons.light_mode_outlined + : Icons.dark_mode_outlined; + final label = isLight + ? tr('ui.common.theme_light', fallback: 'Light') + : tr('ui.common.theme_dark', fallback: 'Dark'); + final tooltip = tr('ui.common.theme_toggle', fallback: '테마 전환'); + + if (compact) { + return IconButton( + tooltip: tooltip, + onPressed: () => ThemeController.instance.toggle(), + icon: Icon(icon), + ); + } + + return OutlinedButton.icon( + onPressed: () => ThemeController.instance.toggle(), + icon: Icon(icon, size: 18), + label: Text(label), + ); + }, + ); + } +} diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 8fbabee1..deb1a9e0 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -24,6 +24,8 @@ import 'core/services/logger_service.dart'; import 'core/services/null_check_recovery.dart'; import 'core/services/web_window.dart'; import 'core/notifiers/auth_notifier.dart'; +import 'core/theme/app_theme.dart'; +import 'core/theme/theme_controller.dart'; import 'core/i18n/locale_gate.dart'; import 'core/i18n/locale_registry.dart'; import 'core/i18n/locale_utils.dart'; @@ -106,6 +108,7 @@ void main() async { // 0. Initialize Logger LoggerService.init(); + await ThemeController.instance.restore(); // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 await _loadBundledFonts(); @@ -366,50 +369,25 @@ class BaronSSOApp extends StatelessWidget { final locale = localization?.currentLocale ?? Locale(resolvePreferredLocaleCode()); - return MaterialApp.router( - title: tr('ui.userfront.app_title'), - localizationsDelegates: delegates, - supportedLocales: supportedLocales, - locale: locale, - builder: (context, child) { - return Stack( - children: [if (child != null) child, const ToastViewport()], + return ValueListenableBuilder( + valueListenable: ThemeController.instance, + builder: (context, themeMode, _) { + return MaterialApp.router( + title: tr('ui.userfront.app_title'), + localizationsDelegates: delegates, + supportedLocales: supportedLocales, + locale: locale, + builder: (context, child) { + return Stack( + children: [if (child != null) child, const ToastViewport()], + ); + }, + theme: buildLightTheme(), + darkTheme: buildDarkTheme(), + themeMode: themeMode, + routerConfig: _router, ); }, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base - brightness: Brightness.light, - ), - useMaterial3: true, - fontFamily: 'NotoSansKR', - pageTransitionsTheme: const PageTransitionsTheme( - builders: { - TargetPlatform.android: NoTransitionsBuilder(), - TargetPlatform.iOS: NoTransitionsBuilder(), - TargetPlatform.linux: NoTransitionsBuilder(), - TargetPlatform.macOS: NoTransitionsBuilder(), - TargetPlatform.windows: NoTransitionsBuilder(), - TargetPlatform.fuchsia: NoTransitionsBuilder(), - }, - ), - ), - routerConfig: _router, ); } } - -class NoTransitionsBuilder extends PageTransitionsBuilder { - const NoTransitionsBuilder(); - - @override - Widget buildTransitions( - PageRoute route, - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) { - return child; - } -} diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index fecd33f1..da86790b 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -485,7 +485,7 @@ packages: source: hosted version: "3.2.0" shared_preferences: - dependency: transitive + dependency: "direct main" description: name: shared_preferences sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" diff --git a/userfront/pubspec.yaml b/userfront/pubspec.yaml index 270c2fb4..71552d8c 100644 --- a/userfront/pubspec.yaml +++ b/userfront/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: easy_localization: ^3.0.7 toml: ^0.15.0 web: ^1.1.0 + shared_preferences: ^2.5.4 dev_dependencies: flutter_test: From 1e53b66abb52227abc649e1d9090ec694f50ca44 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 8 Apr 2026 17:45:51 +0900 Subject: [PATCH 10/14] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=94=8C=EB=9E=AB=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/core/theme/theme_controller.dart | 11 +- userfront/lib/core/theme/theme_scope.dart | 44 + .../lib/core/widgets/theme_toggle_button.dart | 12 +- .../auth/presentation/login_screen.dart | 1036 +++++++++-------- userfront/lib/main.dart | 164 ++- userfront/test/theme_controller_test.dart | 16 +- 6 files changed, 730 insertions(+), 553 deletions(-) create mode 100644 userfront/lib/core/theme/theme_scope.dart diff --git a/userfront/lib/core/theme/theme_controller.dart b/userfront/lib/core/theme/theme_controller.dart index 02034217..5e00b4b9 100644 --- a/userfront/lib/core/theme/theme_controller.dart +++ b/userfront/lib/core/theme/theme_controller.dart @@ -2,10 +2,15 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; class ThemeController extends ValueNotifier { - ThemeController._() : super(ThemeMode.light); + ThemeController._(this.storageKey) : super(ThemeMode.light); - static const storageKey = 'userfront_theme'; - static final ThemeController instance = ThemeController._(); + static const appStorageKey = 'userfront_theme'; + static const authStorageKey = 'userfront_auth_theme'; + static final ThemeController app = ThemeController._(appStorageKey); + static final ThemeController auth = ThemeController._(authStorageKey); + static final ThemeController instance = app; + + final String storageKey; bool get isDark => value == ThemeMode.dark; diff --git a/userfront/lib/core/theme/theme_scope.dart b/userfront/lib/core/theme/theme_scope.dart new file mode 100644 index 00000000..2f912d5f --- /dev/null +++ b/userfront/lib/core/theme/theme_scope.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'app_theme.dart'; +import 'theme_controller.dart'; + +class ThemeScope extends InheritedWidget { + const ThemeScope({super.key, required this.controller, required Widget child}) + : super(child: child); + + final ThemeController controller; + + static ThemeController of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + return scope?.controller ?? ThemeController.app; + } + + @override + bool updateShouldNotify(ThemeScope oldWidget) { + return oldWidget.controller != controller; + } +} + +class ScopedTheme extends StatelessWidget { + const ScopedTheme({super.key, required this.controller, required this.child}); + + final ThemeController controller; + final Widget child; + + @override + Widget build(BuildContext context) { + return ThemeScope( + controller: controller, + child: ValueListenableBuilder( + valueListenable: controller, + builder: (context, mode, _) { + return Theme( + data: mode == ThemeMode.dark ? buildDarkTheme() : buildLightTheme(), + child: child, + ); + }, + ), + ); + } +} diff --git a/userfront/lib/core/widgets/theme_toggle_button.dart b/userfront/lib/core/widgets/theme_toggle_button.dart index e0b11d67..05737ad8 100644 --- a/userfront/lib/core/widgets/theme_toggle_button.dart +++ b/userfront/lib/core/widgets/theme_toggle_button.dart @@ -1,8 +1,7 @@ -import 'package:easy_localization/easy_localization.dart' hide tr; import 'package:flutter/material.dart'; import 'package:userfront/i18n.dart'; -import '../theme/theme_controller.dart'; +import '../theme/theme_scope.dart'; class ThemeToggleButton extends StatelessWidget { const ThemeToggleButton({super.key, this.compact = false}); @@ -11,10 +10,11 @@ class ThemeToggleButton extends StatelessWidget { @override Widget build(BuildContext context) { - context.locale; + Localizations.localeOf(context); + final controller = ThemeScope.of(context); return ValueListenableBuilder( - valueListenable: ThemeController.instance, + valueListenable: controller, builder: (context, mode, _) { final isLight = mode == ThemeMode.light; final icon = isLight @@ -28,13 +28,13 @@ class ThemeToggleButton extends StatelessWidget { if (compact) { return IconButton( tooltip: tooltip, - onPressed: () => ThemeController.instance.toggle(), + onPressed: () => controller.toggle(), icon: Icon(icon), ); } return OutlinedButton.icon( - onPressed: () => ThemeController.instance.toggle(), + onPressed: () => controller.toggle(), icon: Icon(icon, size: 18), label: Text(label), ); diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 8e02a8a8..bcb02973 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -1389,6 +1389,73 @@ class _LoginScreenState extends ConsumerState final theme = Theme.of(context); final colorScheme = theme.colorScheme; final mutedColor = colorScheme.onSurfaceVariant; + final inputForegroundColor = colorScheme.brightness == Brightness.dark + ? const Color(0xFFE2E8F0) + : const Color(0xFF334155); + final primaryColor = colorScheme.brightness == Brightness.dark + ? const Color(0xFF93C5FD) + : const Color(0xFF1E3A8A); + final onPrimaryColor = colorScheme.brightness == Brightness.dark + ? const Color(0xFF0F172A) + : Colors.white; + final inputDecorationTheme = theme.inputDecorationTheme.copyWith( + filled: false, + fillColor: Colors.transparent, + contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18), + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: primaryColor, width: 1.6), + ), + labelStyle: TextStyle(color: inputForegroundColor), + floatingLabelStyle: TextStyle(color: primaryColor), + hintStyle: TextStyle(color: inputForegroundColor), + prefixIconColor: inputForegroundColor, + ); + final localTheme = theme.copyWith( + inputDecorationTheme: inputDecorationTheme, + tabBarTheme: theme.tabBarTheme.copyWith( + dividerColor: colorScheme.outlineVariant, + indicatorColor: primaryColor, + labelColor: colorScheme.onSurface, + unselectedLabelColor: mutedColor, + labelStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + unselectedLabelStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + backgroundColor: primaryColor, + foregroundColor: onPrimaryColor, + textStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primaryColor, + textStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ); if (_verificationOnly && _verificationApproved) { return Scaffold( @@ -1408,379 +1475,345 @@ class _LoginScreenState extends ConsumerState backgroundColor: colorScheme.surfaceContainerLowest, body: LayoutBuilder( builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 400), - padding: const EdgeInsets.all(24), - child: Card( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('ui.userfront.app_title'), - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, + return Theme( + data: localTheme, + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 480), + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 40, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('ui.userfront.app_title'), + style: theme.textTheme.headlineMedium?.copyWith( + fontSize: 34, + fontWeight: FontWeight.w800, + letterSpacing: -0.7, ), - if (_drySendEnabled) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + textAlign: TextAlign.center, + ), + if (_drySendEnabled) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFFFC107), ), - decoration: BoxDecoration( - color: const Color(0xFFFFF3CD), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: const Color(0xFFFFC107), + ), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Color(0xFF8A6D3B), ), - ), - child: Row( - children: [ - const Icon( - Icons.warning_amber_rounded, - color: Color(0xFF8A6D3B), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - tr('msg.userfront.login.dry_send'), - style: const TextStyle( - color: Color(0xFF8A6D3B), - fontSize: 12, - ), + const SizedBox(width: 10), + Expanded( + child: Text( + tr('msg.userfront.login.dry_send'), + style: const TextStyle( + color: Color(0xFF8A6D3B), + fontSize: 12, ), ), - ], - ), + ), + ], ), - ], - const SizedBox(height: 40), - - TabBar( + ), + ], + const SizedBox(height: 52), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 34), + child: TabBar( controller: _tabController, + indicatorSize: TabBarIndicatorSize.label, tabs: [ Tab(text: tr('ui.userfront.login.tabs.password')), Tab(text: tr('ui.userfront.login.tabs.link')), Tab(text: tr('ui.userfront.login.tabs.qr')), ], ), - const SizedBox(height: 24), - - SizedBox( - height: 350, - child: TabBarView( - controller: _tabController, - children: [ - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Column( - children: [ - TextField( - key: const ValueKey( - 'password_login_id_input', - ), - controller: _passwordLoginIdController, - decoration: InputDecoration( - labelText: - _loginIdLabel ?? - tr( - 'ui.userfront.login.field.login_id', - ), - prefixIcon: const Icon( - Icons.person_outline, - ), - ), - onSubmitted: (_) => - _handlePasswordLogin(), - ), - const SizedBox(height: 16), - TextField( - key: const ValueKey( - 'password_login_password_input', - ), - focusNode: _passwordFocusNode, - controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.field.password', - ), - prefixIcon: const Icon( - Icons.lock_outline, - ), - ), - onSubmitted: (_) => - _handlePasswordLogin(), - ), - if (_isPasswordCapsLockOn) ...[ - const SizedBox(height: 8), - Row( - children: [ - const Icon( - Icons.keyboard_capslock_rounded, - size: 18, - color: Colors.orange, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _capsLockWarningText(context), - style: const TextStyle( - color: Colors.orange, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ], - const SizedBox(height: 24), - FilledButton( - key: const ValueKey( - 'password_login_submit_button', - ), - onPressed: _handlePasswordLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight( - 50, - ), - ), - child: Text( - tr( - 'ui.userfront.login.action.submit', - ), - ), - ), - ], - ), - ), - - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Column( - children: [ - if (_linkPendingRef == null) ...[ + ), + const SizedBox(height: 28), + SizedBox( + height: 360, + child: TabBarView( + controller: _tabController, + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 356, + ), + child: Column( + children: [ TextField( - controller: _linkIdController, + key: const ValueKey( + 'password_login_id_input', + ), + controller: + _passwordLoginIdController, decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.field.login_id', - ), - hintText: '', + labelText: + _loginIdLabel ?? + tr( + 'ui.userfront.login.field.login_id', + ), prefixIcon: const Icon( Icons.person_outline, + size: 22, ), ), onSubmitted: (_) => - _handleLinkLogin(), + _handlePasswordLogin(), ), - const SizedBox(height: 24), - FilledButton( - onPressed: _handleLinkLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight( - 50, + const SizedBox(height: 18), + TextField( + key: const ValueKey( + 'password_login_password_input', + ), + focusNode: _passwordFocusNode, + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.password', + ), + prefixIcon: const Icon( + Icons.lock_outline, + size: 22, ), ), - child: Text( - tr('ui.userfront.login.link.send'), - ), + onSubmitted: (_) => + _handlePasswordLogin(), ), - const SizedBox(height: 24), - Text( - tr('msg.userfront.login.link.helper'), - style: TextStyle( - color: mutedColor, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ], - if (_linkPendingRef != null) ...[ - if (_linkExpired) ...[ - Text( - tr( - 'msg.userfront.login.link_timeout', - ), - textAlign: TextAlign.center, - style: TextStyle( - color: mutedColor, - fontSize: 12, - ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: () { - setState(_resetLinkLoginState); - }, - style: FilledButton.styleFrom( - minimumSize: - const Size.fromHeight(45), - ), - child: Text( - tr('ui.common.refresh'), - ), - ), - ] else ...[ - Text( - tr( - 'msg.userfront.login.link.short_code_help', - ), - style: TextStyle( - color: mutedColor, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), + if (_isPasswordCapsLockOn) ...[ + const SizedBox(height: 10), Row( children: [ - Expanded( - flex: 2, - child: TextField( - controller: - _shortCodePrefixController, - textCapitalization: - TextCapitalization - .characters, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.short_code.prefix', - ), - hintText: 'AB', - hintStyle: TextStyle( - color: mutedColor, - ), - ), - maxLength: 2, - ), + const Icon( + Icons.keyboard_capslock_rounded, + size: 18, + color: Colors.orange, ), const SizedBox(width: 8), Expanded( - flex: 4, - child: TextField( - controller: - _shortCodeDigitsController, - keyboardType: - TextInputType.number, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.short_code.digits', - ), - hintText: '345678', - hintStyle: TextStyle( - color: mutedColor, - ), - suffixText: - _linkExpireSeconds > 0 - ? tr( - 'ui.userfront.login.short_code.expire_time', - params: { - 'time': _formatTime( - _linkExpireSeconds, - ), - }, - ) - : null, + child: Text( + _capsLockWarningText(context), + style: const TextStyle( + color: Colors.orange, + fontSize: 12, + fontWeight: FontWeight.w600, ), - maxLength: 6, ), ), ], ), - const SizedBox(height: 12), - FilledButton( - onPressed: () { - final prefix = - _shortCodePrefixController - .text - .trim() - .toUpperCase(); - final digits = - _shortCodeDigitsController - .text - .trim(); - if (prefix.length != 2 || - digits.length != 6) { - _showError( - tr( - 'msg.userfront.login.short_code.invalid', - ), - ); - return; - } - _verifyShortCode(prefix + digits); - }, - style: FilledButton.styleFrom( - minimumSize: - const Size.fromHeight(45), + ], + const SizedBox(height: 28), + FilledButton( + key: const ValueKey( + 'password_login_submit_button', + ), + onPressed: _handlePasswordLogin, + child: Text( + tr( + 'ui.userfront.login.action.submit', ), + ), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 356, + ), + child: Column( + children: [ + if (_linkPendingRef == null) ...[ + TextField( + controller: _linkIdController, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.login_id', + ), + hintText: '', + prefixIcon: const Icon( + Icons.person_outline, + size: 22, + ), + ), + onSubmitted: (_) => + _handleLinkLogin(), + ), + const SizedBox(height: 28), + FilledButton( + onPressed: _handleLinkLogin, child: Text( tr( - 'ui.userfront.login.short_code.submit', + 'ui.userfront.login.link.send', ), ), ), - const SizedBox(height: 12), - TextButton( - onPressed: () { - if (_linkResendSeconds > 0) { - _showInfo( - tr( - 'msg.userfront.login.link.resend_wait', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ), - ); - return; - } - final loginId = - _lastLinkLoginId ?? - _linkIdController.text.trim(); - if (loginId.isEmpty) { - _showError( - tr( - 'msg.userfront.login.link.missing_login_id', - ), - ); - return; - } - _startEnchantedFlow( - loginId, - isEmail: - _lastLinkIsEmail || - loginId.contains('@'), - codeOnly: false, - ); - }, - child: Text( - _linkResendSeconds > 0 - ? tr( - 'ui.userfront.login.link.resend_with_time', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ) - : tr('ui.common.resend'), + const SizedBox(height: 24), + Text( + tr( + 'msg.userfront.login.link.helper', ), + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.5, + ), + textAlign: TextAlign.center, ), - if (!_lastLinkIsEmail) ...[ - const SizedBox(height: 4), + ], + if (_linkPendingRef != null) ...[ + if (_linkExpired) ...[ + Text( + tr( + 'msg.userfront.login.link_timeout', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () { + setState(_resetLinkLoginState); + }, + child: Text( + tr('ui.common.refresh'), + ), + ), + ] else ...[ + Text( + tr( + 'msg.userfront.login.link.short_code_help', + ), + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + flex: 2, + child: TextField( + controller: + _shortCodePrefixController, + textCapitalization: + TextCapitalization + .characters, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.short_code.prefix', + ), + hintText: 'AB', + hintStyle: TextStyle( + color: mutedColor, + ), + counterText: '', + ), + maxLength: 2, + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 4, + child: TextField( + controller: + _shortCodeDigitsController, + keyboardType: + TextInputType.number, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.short_code.digits', + ), + hintText: '345678', + hintStyle: TextStyle( + color: mutedColor, + ), + counterText: '', + suffixText: + _linkExpireSeconds > 0 + ? tr( + 'ui.userfront.login.short_code.expire_time', + params: { + 'time': _formatTime( + _linkExpireSeconds, + ), + }, + ) + : null, + ), + maxLength: 6, + ), + ), + ], + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () { + final prefix = + _shortCodePrefixController + .text + .trim() + .toUpperCase(); + final digits = + _shortCodeDigitsController + .text + .trim(); + if (prefix.length != 2 || + digits.length != 6) { + _showError( + tr( + 'msg.userfront.login.short_code.invalid', + ), + ); + return; + } + _verifyShortCode( + prefix + digits, + ); + }, + child: Text( + tr( + 'ui.userfront.login.short_code.submit', + ), + ), + ), + const SizedBox(height: 14), TextButton( onPressed: () { if (_linkResendSeconds > 0) { @@ -1803,182 +1836,219 @@ class _LoginScreenState extends ConsumerState if (loginId.isEmpty) { _showError( tr( - 'msg.userfront.login.link.missing_phone', + 'msg.userfront.login.link.missing_login_id', ), ); return; } _startEnchantedFlow( loginId, - isEmail: false, - codeOnly: true, + isEmail: + _lastLinkIsEmail || + loginId.contains('@'), + codeOnly: false, ); }, child: Text( - tr( - 'ui.userfront.login.link.code_only', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ), + _linkResendSeconds > 0 + ? tr( + 'ui.userfront.login.link.resend_with_time', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ) + : tr('ui.common.resend'), ), ), + if (!_lastLinkIsEmail) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: () { + if (_linkResendSeconds > 0) { + _showInfo( + tr( + 'msg.userfront.login.link.resend_wait', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ), + ); + return; + } + final loginId = + _lastLinkLoginId ?? + _linkIdController.text + .trim(); + if (loginId.isEmpty) { + _showError( + tr( + 'msg.userfront.login.link.missing_phone', + ), + ); + return; + } + _startEnchantedFlow( + loginId, + isEmail: false, + codeOnly: true, + ); + }, + child: Text( + tr( + 'ui.userfront.login.link.code_only', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ), + ), + ), + ], ], ], ], - ], + ), ), ), - - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (_isQrLoading) - const CircularProgressIndicator() - else if (_qrExpired) - Column( - children: [ - Text( - tr( - 'msg.userfront.login.qr_expired', - ), - textAlign: TextAlign.center, - style: TextStyle( - color: mutedColor, - fontSize: 12, - ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: _startQrFlow, - style: FilledButton.styleFrom( - minimumSize: - const Size.fromHeight(45), - ), - child: Text( - tr('ui.common.refresh'), - ), - ), - ], - ) - else if (_qrImageBase64 != null) - Column( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all( - color: colorScheme.outline, - ), - borderRadius: - BorderRadius.circular(12), - ), - child: QrImageView( - data: _qrImageBase64!, - version: QrVersions.auto, - size: 200.0, - backgroundColor: Colors.white, - ), - ), - const SizedBox(height: 12), - Text( - _qrRemainingSeconds > 0 - ? tr( - 'ui.userfront.login.qr.remaining', - params: { - 'time': _formatTime( - _qrRemainingSeconds, - ), - }, - ) - : tr( - 'ui.userfront.login.qr.expired', - ), - textAlign: TextAlign.center, - style: TextStyle( - color: _qrRemainingSeconds > 30 - ? Colors.blue - : Colors.red, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - tr( - 'msg.userfront.login.qr.scan_hint', - ), - textAlign: TextAlign.center, - style: TextStyle( - color: mutedColor, - fontSize: 12, - ), - ), - TextButton( - onPressed: _startQrFlow, - child: Text( - tr( - 'ui.userfront.login.qr.refresh', - ), - ), - ), - ], - ) - else - Text( - tr( - 'msg.userfront.login.qr.load_failed', - ), - textAlign: TextAlign.center, - ), - ], - ), - ], - ), - ), - const SizedBox(height: 16), - Column( - children: [ - TextButton( - onPressed: () => - context.push('/forgot-password'), - child: Text( - tr('ui.userfront.login.forgot_password'), - ), ), - Row( + Column( mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - tr('msg.userfront.login.no_account'), - style: TextStyle( - color: mutedColor, - fontSize: 14, + if (_isQrLoading) + const CircularProgressIndicator() + else if (_qrExpired) + Column( + children: [ + Text( + tr('msg.userfront.login.qr_expired'), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: _startQrFlow, + child: Text(tr('ui.common.refresh')), + ), + ], + ) + else if (_qrImageBase64 != null) + Column( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + border: Border.all( + color: colorScheme.outline, + ), + borderRadius: BorderRadius.circular( + 18, + ), + ), + child: QrImageView( + data: _qrImageBase64!, + version: QrVersions.auto, + size: 200.0, + backgroundColor: Colors.white, + ), + ), + const SizedBox(height: 14), + Text( + _qrRemainingSeconds > 0 + ? tr( + 'ui.userfront.login.qr.remaining', + params: { + 'time': _formatTime( + _qrRemainingSeconds, + ), + }, + ) + : tr( + 'ui.userfront.login.qr.expired', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: _qrRemainingSeconds > 30 + ? primaryColor + : Colors.red, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + tr( + 'msg.userfront.login.qr.scan_hint', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.5, + ), + ), + TextButton( + onPressed: _startQrFlow, + child: Text( + tr('ui.userfront.login.qr.refresh'), + ), + ), + ], + ) + else + Text( + tr('msg.userfront.login.qr.load_failed'), + textAlign: TextAlign.center, ), - ), - TextButton( - onPressed: () => context.push('/signup'), - child: Text( - tr('ui.userfront.login.signup'), - ), - ), ], ), ], ), - const SizedBox(height: 6), - const Wrap( - alignment: WrapAlignment.center, - spacing: 10, - runSpacing: 10, - children: [ThemeToggleButton(), LanguageSelector()], - ), - ], - ), + ), + const SizedBox(height: 18), + Column( + children: [ + TextButton( + onPressed: () => context.push('/forgot-password'), + child: Text( + tr('ui.userfront.login.forgot_password'), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tr('msg.userfront.login.no_account'), + style: TextStyle( + color: mutedColor, + fontSize: 14, + ), + ), + TextButton( + onPressed: () => context.push('/signup'), + child: Text(tr('ui.userfront.login.signup')), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + const Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: [ThemeToggleButton(), LanguageSelector()], + ), + ], ), ), ), diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index deb1a9e0..774ecb66 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -26,6 +26,7 @@ import 'core/services/web_window.dart'; import 'core/notifiers/auth_notifier.dart'; import 'core/theme/app_theme.dart'; import 'core/theme/theme_controller.dart'; +import 'core/theme/theme_scope.dart'; import 'core/i18n/locale_gate.dart'; import 'core/i18n/locale_registry.dart'; import 'core/i18n/locale_utils.dart'; @@ -108,7 +109,8 @@ void main() async { // 0. Initialize Logger LoggerService.init(); - await ThemeController.instance.restore(); + await ThemeController.app.restore(); + await ThemeController.auth.restore(); // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 await _loadBundledFonts(); @@ -180,12 +182,18 @@ final _router = GoRouter( GoRoute( path: 'dashboard', builder: (context, state) { - return const DashboardScreen(); + return ScopedTheme( + controller: ThemeController.app, + child: const DashboardScreen(), + ); }, ), GoRoute( path: 'profile', - builder: (context, state) => const ProfilePage(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.app, + child: const ProfilePage(), + ), ), GoRoute( path: 'signin', @@ -195,10 +203,13 @@ final _router = GoRouter( final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url']; - return LoginScreen( - key: state.pageKey, - loginChallenge: loginChallenge, - redirectUrl: redirectUrl, + return ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen( + key: state.pageKey, + loginChallenge: loginChallenge, + redirectUrl: redirectUrl, + ), ); }, ), @@ -211,10 +222,13 @@ final _router = GoRouter( final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url']; - return LoginScreen( - key: state.pageKey, - loginChallenge: loginChallenge, - redirectUrl: redirectUrl, + return ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen( + key: state.pageKey, + loginChallenge: loginChallenge, + redirectUrl: redirectUrl, + ), ); }, ), @@ -230,88 +244,137 @@ final _router = GoRouter( ), ); } - return ConsentScreen(consentChallenge: consentChallenge); + return ScopedTheme( + controller: ThemeController.auth, + child: ConsentScreen(consentChallenge: consentChallenge), + ); }, ), GoRoute( path: 'signup', - builder: (context, state) => const SignupScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const SignupScreen(), + ), ), GoRoute( path: 'registration', - builder: (context, state) => const SignupScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const SignupScreen(), + ), ), GoRoute( path: 'verify', - builder: (context, state) => LoginScreen(key: state.pageKey), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen(key: state.pageKey), + ), ), GoRoute( path: 'verify/:token', builder: (context, state) { final token = state.pathParameters['token']; - return LoginScreen( - key: state.pageKey, - verificationToken: token, + return ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen( + key: state.pageKey, + verificationToken: token, + ), ); }, ), GoRoute( path: 'verification', - builder: (context, state) => LoginScreen(key: state.pageKey), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen(key: state.pageKey), + ), ), GoRoute( path: 'l/:shortCode', builder: (context, state) { - return LoginScreen(key: state.pageKey); + return ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen(key: state.pageKey), + ); }, ), GoRoute( path: 'forgot-password', - builder: (context, state) => const ForgotPasswordScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const ForgotPasswordScreen(), + ), ), GoRoute( path: 'recovery', - builder: (context, state) => const ForgotPasswordScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const ForgotPasswordScreen(), + ), ), GoRoute( path: 'reset-password', - builder: (context, state) => const ResetPasswordScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const ResetPasswordScreen(), + ), ), GoRoute( path: 'error', builder: (context, state) { final params = state.uri.queryParameters; - return ErrorScreen( - errorId: params['id'], - errorCode: params['error'], - description: params['error_description'] ?? params['message'], + return ScopedTheme( + controller: ThemeController.auth, + child: ErrorScreen( + errorId: params['id'], + errorCode: params['error'], + description: + params['error_description'] ?? params['message'], + ), ); }, ), GoRoute( path: 'settings', - builder: (context, state) => ErrorScreen( - errorCode: 'settings_disabled', - description: tr('msg.userfront.settings.disabled'), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: ErrorScreen( + errorCode: 'settings_disabled', + description: tr('msg.userfront.settings.disabled'), + ), ), ), GoRoute( path: 'approve', - builder: (context, state) => - ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: ApproveQrScreen( + pendingRef: state.uri.queryParameters['ref'], + ), + ), ), GoRoute( path: 'ql/:ref', - builder: (context, state) => - ApproveQrScreen(pendingRef: state.pathParameters['ref']), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: ApproveQrScreen(pendingRef: state.pathParameters['ref']), + ), ), GoRoute( path: 'scan', - builder: (context, state) => const QRScanScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const QRScanScreen(), + ), ), GoRoute( path: 'admin/users', - builder: (context, state) => const UserManagementScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.app, + child: const UserManagementScreen(), + ), ), ], ), @@ -369,25 +432,20 @@ class BaronSSOApp extends StatelessWidget { final locale = localization?.currentLocale ?? Locale(resolvePreferredLocaleCode()); - return ValueListenableBuilder( - valueListenable: ThemeController.instance, - builder: (context, themeMode, _) { - return MaterialApp.router( - title: tr('ui.userfront.app_title'), - localizationsDelegates: delegates, - supportedLocales: supportedLocales, - locale: locale, - builder: (context, child) { - return Stack( - children: [if (child != null) child, const ToastViewport()], - ); - }, - theme: buildLightTheme(), - darkTheme: buildDarkTheme(), - themeMode: themeMode, - routerConfig: _router, + return MaterialApp.router( + title: tr('ui.userfront.app_title'), + localizationsDelegates: delegates, + supportedLocales: supportedLocales, + locale: locale, + builder: (context, child) { + return Stack( + children: [if (child != null) child, const ToastViewport()], ); }, + theme: buildLightTheme(), + darkTheme: buildDarkTheme(), + themeMode: ThemeMode.light, + routerConfig: _router, ); } } diff --git a/userfront/test/theme_controller_test.dart b/userfront/test/theme_controller_test.dart index 1c829b93..447255c9 100644 --- a/userfront/test/theme_controller_test.dart +++ b/userfront/test/theme_controller_test.dart @@ -8,25 +8,25 @@ void main() { setUp(() async { SharedPreferences.setMockInitialValues({}); - await ThemeController.instance.setThemeMode(ThemeMode.light); + await ThemeController.app.setThemeMode(ThemeMode.light); }); test('저장된 dark 값을 복원한다', () async { SharedPreferences.setMockInitialValues({ - ThemeController.storageKey: 'dark', + ThemeController.appStorageKey: 'dark', }); - await ThemeController.instance.restore(); + await ThemeController.app.restore(); - expect(ThemeController.instance.value, ThemeMode.dark); + expect(ThemeController.app.value, ThemeMode.dark); }); test('toggle 결과를 저장한다', () async { - await ThemeController.instance.restore(); - await ThemeController.instance.toggle(); + await ThemeController.app.restore(); + await ThemeController.app.toggle(); final prefs = await SharedPreferences.getInstance(); - expect(ThemeController.instance.value, ThemeMode.dark); - expect(prefs.getString(ThemeController.storageKey), 'dark'); + expect(ThemeController.app.value, ThemeMode.dark); + expect(prefs.getString(ThemeController.appStorageKey), 'dark'); }); } From df09694ed62d4d79caf2f1cbcdecc33deaf346f6 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 9 Apr 2026 09:46:40 +0900 Subject: [PATCH 11/14] =?UTF-8?q?=EC=95=B1=20=EB=A1=9C=EA=B3=A0=20URL=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=83=81=ED=83=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientGeneralPage.tsx | 121 +++++++++++++++++- devfront/src/locales/en.toml | 7 + devfront/src/locales/ko.toml | 7 + devfront/src/locales/template.toml | 7 + 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 2446daf9..84c4d470 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowLeft, + ExternalLink, Info, Plus, Save, @@ -133,6 +134,9 @@ function ClientGeneralPage() { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [logoUrl, setLogoUrl] = useState(""); + const [logoPreviewStatus, setLogoPreviewStatus] = useState< + "idle" | "loading" | "loaded" | "error" + >("idle"); const [clientType, setClientType] = useState("private"); const [status, setStatus] = useState("active"); const [initialStatus, setInitialStatus] = useState("active"); @@ -240,6 +244,21 @@ function ClientGeneralPage() { const securityProfile: SecurityProfile = clientType === "pkce" ? "pkce" : "private"; + const trimmedLogoUrl = logoUrl.trim(); + const hasLogoUrl = trimmedLogoUrl.length > 0; + const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl); + + useEffect(() => { + if (!hasLogoUrl) { + setLogoPreviewStatus("idle"); + return; + } + if (!hasValidLogoUrl) { + setLogoPreviewStatus("error"); + return; + } + setLogoPreviewStatus("loading"); + }, [hasLogoUrl, hasValidLogoUrl, trimmedLogoUrl]); const handleSecurityProfileChange = (profile: SecurityProfile) => { setClientType(profile); @@ -438,6 +457,15 @@ function ClientGeneralPage() { const mutation = useMutation({ mutationFn: async () => { + if (hasLogoUrl && !hasValidLogoUrl) { + throw new Error( + t( + "msg.dev.clients.general.identity.logo_invalid", + "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.", + ), + ); + } + const scopeNames = scopes.map((scope) => scope.name).filter(Boolean); const effectiveTokenEndpointAuthMethod = @@ -457,7 +485,7 @@ function ClientGeneralPage() { : undefined, metadata: { description, - logo_url: logoUrl, + logo_url: trimmedLogoUrl, structured_scopes: scopes, token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, headless_login_enabled: headlessLoginEnabled, @@ -722,6 +750,8 @@ function ClientGeneralPage() { setLogoUrl(e.target.value)} + aria-invalid={!hasValidLogoUrl} + className={!hasValidLogoUrl ? "border-destructive" : ""} placeholder={t( "ui.dev.clients.general.identity.logo_placeholder", "https://example.com/logo.png", @@ -733,19 +763,100 @@ function ClientGeneralPage() { "인증 화면에 표시될 PNG/SVG URL입니다.", )}

+ {!hasValidLogoUrl ? ( +

+ {t( + "msg.dev.clients.general.identity.logo_invalid", + "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.", + )} +

+ ) : null} + {hasLogoUrl && hasValidLogoUrl ? ( +
+ + {logoPreviewStatus === "loading" + ? t( + "msg.dev.clients.general.identity.logo_preview_loading", + "로고 미리보기를 불러오는 중입니다.", + ) + : logoPreviewStatus === "loaded" + ? t( + "msg.dev.clients.general.identity.logo_preview_ready", + "로고 미리보기를 확인했습니다.", + ) + : logoPreviewStatus === "error" + ? t( + "msg.dev.clients.general.identity.logo_preview_failed", + "로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요.", + ) + : null} + + + + {t( + "ui.dev.clients.general.identity.logo_open", + "새 탭에서 열기", + )} + +
+ ) : null} -
- {logoUrl ? ( +
+ {hasLogoUrl && hasValidLogoUrl ? ( {t( setLogoPreviewStatus("loaded")} + onError={() => setLogoPreviewStatus("error")} /> ) : ( - +
+ + {logoPreviewStatus === "error" ? ( + + {t( + "ui.dev.clients.general.identity.logo_preview_error_badge", + "미리보기 실패", + )} + + ) : ( + + {t( + "ui.dev.clients.general.identity.logo_preview_empty", + "미리보기", + )} + + )} +
)}
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index d3ae2a09..87256821 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -377,6 +377,10 @@ empty = "No IdP configurations found." [msg.dev.clients.general.identity] logo_help = "PNG or SVG URL shown on the consent and authentication screens." +logo_invalid = "The app logo URL format is invalid. Enter an http or https address." +logo_preview_loading = "Loading the logo preview." +logo_preview_ready = "Logo preview loaded." +logo_preview_failed = "Failed to load the logo preview. Check the URL or image access policy." subtitle = "Set the application name, description, and logo." [msg.dev.clients.general.redirect] @@ -1378,6 +1382,9 @@ description_placeholder = "Description Placeholder" logo = "App Logo URL" logo_placeholder = "https://example.com/logo.png" logo_preview = "Logo Preview" +logo_open = "Open in new tab" +logo_preview_error_badge = "Preview failed" +logo_preview_empty = "Preview" name = "Name" name_placeholder = "My Awesome Application" title = "Application Identity" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index b6abb943..2ac3c74a 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -377,6 +377,10 @@ subtitle = "이 애플리케이션의 외부 IdP 설정을 관리합니다." [msg.dev.clients.general.identity] logo_help = "인증 화면에 표시될 PNG/SVG URL입니다." +logo_invalid = "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요." +logo_preview_loading = "로고 미리보기를 불러오는 중입니다." +logo_preview_ready = "로고 미리보기를 확인했습니다." +logo_preview_failed = "로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요." subtitle = "앱 이름과 설명, 로고를 설정합니다." [msg.dev.clients.general.redirect] @@ -1377,6 +1381,9 @@ description_placeholder = "앱에 대한 간단한 설명을 입력하세요." logo = "앱 로고 URL" logo_placeholder = "https://example.com/logo.png" logo_preview = "로고 미리보기" +logo_open = "새 탭에서 열기" +logo_preview_error_badge = "미리보기 실패" +logo_preview_empty = "미리보기" name = "앱 이름" name_placeholder = "예: 멋진 애플리케이션" title = "애플리케이션 정보" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 87853adf..460baaec 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -377,6 +377,10 @@ empty = "" [msg.dev.clients.general.identity] logo_help = "" +logo_invalid = "" +logo_preview_loading = "" +logo_preview_ready = "" +logo_preview_failed = "" subtitle = "" [msg.dev.clients.general.redirect] @@ -1378,6 +1382,9 @@ description_placeholder = "" logo = "" logo_placeholder = "" logo_preview = "" +logo_open = "" +logo_preview_error_badge = "" +logo_preview_empty = "" name = "" name_placeholder = "" title = "" From 06a6875cdb828b9c4c5580c469ef2afbe3fed614 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 9 Apr 2026 11:27:46 +0900 Subject: [PATCH 12/14] =?UTF-8?q?App=20=EC=B9=B4=EB=93=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 42 +++++ .../handler/auth_handler_linked_test.go | 85 ++++++++++ .../presentation/dashboard_screen.dart | 145 ++++++++++++++---- userfront/pubspec.lock | 48 ++++++ userfront/pubspec.yaml | 1 + 5 files changed, 290 insertions(+), 31 deletions(-) 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 From c6ddf7c485e18ba99199b4d75a6df856078fda41 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 9 Apr 2026 16:45:26 +0900 Subject: [PATCH 13/14] =?UTF-8?q?code=20check=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 13 ++++++++---- adminfront/package.json | 2 +- adminfront/scripts/runtime-mode.sh | 4 ++-- adminfront/src/features/auth/LoginPage.tsx | 8 +------ adminfront/src/locales/template.toml | 1 + adminfront/vite.config.ts | 4 ++-- devfront/package.json | 2 +- devfront/scripts/runtime-mode.sh | 4 ++-- devfront/src/features/auth/LoginPage.tsx | 8 +------ .../features/clients/ClientDetailsPage.tsx | 12 +++++++++++ .../features/clients/ClientGeneralPage.tsx | 7 +++++-- devfront/src/locales/template.toml | 1 + .../tests/devfront-role-switch-report.spec.ts | 1 + devfront/tests/helpers/devfront-fixtures.ts | 6 +++++- devfront/vite.config.ts | 4 ++-- locales/template.toml | 1 + scripts/run_adminfront_ci_tests.sh | 13 +++++++++++- tools/i18n-scanner/report.js | 21 ++++++++++++++++++- userfront/assets/translations/en.toml | 2 +- userfront/assets/translations/ko.toml | 4 ++-- userfront/assets/translations/template.toml | 1 + 21 files changed, 83 insertions(+), 36 deletions(-) diff --git a/Makefile b/Makefile index 855f72ef..f08f8d6a 100644 --- a/Makefile +++ b/Makefile @@ -107,12 +107,17 @@ logs-app: docker compose -f $(COMPOSE_APP) logs -f # --- 로컬 통합 코드 체크 --- +PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright +PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE +PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE +PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE + ifeq ($(CI),) -PLAYWRIGHT_INSTALL_ALL := npx playwright install -PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install chromium +PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi' +PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi' else -PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps -PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium +PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install --with-deps; fi' +PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install --with-deps chromium; fi' endif .PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests diff --git a/adminfront/package.json b/adminfront/package.json index fbcba5b6..80dd2bb4 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -7,7 +7,7 @@ "node": ">=24.0.0" }, "scripts": { - "dev": "vite --host 0.0.0.0", + "dev": "vite --host 127.0.0.1", "build": "tsc -b && vite build", "lint": "biome check .", "lint:fix": "biome check . --write", diff --git a/adminfront/scripts/runtime-mode.sh b/adminfront/scripts/runtime-mode.sh index aa41dce1..8191bf0d 100644 --- a/adminfront/scripts/runtime-mode.sh +++ b/adminfront/scripts/runtime-mode.sh @@ -19,8 +19,8 @@ fi if [ "$mode" = "production" ]; then echo "Running in production mode with Vite preview..." - exec sh -c "npm run build && npm run preview -- --host 0.0.0.0" + exec sh -c "npm run build && npm run preview -- --host 127.0.0.1" fi echo "Running in development mode..." -exec npm run dev -- --host 0.0.0.0 +exec npm run dev -- --host 127.0.0.1 diff --git a/adminfront/src/features/auth/LoginPage.tsx b/adminfront/src/features/auth/LoginPage.tsx index a9edebb5..bc6d780d 100644 --- a/adminfront/src/features/auth/LoginPage.tsx +++ b/adminfront/src/features/auth/LoginPage.tsx @@ -39,13 +39,7 @@ function LoginPage() { returnTo, }, }); - }, [ - auth, - auth.activeNavigator, - auth.isLoading, - returnTo, - shouldAutoLogin, - ]); + }, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]); const handleSSOLogin = () => { void auth.signinRedirect({ diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 868d4f8d..eaacc511 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -1501,6 +1501,7 @@ ory = "" session = "" [ui.userfront.dashboard] +link_status_label = "" last_auth_label = "" status_history = "" diff --git a/adminfront/vite.config.ts b/adminfront/vite.config.ts index 2a8338a8..8176399c 100644 --- a/adminfront/vite.config.ts +++ b/adminfront/vite.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ plugins: [react()], envPrefix: ["VITE_", "USERFRONT_"], server: { - host: "0.0.0.0", + host: "127.0.0.1", allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"], proxy: { "/api": { @@ -15,7 +15,7 @@ export default defineConfig({ }, }, preview: { - host: "0.0.0.0", + host: "127.0.0.1", port: 5173, allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"], proxy: { diff --git a/devfront/package.json b/devfront/package.json index 4dd04e0b..8809ce3a 100644 --- a/devfront/package.json +++ b/devfront/package.json @@ -7,7 +7,7 @@ "node": ">=24.0.0" }, "scripts": { - "dev": "vite --host 0.0.0.0", + "dev": "vite --host 127.0.0.1", "build": "tsc -b && vite build", "lint": "biome check .", "preview": "vite preview", diff --git a/devfront/scripts/runtime-mode.sh b/devfront/scripts/runtime-mode.sh index aa41dce1..8191bf0d 100644 --- a/devfront/scripts/runtime-mode.sh +++ b/devfront/scripts/runtime-mode.sh @@ -19,8 +19,8 @@ fi if [ "$mode" = "production" ]; then echo "Running in production mode with Vite preview..." - exec sh -c "npm run build && npm run preview -- --host 0.0.0.0" + exec sh -c "npm run build && npm run preview -- --host 127.0.0.1" fi echo "Running in development mode..." -exec npm run dev -- --host 0.0.0.0 +exec npm run dev -- --host 127.0.0.1 diff --git a/devfront/src/features/auth/LoginPage.tsx b/devfront/src/features/auth/LoginPage.tsx index cd9f8ca1..212eabf7 100644 --- a/devfront/src/features/auth/LoginPage.tsx +++ b/devfront/src/features/auth/LoginPage.tsx @@ -40,13 +40,7 @@ function LoginPage() { returnTo, }, }); - }, [ - auth, - auth.activeNavigator, - auth.isLoading, - returnTo, - shouldAutoLogin, - ]); + }, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]); const handleSSOLogin = async () => { try { diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index f4a3550f..26346563 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -150,7 +150,18 @@ function ClientDetailsPage() { ); } + if (isLoading && !data) { + return ( +
+ {t("msg.dev.clients.details.loading", "Loading app details...")} +
+ ); + } + const client = data?.client; + if (!client) { + return null; + } const endpointValues = data?.endpoints ?? { discovery: "-", issuer: "-", @@ -469,6 +480,7 @@ function ClientDetailsPage() { )} rows={5} value={redirectUris} + onFocus={(e) => e.currentTarget.select()} onChange={(e) => { redirectUrisHydratedRef.current = true; setRedirectUris(e.target.value); diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 84c4d470..32928281 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -258,7 +258,7 @@ function ClientGeneralPage() { return; } setLogoPreviewStatus("loading"); - }, [hasLogoUrl, hasValidLogoUrl, trimmedLogoUrl]); + }, [hasLogoUrl, hasValidLogoUrl]); const handleSecurityProfileChange = (profile: SecurityProfile) => { setClientType(profile); @@ -814,7 +814,9 @@ function ClientGeneralPage() {