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 ? (

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() {