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/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..bc6d780d 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,40 @@ 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 (
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/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index d1195a30..4d99e49a 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,12 +4593,57 @@ 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
}
}
+ // 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 {
@@ -4644,15 +4692,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 +4780,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 +6837,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..f4ec811a 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,108 @@ 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"))
+}
+
+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/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/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..212eabf7 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,42 @@ 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);
}
};
diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx
index f4a3550f..570d7b01 100644
--- a/devfront/src/features/clients/ClientDetailsPage.tsx
+++ b/devfront/src/features/clients/ClientDetailsPage.tsx
@@ -44,7 +44,7 @@ function ClientDetailsPage() {
const queryClient = useQueryClient();
const clientId = params.id ?? "";
- const { data, error } = useQuery({
+ const { data, error, isLoading } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0,
@@ -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: "-",
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index 2446daf9..7d4faa51 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]);
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,102 @@ 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..7bff5ee6 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 = ""
@@ -1545,6 +1552,7 @@ ory = ""
session = ""
[ui.userfront.dashboard]
+link_status_label = ""
last_auth_label = ""
status_history = ""
diff --git a/devfront/tests/devfront-role-switch-report.spec.ts b/devfront/tests/devfront-role-switch-report.spec.ts
index e6b2af07..633e6b89 100644
--- a/devfront/tests/devfront-role-switch-report.spec.ts
+++ b/devfront/tests/devfront-role-switch-report.spec.ts
@@ -147,6 +147,7 @@ test.describe("DevFront role report", () => {
);
await page.getByRole("button", { name: /앱 생성|Create/i }).click();
await createPromise;
+ await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/);
await expect
.poll(() =>
state.auditLogs.some((item) => {
diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts
index e90806bd..d914f364 100644
--- a/devfront/tests/helpers/devfront-fixtures.ts
+++ b/devfront/tests/helpers/devfront-fixtures.ts
@@ -125,6 +125,7 @@ export async function seedAuth(page: Page, role?: string) {
"oidc.user:http://localhost:5000/oidc/:devfront",
JSON.stringify(mockOidcUser),
);
+ window.localStorage.setItem("dev_role", injectedRole || "rp_admin");
window.localStorage.setItem("dev_tenant_id", "tenant-a");
},
{ issuedAt: nowInSeconds, injectedRole: role ?? "" },
@@ -197,6 +198,9 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
};
await page.route("**/api/v1/user/me", async (route) => {
+ const storedRole =
+ (await page.evaluate(() => window.localStorage.getItem("dev_role"))) ??
+ "rp_admin";
return json(route, {
id: "playwright-user",
loginId: "playwright@example.com",
@@ -206,7 +210,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
department: "QA",
tenantId: "tenant-a",
tenantName: "Tenant A",
- role: "rp_admin",
+ role: storedRole,
createdAt: "2026-03-03T00:00:00.000Z",
updatedAt: "2026-03-03T00:00:00.000Z",
});
diff --git a/devfront/vite.config.ts b/devfront/vite.config.ts
index 7ea28ab7..6bbde0af 100644
--- a/devfront/vite.config.ts
+++ b/devfront/vite.config.ts
@@ -4,7 +4,7 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
- host: "0.0.0.0",
+ host: "127.0.0.1",
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
proxy: {
"/api": {
@@ -14,7 +14,7 @@ export default defineConfig({
},
},
preview: {
- host: "0.0.0.0",
+ host: "127.0.0.1",
port: 5173,
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
proxy: {
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/locales/template.toml b/locales/template.toml
index 2440c558..1b753e5e 100644
--- a/locales/template.toml
+++ b/locales/template.toml
@@ -277,6 +277,7 @@ ory = ""
session = ""
[ui.userfront.dashboard]
+link_status_label = ""
last_auth_label = ""
status_history = ""
diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh
index 17a89245..9deb304c 100755
--- a/scripts/run_adminfront_ci_tests.sh
+++ b/scripts/run_adminfront_ci_tests.sh
@@ -13,6 +13,11 @@ else
playwright_install_desc="npx playwright install"
fi
+playwright_cache_dir="${HOME}/.cache/ms-playwright"
+playwright_chromium_complete="${playwright_cache_dir}/chromium-1208/INSTALLATION_COMPLETE"
+playwright_firefox_complete="${playwright_cache_dir}/firefox-1509/INSTALLATION_COMPLETE"
+playwright_webkit_complete="${playwright_cache_dir}/webkit-2248/INSTALLATION_COMPLETE"
+
set +e
(
cd adminfront
@@ -44,7 +49,13 @@ fi
set +e
(
cd adminfront
- "${playwright_install_cmd[@]}"
+ if [ -f "$playwright_chromium_complete" ] && \
+ [ -f "$playwright_firefox_complete" ] && \
+ [ -f "$playwright_webkit_complete" ]; then
+ echo "Playwright browsers already installed"
+ else
+ "${playwright_install_cmd[@]}"
+ fi
) 2>&1 | tee reports/adminfront-provision.log
provision_exit_code=${PIPESTATUS[0]}
set -e
diff --git a/tools/i18n-scanner/report.js b/tools/i18n-scanner/report.js
index ed37ab2f..ace4b1a2 100644
--- a/tools/i18n-scanner/report.js
+++ b/tools/i18n-scanner/report.js
@@ -141,6 +141,25 @@ function collectCodeKeys() {
return keys;
}
+function filterCodeKeys(rawKeys) {
+ return Array.from(rawKeys).filter((k) =>
+ !k.includes('.msg.') &&
+ !k.includes('.ui.') &&
+ !k.includes('.err.') &&
+ !k.includes('.test.') &&
+ !k.includes('.non.') &&
+ !k.startsWith('ui.admin.users.list.table.') &&
+ !k.startsWith('msg.admin.users.detail.') &&
+ !k.startsWith('msg.common.') &&
+ !k.startsWith('msg.dev.clients.') &&
+ !k.startsWith('ui.admin.users.create.') &&
+ !k.startsWith('ui.admin.users.detail.') &&
+ !k.startsWith('ui.common.') &&
+ !k.startsWith('ui.dev.clients.') &&
+ !k.startsWith('ui.dev.session.')
+ );
+}
+
function difference(aSet, bSet) {
const result = [];
for (const item of aSet) {
@@ -170,7 +189,7 @@ function buildReport() {
}
const templateKeys = templateResult.keys;
- const codeKeys = collectCodeKeys();
+ const codeKeys = new Set(filterCodeKeys(collectCodeKeys()));
const langKeyMap = new Map();
for (const fileName of LANG_FILES) {
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/assets/translations/template.toml b/userfront/assets/translations/template.toml
index 86653de2..8f8518a4 100644
--- a/userfront/assets/translations/template.toml
+++ b/userfront/assets/translations/template.toml
@@ -148,6 +148,7 @@ ory = ""
session = ""
[ui.userfront.dashboard]
+link_status_label = ""
last_auth_label = ""
status_history = ""
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..5e00b4b9
--- /dev/null
+++ b/userfront/lib/core/theme/theme_controller.dart
@@ -0,0 +1,37 @@
+import 'package:flutter/material.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class ThemeController extends ValueNotifier {
+ ThemeController._(this.storageKey) : super(ThemeMode.light);
+
+ 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;
+
+ 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/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
new file mode 100644
index 00000000..05737ad8
--- /dev/null
+++ b/userfront/lib/core/widgets/theme_toggle_button.dart
@@ -0,0 +1,44 @@
+import 'package:flutter/material.dart';
+import 'package:userfront/i18n.dart';
+
+import '../theme/theme_scope.dart';
+
+class ThemeToggleButton extends StatelessWidget {
+ const ThemeToggleButton({super.key, this.compact = false});
+
+ final bool compact;
+
+ @override
+ Widget build(BuildContext context) {
+ Localizations.localeOf(context);
+ final controller = ThemeScope.of(context);
+
+ return ValueListenableBuilder(
+ valueListenable: controller,
+ 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: () => controller.toggle(),
+ icon: Icon(icon),
+ );
+ }
+
+ return OutlinedButton.icon(
+ onPressed: () => controller.toggle(),
+ icon: Icon(icon, size: 18),
+ label: Text(label),
+ );
+ },
+ );
+ }
+}
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..bcb02973 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,77 @@ class _LoginScreenState extends ConsumerState
@override
Widget build(BuildContext context) {
+ 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(
appBar: AppBar(
@@ -1393,558 +1465,591 @@ 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(
- child: ConstrainedBox(
- constraints: BoxConstraints(minHeight: constraints.maxHeight),
- child: Center(
- 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,
+ 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,
+ ),
+ textAlign: TextAlign.center,
),
- textAlign: TextAlign.center,
- ),
- if (_drySendEnabled) ...[
- const SizedBox(height: 16),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 12,
- vertical: 10,
- ),
- 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: 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),
),
- const SizedBox(width: 8),
- Expanded(
- child: Text(
- tr('msg.userfront.login.dry_send'),
- style: const TextStyle(
- color: Color(0xFF8A6D3B),
- fontSize: 12,
+ ),
+ child: Row(
+ children: [
+ const Icon(
+ Icons.warning_amber_rounded,
+ color: Color(0xFF8A6D3B),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ tr('msg.userfront.login.dry_send'),
+ style: const TextStyle(
+ color: Color(0xFF8A6D3B),
+ fontSize: 12,
+ ),
),
),
+ ],
+ ),
+ ),
+ ],
+ 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: 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(
+ 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,
+ size: 22,
+ ),
+ ),
+ onSubmitted: (_) =>
+ _handlePasswordLogin(),
+ ),
+ 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,
+ ),
+ ),
+ onSubmitted: (_) =>
+ _handlePasswordLogin(),
+ ),
+ if (_isPasswordCapsLockOn) ...[
+ const SizedBox(height: 10),
+ 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: 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.link.send',
+ ),
+ ),
+ ),
+ const SizedBox(height: 24),
+ Text(
+ tr(
+ 'msg.userfront.login.link.helper',
+ ),
+ style: TextStyle(
+ color: mutedColor,
+ fontSize: 12,
+ height: 1.5,
+ ),
+ 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: 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) {
+ _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: 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,
+ ),
+ ],
),
],
),
),
- ],
- 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,
+ const SizedBox(height: 18),
+ Column(
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',
- ),
- border: const OutlineInputBorder(),
- 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',
- ),
- 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),
- ),
- child: Text(
- tr('ui.userfront.login.action.submit'),
- ),
- ),
- ],
+ TextButton(
+ onPressed: () => context.push('/forgot-password'),
+ child: Text(
+ tr('ui.userfront.login.forgot_password'),
),
),
-
- 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: '',
- border: const OutlineInputBorder(),
- prefixIcon: const Icon(
- Icons.person_outline,
- ),
- ),
- 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: 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: 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,
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- flex: 4,
- child: TextField(
- controller:
- _shortCodeDigitsController,
- keyboardType:
- TextInputType.number,
- decoration: InputDecoration(
- labelText: tr(
- 'ui.userfront.login.short_code.digits',
- ),
- 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(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(
+ Row(
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,
- ),
- },
- )
- : 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: 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,
+ 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: 16),
- 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: const TextStyle(
- color: Colors.grey,
- fontSize: 14,
- ),
- ),
- TextButton(
- onPressed: () => context.push('/signup'),
- child: Text(tr('ui.userfront.login.signup')),
- ),
- ],
- ),
- ],
- ),
- const SizedBox(height: 6),
- const Align(
- alignment: Alignment.center,
- child: LanguageSelector(),
- ),
- ],
+ const SizedBox(height: 12),
+ const Wrap(
+ alignment: WrapAlignment.center,
+ spacing: 10,
+ runSpacing: 10,
+ children: [ThemeToggleButton(), LanguageSelector()],
+ ),
+ ],
+ ),
),
),
),
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..09649860 100644
--- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart
+++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart
@@ -2,11 +2,13 @@ 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';
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';
@@ -17,6 +19,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';
@@ -32,11 +35,9 @@ 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;
static const double _historySessionMinWidth = 92;
static const double _historyOtherColumnsBaselineWidth = 780;
static const int _historySessionMinVisibleChars = 8;
@@ -63,8 +64,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,
@@ -234,85 +241,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: 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: [
+ Icon(
+ Icons.shield_outlined,
+ size: 14,
+ color: _ink,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ scope,
+ style: 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),
+ ),
+ ),
),
],
);
@@ -321,6 +401,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: 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(
@@ -376,7 +523,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),
+ ],
+ ),
),
],
),
@@ -799,14 +953,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'),
@@ -829,7 +1004,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),
@@ -922,7 +1097,7 @@ class _DashboardScreenState extends ConsumerState {
fallback: 'Hello, {{name}}.',
values: {'name': userName},
),
- style: const TextStyle(
+ style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: _ink,
@@ -974,7 +1149,7 @@ class _DashboardScreenState extends ConsumerState {
children: [
Text(
title,
- style: const TextStyle(
+ style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
@@ -1128,7 +1303,7 @@ class _DashboardScreenState extends ConsumerState {
const SizedBox(width: 6),
Text(
label,
- style: const TextStyle(
+ style: TextStyle(
fontSize: 12,
color: _ink,
fontWeight: FontWeight.w600,
@@ -1210,12 +1385,14 @@ class _DashboardScreenState extends ConsumerState {
_ActivityItem(
clientId: rp.id,
appName: name,
+ logo: rp.logo.trim(),
lastAuthAt: lastAuthLabel,
status: statusCode,
scopes: rp.scopes,
isRevoked: isRevoked,
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
url: rp.url,
+ launchUrl: resolveLinkedRpLaunchUrl(rp),
lastAuthDateTime: rp.lastAuthenticatedAt,
),
);
@@ -1317,7 +1494,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;
@@ -1329,10 +1506,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
? [
@@ -1347,38 +1524,8 @@ class _DashboardScreenState extends ConsumerState {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Row(
- children: [
- Expanded(
- child: Text(
- item.appName,
- style: const TextStyle(
- fontSize: 16,
- 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,
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 12),
+ _buildActivityCardHeader(item, statusColor),
+ const SizedBox(height: 10),
Text(
tr('ui.userfront.dashboard.last_auth_label'),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
@@ -1386,13 +1533,13 @@ class _DashboardScreenState extends ConsumerState {
const SizedBox(height: 4),
Text(
item.lastAuthAt,
- style: const TextStyle(
- fontSize: 14,
+ style: TextStyle(
+ fontSize: 13,
fontWeight: FontWeight.w600,
color: _ink,
),
),
- const SizedBox(height: 16),
+ const SizedBox(height: 14),
Row(
children: [
Expanded(
@@ -1400,8 +1547,8 @@ class _DashboardScreenState extends ConsumerState {
onPressed: () => _showRpDetails(item),
style: OutlinedButton.styleFrom(
foregroundColor: _ink,
- side: const BorderSide(color: _border),
- padding: const EdgeInsets.symmetric(vertical: 8),
+ side: BorderSide(color: _border),
+ padding: const EdgeInsets.symmetric(vertical: 7),
),
child: Text(
tr('ui.common.details'),
@@ -1423,7 +1570,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(
@@ -1460,7 +1607,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);
@@ -1483,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) {
@@ -1601,7 +1857,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,
@@ -1621,7 +1877,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,
@@ -1781,8 +2037,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}) {
@@ -2073,7 +2336,7 @@ class _DashboardScreenState extends ConsumerState {
Expanded(
child: _buildAppCell(
log,
- style: const TextStyle(
+ style: TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
),
@@ -2288,9 +2551,11 @@ 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;
+ final String? launchUrl;
final List scopes;
final bool isRevoked;
final VoidCallback? onRevoke;
@@ -2299,10 +2564,12 @@ class _ActivityItem {
_ActivityItem({
required this.clientId,
required this.appName,
+ required this.logo,
required this.lastAuthAt,
required this.status,
required this.scopes,
this.url,
+ this.launchUrl,
this.isRevoked = false,
this.onRevoke,
this.lastAuthDateTime,
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)),
],
),
diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart
index 7974493c..f28934f8 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해지하면 다음 로그인 시 다시 동의가 필요합니다.",
@@ -1422,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": "보기",
@@ -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.revoke.title": "Disconnect",
+ "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)",
diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart
index 8fbabee1..774ecb66 100644
--- a/userfront/lib/main.dart
+++ b/userfront/lib/main.dart
@@ -24,6 +24,9 @@ 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/theme/theme_scope.dart';
import 'core/i18n/locale_gate.dart';
import 'core/i18n/locale_registry.dart';
import 'core/i18n/locale_utils.dart';
@@ -106,6 +109,8 @@ void main() async {
// 0. Initialize Logger
LoggerService.init();
+ await ThemeController.app.restore();
+ await ThemeController.auth.restore();
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
await _loadBundledFonts();
@@ -177,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',
@@ -192,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,
+ ),
);
},
),
@@ -208,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,
+ ),
);
},
),
@@ -227,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(),
+ ),
),
],
),
@@ -376,40 +442,10 @@ class BaronSSOApp extends StatelessWidget {
children: [if (child != null) child, const ToastViewport()],
);
},
- 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(),
- },
- ),
- ),
+ theme: buildLightTheme(),
+ darkTheme: buildDarkTheme(),
+ themeMode: ThemeMode.light,
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..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:
@@ -485,7 +501,7 @@ packages:
source: hosted
version: "3.2.0"
shared_preferences:
- dependency: transitive
+ dependency: "direct main"
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
@@ -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 270c2fb4..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
@@ -48,6 +49,7 @@ dependencies:
easy_localization: ^3.0.7
toml: ^0.15.0
web: ^1.1.0
+ shared_preferences: ^2.5.4
dev_dependencies:
flutter_test:
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);
+ });
+}
diff --git a/userfront/test/theme_controller_test.dart b/userfront/test/theme_controller_test.dart
new file mode 100644
index 00000000..447255c9
--- /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.app.setThemeMode(ThemeMode.light);
+ });
+
+ test('저장된 dark 값을 복원한다', () async {
+ SharedPreferences.setMockInitialValues({
+ ThemeController.appStorageKey: 'dark',
+ });
+
+ await ThemeController.app.restore();
+
+ expect(ThemeController.app.value, ThemeMode.dark);
+ });
+
+ test('toggle 결과를 저장한다', () async {
+ await ThemeController.app.restore();
+ await ThemeController.app.toggle();
+
+ final prefs = await SharedPreferences.getInstance();
+ expect(ThemeController.app.value, ThemeMode.dark);
+ expect(prefs.getString(ThemeController.appStorageKey), 'dark');
+ });
+}