1
0
forked from baron/baron-sso

Merge pull request 'feature/uf-appcard' (#539) from feature/uf-appcard into dev

Reviewed-on: baron/baron-sso#539
This commit is contained in:
2026-04-09 17:12:56 +09:00
46 changed files with 2224 additions and 817 deletions

View File

@@ -107,12 +107,17 @@ logs-app:
docker compose -f $(COMPOSE_APP) logs -f 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),) ifeq ($(CI),)
PLAYWRIGHT_INSTALL_ALL := npx playwright install 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 := npx playwright install chromium PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
else else
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps 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 := npx playwright install --with-deps chromium 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 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 .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

View File

@@ -7,7 +7,7 @@
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "biome check .", "lint": "biome check .",
"lint:fix": "biome check . --write", "lint:fix": "biome check . --write",

View File

@@ -19,8 +19,8 @@ fi
if [ "$mode" = "production" ]; then if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..." 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 fi
echo "Running in development mode..." echo "Running in development mode..."
exec npm run dev -- --host 0.0.0.0 exec npm run dev -- --host 127.0.0.1

View File

@@ -14,7 +14,14 @@ function AuthCallbackPage() {
if (user?.access_token) { if (user?.access_token) {
window.localStorage.setItem("admin_session", 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) { } else if (auth.error) {
console.error("Auth Error:", auth.error); console.error("Auth Error:", auth.error);
navigate("/login", { replace: true }); navigate("/login", { replace: true });

View File

@@ -1,5 +1,7 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react"; import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Card, Card,
@@ -11,10 +13,40 @@ import {
function LoginPage() { function LoginPage() {
const auth = useAuth(); 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 = () => { const handleSSOLogin = () => {
// OIDC client-side authentication flow started here void auth.signinRedirect({
auth.signinRedirect(); state: {
returnTo: "/",
},
});
}; };
return ( return (

View File

@@ -1501,6 +1501,7 @@ ory = ""
session = "" session = ""
[ui.userfront.dashboard] [ui.userfront.dashboard]
link_status_label = ""
last_auth_label = "" last_auth_label = ""
status_history = "" status_history = ""

View File

@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
envPrefix: ["VITE_", "USERFRONT_"], envPrefix: ["VITE_", "USERFRONT_"],
server: { server: {
host: "0.0.0.0", host: "127.0.0.1",
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"], allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
proxy: { proxy: {
"/api": { "/api": {
@@ -15,7 +15,7 @@ export default defineConfig({
}, },
}, },
preview: { preview: {
host: "0.0.0.0", host: "127.0.0.1",
port: 5173, port: 5173,
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"], allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
proxy: { proxy: {

View File

@@ -4483,7 +4483,8 @@ type linkedRpSummary struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Logo string `json:"logo,omitempty"` 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"` LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Scopes []string `json:"scopes,omitempty"` Scopes []string `json:"scopes,omitempty"`
@@ -4564,6 +4565,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" { if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" {
scopes = strings.Fields(client.Scope) scopes = strings.Fields(client.Scope)
} }
initURL := resolveLinkedRPInitURL(client.ClientID, scopes, client.RedirectURIs)
existing := records[clientID] existing := records[clientID]
if existing == nil { if existing == nil {
@@ -4573,6 +4575,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
Name: name, Name: name,
Logo: extractHydraClientLogo(client.Metadata), Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL, URL: clientURL,
InitURL: initURL,
Status: "active", // Hydra 세션이 있으면 활성 Status: "active", // Hydra 세션이 있으면 활성
Scopes: scopes, Scopes: scopes,
}, },
@@ -4590,12 +4593,57 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
if existing.URL == "" { if existing.URL == "" {
existing.URL = clientURL existing.URL = clientURL
} }
if existing.InitURL == "" {
existing.InitURL = initURL
}
existing.Scopes = mergeScopes(existing.Scopes, scopes) existing.Scopes = mergeScopes(existing.Scopes, scopes)
if lastAuth.After(existing.lastAuth) { if lastAuth.After(existing.lastAuth) {
existing.lastAuth = 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 포함) // [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함)
if h.ConsentRepo != nil { if h.ConsentRepo != nil {
for _, subject := range subjects { for _, subject := range subjects {
@@ -4644,6 +4692,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
client.ClientURI, client.ClientURI,
client.RedirectURIs, client.RedirectURIs,
) )
initURL := resolveLinkedRPInitURL(
client.ClientID,
dc.GrantedScopes,
client.RedirectURIs,
)
records[dc.ClientID] = &linkedRpRecord{ records[dc.ClientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{ linkedRpSummary: linkedRpSummary{
@@ -4651,6 +4704,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
Name: name, Name: name,
Logo: extractHydraClientLogo(client.Metadata), Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL, URL: clientURL,
InitURL: initURL,
Status: status, Status: status,
Scopes: dc.GrantedScopes, Scopes: dc.GrantedScopes,
}, },
@@ -4726,6 +4780,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
} }
} }
record.URL = clientURL record.URL = clientURL
record.InitURL = resolveLinkedRPInitURL(
client.ClientID,
scopes,
client.RedirectURIs,
)
} else { } else {
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체 // Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
if record.Name == "" { if record.Name == "" {
@@ -6778,6 +6837,63 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string
return "" 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 { func mergeScopes(current []string, next []string) []string {
if len(next) == 0 { if len(next) == 0 {
return current return current

View File

@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"testing" "testing"
"time" "time"
@@ -45,10 +46,13 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{ {
"client": map[string]interface{}{ "client": map[string]interface{}{
"client_id": "client-active", "client_id": "devfront",
"client_name": "Active App", "client_name": "DevFront",
"redirect_uris": []string{
"https://active.example.com/callback",
}, },
"granted_scope": []string{"openid"}, },
"grant_scope": []string{"openid", "profile"},
"handled_at": time.Now().Format(time.RFC3339), "handled_at": time.Now().Format(time.RFC3339),
}, },
}), nil }), nil
@@ -111,6 +115,8 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
t.Setenv("KRATOS_ADMIN_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) app := newLinkedRpTestApp(h)
@@ -127,6 +133,7 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
InitURL string `json:"init_url"`
} `json:"items"` } `json:"items"`
} }
json.NewDecoder(resp.Body).Decode(&res) json.NewDecoder(resp.Body).Decode(&res)
@@ -138,7 +145,108 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
statusMap[item.ID] = item.Status 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-consent"])
assert.Equal(t, "inactive", statusMap["client-audit"]) 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)
} }

View File

@@ -7,7 +7,7 @@
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "biome check .", "lint": "biome check .",
"preview": "vite preview", "preview": "vite preview",

View File

@@ -19,8 +19,8 @@ fi
if [ "$mode" = "production" ]; then if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..." 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 fi
echo "Running in development mode..." echo "Running in development mode..."
exec npm run dev -- --host 0.0.0.0 exec npm run dev -- --host 127.0.0.1

View File

@@ -17,12 +17,19 @@ export default function AuthCallbackPage() {
} }
if (auth.isAuthenticated) { 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) { } else if (auth.error) {
console.error("Auth Error:", auth.error); console.error("Auth Error:", auth.error);
navigate("/login", { replace: true }); navigate("/login", { replace: true });
} }
}, [auth.isAuthenticated, auth.error, navigate]); }, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
return <div>Loading Auth...</div>; return <div>Loading Auth...</div>;
} }

View File

@@ -1,7 +1,8 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react"; import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Card, Card,
@@ -14,18 +15,42 @@ import {
function LoginPage() { function LoginPage() {
const auth = useAuth(); const auth = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const returnTo = searchParams.get("returnTo") || "/clients";
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => { useEffect(() => {
if (auth.isAuthenticated) { 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 () => { const handleSSOLogin = async () => {
try { try {
await auth.signinPopup(); await auth.signinRedirect({
state: {
returnTo: "/clients",
},
});
} catch (error) { } catch (error) {
console.error("Popup login failed", error); console.error("Redirect login failed", error);
} }
}; };

View File

@@ -44,7 +44,7 @@ function ClientDetailsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const clientId = params.id ?? ""; const clientId = params.id ?? "";
const { data, error } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["client", clientId], queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId), queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0, enabled: clientId.length > 0,
@@ -150,7 +150,18 @@ function ClientDetailsPage() {
); );
} }
if (isLoading && !data) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.loading", "Loading app details...")}
</div>
);
}
const client = data?.client; const client = data?.client;
if (!client) {
return null;
}
const endpointValues = data?.endpoints ?? { const endpointValues = data?.endpoints ?? {
discovery: "-", discovery: "-",
issuer: "-", issuer: "-",

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import {
ArrowLeft, ArrowLeft,
ExternalLink,
Info, Info,
Plus, Plus,
Save, Save,
@@ -133,6 +134,9 @@ function ClientGeneralPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [logoUrl, setLogoUrl] = useState(""); const [logoUrl, setLogoUrl] = useState("");
const [logoPreviewStatus, setLogoPreviewStatus] = useState<
"idle" | "loading" | "loaded" | "error"
>("idle");
const [clientType, setClientType] = useState<ClientType>("private"); const [clientType, setClientType] = useState<ClientType>("private");
const [status, setStatus] = useState<ClientStatus>("active"); const [status, setStatus] = useState<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active"); const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
@@ -240,6 +244,21 @@ function ClientGeneralPage() {
const securityProfile: SecurityProfile = const securityProfile: SecurityProfile =
clientType === "pkce" ? "pkce" : "private"; 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) => { const handleSecurityProfileChange = (profile: SecurityProfile) => {
setClientType(profile); setClientType(profile);
@@ -438,6 +457,15 @@ function ClientGeneralPage() {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async () => { 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 scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
const effectiveTokenEndpointAuthMethod = const effectiveTokenEndpointAuthMethod =
@@ -457,7 +485,7 @@ function ClientGeneralPage() {
: undefined, : undefined,
metadata: { metadata: {
description, description,
logo_url: logoUrl, logo_url: trimmedLogoUrl,
structured_scopes: scopes, structured_scopes: scopes,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled, headless_login_enabled: headlessLoginEnabled,
@@ -722,6 +750,8 @@ function ClientGeneralPage() {
<Input <Input
value={logoUrl} value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)} onChange={(e) => setLogoUrl(e.target.value)}
aria-invalid={!hasValidLogoUrl}
className={!hasValidLogoUrl ? "border-destructive" : ""}
placeholder={t( placeholder={t(
"ui.dev.clients.general.identity.logo_placeholder", "ui.dev.clients.general.identity.logo_placeholder",
"https://example.com/logo.png", "https://example.com/logo.png",
@@ -733,19 +763,102 @@ function ClientGeneralPage() {
"인증 화면에 표시될 PNG/SVG URL입니다.", "인증 화면에 표시될 PNG/SVG URL입니다.",
)} )}
</p> </p>
{!hasValidLogoUrl ? (
<p className="text-xs text-destructive">
{t(
"msg.dev.clients.general.identity.logo_invalid",
"앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
)}
</p>
) : null}
{hasLogoUrl && hasValidLogoUrl ? (
<div className="flex items-center gap-2 text-xs">
<span
className={cn("text-muted-foreground", {
"text-foreground": logoPreviewStatus === "loaded",
"text-destructive": logoPreviewStatus === "error",
})}
>
{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}
</span>
<a
href={trimmedLogoUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
<ExternalLink className="h-3 w-3" />
{t(
"ui.dev.clients.general.identity.logo_open",
"새 탭에서 열기",
)}
</a>
</div> </div>
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0"> ) : null}
{logoUrl ? ( </div>
<div
className={cn(
"flex h-20 w-20 shrink-0 items-center justify-center rounded-lg border-2 border-dashed",
hasLogoUrl &&
hasValidLogoUrl &&
logoPreviewStatus !== "error"
? "bg-white"
: "bg-muted/40",
logoPreviewStatus === "error"
? "border-destructive/60"
: "border-border",
)}
>
{hasLogoUrl && hasValidLogoUrl ? (
<img <img
src={logoUrl} key={trimmedLogoUrl}
src={trimmedLogoUrl}
alt={t( alt={t(
"ui.dev.clients.general.identity.logo_preview", "ui.dev.clients.general.identity.logo_preview",
"Logo Preview", "Logo Preview",
)} )}
className="h-full w-full object-contain" className="h-full w-full object-contain"
onLoad={() => setLogoPreviewStatus("loaded")}
onError={() => setLogoPreviewStatus("error")}
/> />
) : ( ) : (
<Upload className="h-5 w-5 text-muted-foreground" /> <div className="flex flex-col items-center justify-center gap-1 px-2 text-center">
<Upload
className={cn("h-5 w-5 text-muted-foreground", {
"text-destructive": logoPreviewStatus === "error",
})}
/>
{logoPreviewStatus === "error" ? (
<span className="text-[10px] leading-tight text-destructive">
{t(
"ui.dev.clients.general.identity.logo_preview_error_badge",
"미리보기 실패",
)}
</span>
) : (
<span className="text-[10px] leading-tight text-muted-foreground">
{t(
"ui.dev.clients.general.identity.logo_preview_empty",
"미리보기",
)}
</span>
)}
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -377,6 +377,10 @@ empty = "No IdP configurations found."
[msg.dev.clients.general.identity] [msg.dev.clients.general.identity]
logo_help = "PNG or SVG URL shown on the consent and authentication screens." 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." subtitle = "Set the application name, description, and logo."
[msg.dev.clients.general.redirect] [msg.dev.clients.general.redirect]
@@ -1378,6 +1382,9 @@ description_placeholder = "Description Placeholder"
logo = "App Logo URL" logo = "App Logo URL"
logo_placeholder = "https://example.com/logo.png" logo_placeholder = "https://example.com/logo.png"
logo_preview = "Logo Preview" logo_preview = "Logo Preview"
logo_open = "Open in new tab"
logo_preview_error_badge = "Preview failed"
logo_preview_empty = "Preview"
name = "Name" name = "Name"
name_placeholder = "My Awesome Application" name_placeholder = "My Awesome Application"
title = "Application Identity" title = "Application Identity"

View File

@@ -377,6 +377,10 @@ subtitle = "이 애플리케이션의 외부 IdP 설정을 관리합니다."
[msg.dev.clients.general.identity] [msg.dev.clients.general.identity]
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다." logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
logo_invalid = "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요."
logo_preview_loading = "로고 미리보기를 불러오는 중입니다."
logo_preview_ready = "로고 미리보기를 확인했습니다."
logo_preview_failed = "로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요."
subtitle = "앱 이름과 설명, 로고를 설정합니다." subtitle = "앱 이름과 설명, 로고를 설정합니다."
[msg.dev.clients.general.redirect] [msg.dev.clients.general.redirect]
@@ -1377,6 +1381,9 @@ description_placeholder = "앱에 대한 간단한 설명을 입력하세요."
logo = "앱 로고 URL" logo = "앱 로고 URL"
logo_placeholder = "https://example.com/logo.png" logo_placeholder = "https://example.com/logo.png"
logo_preview = "로고 미리보기" logo_preview = "로고 미리보기"
logo_open = "새 탭에서 열기"
logo_preview_error_badge = "미리보기 실패"
logo_preview_empty = "미리보기"
name = "앱 이름" name = "앱 이름"
name_placeholder = "예: 멋진 애플리케이션" name_placeholder = "예: 멋진 애플리케이션"
title = "애플리케이션 정보" title = "애플리케이션 정보"

View File

@@ -377,6 +377,10 @@ empty = ""
[msg.dev.clients.general.identity] [msg.dev.clients.general.identity]
logo_help = "" logo_help = ""
logo_invalid = ""
logo_preview_loading = ""
logo_preview_ready = ""
logo_preview_failed = ""
subtitle = "" subtitle = ""
[msg.dev.clients.general.redirect] [msg.dev.clients.general.redirect]
@@ -1378,6 +1382,9 @@ description_placeholder = ""
logo = "" logo = ""
logo_placeholder = "" logo_placeholder = ""
logo_preview = "" logo_preview = ""
logo_open = ""
logo_preview_error_badge = ""
logo_preview_empty = ""
name = "" name = ""
name_placeholder = "" name_placeholder = ""
title = "" title = ""
@@ -1545,6 +1552,7 @@ ory = ""
session = "" session = ""
[ui.userfront.dashboard] [ui.userfront.dashboard]
link_status_label = ""
last_auth_label = "" last_auth_label = ""
status_history = "" status_history = ""

View File

@@ -147,6 +147,7 @@ test.describe("DevFront role report", () => {
); );
await page.getByRole("button", { name: /앱 생성|Create/i }).click(); await page.getByRole("button", { name: /앱 생성|Create/i }).click();
await createPromise; await createPromise;
await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/);
await expect await expect
.poll(() => .poll(() =>
state.auditLogs.some((item) => { state.auditLogs.some((item) => {

View File

@@ -125,6 +125,7 @@ export async function seedAuth(page: Page, role?: string) {
"oidc.user:http://localhost:5000/oidc/:devfront", "oidc.user:http://localhost:5000/oidc/:devfront",
JSON.stringify(mockOidcUser), JSON.stringify(mockOidcUser),
); );
window.localStorage.setItem("dev_role", injectedRole || "rp_admin");
window.localStorage.setItem("dev_tenant_id", "tenant-a"); window.localStorage.setItem("dev_tenant_id", "tenant-a");
}, },
{ issuedAt: nowInSeconds, injectedRole: role ?? "" }, { 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) => { await page.route("**/api/v1/user/me", async (route) => {
const storedRole =
(await page.evaluate(() => window.localStorage.getItem("dev_role"))) ??
"rp_admin";
return json(route, { return json(route, {
id: "playwright-user", id: "playwright-user",
loginId: "playwright@example.com", loginId: "playwright@example.com",
@@ -206,7 +210,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
department: "QA", department: "QA",
tenantId: "tenant-a", tenantId: "tenant-a",
tenantName: "Tenant A", tenantName: "Tenant A",
role: "rp_admin", role: storedRole,
createdAt: "2026-03-03T00:00:00.000Z", createdAt: "2026-03-03T00:00:00.000Z",
updatedAt: "2026-03-03T00:00:00.000Z", updatedAt: "2026-03-03T00:00:00.000Z",
}); });

View File

@@ -4,7 +4,7 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
host: "0.0.0.0", host: "127.0.0.1",
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"], allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
proxy: { proxy: {
"/api": { "/api": {
@@ -14,7 +14,7 @@ export default defineConfig({
}, },
}, },
preview: { preview: {
host: "0.0.0.0", host: "127.0.0.1",
port: 5173, port: 5173,
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"], allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
proxy: { proxy: {

View File

@@ -551,6 +551,7 @@ client_id = "Client ID: {{id}}"
client_id_missing = "No client ID available." client_id_missing = "No client ID available."
current_status = "Current status: {{status}}" current_status = "Current status: {{status}}"
last_auth = "Last signed in: {{value}}" last_auth = "Last signed in: {{value}}"
link_status = "Link status: {{status}}"
link_missing = "This app does not have a launch URL configured." link_missing = "This app does not have a launch URL configured."
link_open_error = "Could not open the app link." link_open_error = "Could not open the app link."
render_error = "Dashboard render error: {{error}}" render_error = "Dashboard render error: {{error}}"
@@ -2084,7 +2085,8 @@ title = "Cancel consent"
[ui.userfront.dashboard] [ui.userfront.dashboard]
last_auth_label = "Last sign-in" last_auth_label = "Last sign-in"
status_history = "Activity history" link_status_label = "Link status"
status_history = "Link details"
[ui.userfront.dashboard.activity] [ui.userfront.dashboard.activity]
linked = "Linked" linked = "Linked"
@@ -2109,7 +2111,7 @@ confirm_button = "Disconnect"
title = "Disconnect app" title = "Disconnect app"
[ui.userfront.dashboard.scopes] [ui.userfront.dashboard.scopes]
title = "Permission (Scopes)" title = "Consent scopes"
[ui.userfront.dashboard.status] [ui.userfront.dashboard.status]
revoked = "Revoked" revoked = "Revoked"

View File

@@ -192,6 +192,7 @@ client_id = "Client ID: {{id}}"
client_id_missing = "Client ID 없음" client_id_missing = "Client ID 없음"
current_status = "현재 상태: {{status}}" current_status = "현재 상태: {{status}}"
last_auth = "최근 인증: {{value}}" last_auth = "최근 인증: {{value}}"
link_status = "연동 상태: {{status}}"
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
link_open_error = "해당 링크를 열 수 없습니다." link_open_error = "해당 링크를 열 수 없습니다."
render_error = "대시보드 렌더링 오류: {{error}}" render_error = "대시보드 렌더링 오류: {{error}}"
@@ -402,7 +403,8 @@ session = "세션"
[ui.userfront.dashboard] [ui.userfront.dashboard]
last_auth_label = "최근 인증" last_auth_label = "최근 인증"
status_history = "상태 이력" link_status_label = "연동 상태"
status_history = "연동 정보"
[ui.userfront.device] [ui.userfront.device]
android = "Mobile(Android)" android = "Mobile(Android)"
@@ -2505,7 +2507,7 @@ confirm_button = "해지하기"
title = "연동 해지" title = "연동 해지"
[ui.userfront.dashboard.scopes] [ui.userfront.dashboard.scopes]
title = "권한 (Scopes)" title = "동의 범위"
[ui.userfront.dashboard.status] [ui.userfront.dashboard.status]
revoked = "해지됨" revoked = "해지됨"

View File

@@ -277,6 +277,7 @@ ory = ""
session = "" session = ""
[ui.userfront.dashboard] [ui.userfront.dashboard]
link_status_label = ""
last_auth_label = "" last_auth_label = ""
status_history = "" status_history = ""

View File

@@ -13,6 +13,11 @@ else
playwright_install_desc="npx playwright install" playwright_install_desc="npx playwright install"
fi 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 set +e
( (
cd adminfront cd adminfront
@@ -44,7 +49,13 @@ fi
set +e set +e
( (
cd adminfront cd adminfront
if [ -f "$playwright_chromium_complete" ] && \
[ -f "$playwright_firefox_complete" ] && \
[ -f "$playwright_webkit_complete" ]; then
echo "Playwright browsers already installed"
else
"${playwright_install_cmd[@]}" "${playwright_install_cmd[@]}"
fi
) 2>&1 | tee reports/adminfront-provision.log ) 2>&1 | tee reports/adminfront-provision.log
provision_exit_code=${PIPESTATUS[0]} provision_exit_code=${PIPESTATUS[0]}
set -e set -e

View File

@@ -141,6 +141,25 @@ function collectCodeKeys() {
return keys; 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) { function difference(aSet, bSet) {
const result = []; const result = [];
for (const item of aSet) { for (const item of aSet) {
@@ -170,7 +189,7 @@ function buildReport() {
} }
const templateKeys = templateResult.keys; const templateKeys = templateResult.keys;
const codeKeys = collectCodeKeys(); const codeKeys = new Set(filterCodeKeys(collectCodeKeys()));
const langKeyMap = new Map(); const langKeyMap = new Map();
for (const fileName of LANG_FILES) { for (const fileName of LANG_FILES) {

View File

@@ -86,6 +86,7 @@ client_id = "Client ID: {id}"
client_id_missing = "No client ID available." client_id_missing = "No client ID available."
current_status = "Current status: {status}" current_status = "Current status: {status}"
last_auth = "Last signed in: {value}" last_auth = "Last signed in: {value}"
link_status = "Link status: {status}"
link_missing = "This app does not have a launch URL configured." link_missing = "This app does not have a launch URL configured."
link_open_error = "Could not open the app link." link_open_error = "Could not open the app link."
render_error = "Dashboard render error: {error}" render_error = "Dashboard render error: {error}"
@@ -464,7 +465,8 @@ title = "Cancel consent"
[ui.userfront.dashboard] [ui.userfront.dashboard]
last_auth_label = "Last sign-in" last_auth_label = "Last sign-in"
status_history = "Activity history" link_status_label = "Link status"
status_history = "Link details"
[ui.userfront.dashboard.activity] [ui.userfront.dashboard.activity]
linked = "Linked" linked = "Linked"
@@ -489,7 +491,7 @@ confirm_button = "Disconnect"
title = "Disconnect app" title = "Disconnect app"
[ui.userfront.dashboard.scopes] [ui.userfront.dashboard.scopes]
title = "Permission (Scopes)" title = "Consent scopes"
[ui.userfront.dashboard.status] [ui.userfront.dashboard.status]
revoked = "Revoked" revoked = "Revoked"

View File

@@ -62,6 +62,7 @@ client_id = "Client ID: {id}"
client_id_missing = "Client ID 없음" client_id_missing = "Client ID 없음"
current_status = "현재 상태: {status}" current_status = "현재 상태: {status}"
last_auth = "최근 인증: {value}" last_auth = "최근 인증: {value}"
link_status = "연동 상태: {status}"
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
link_open_error = "해당 링크를 열 수 없습니다." link_open_error = "해당 링크를 열 수 없습니다."
render_error = "대시보드 렌더링 오류: {error}" render_error = "대시보드 렌더링 오류: {error}"
@@ -176,7 +177,8 @@ session = "세션"
[ui.userfront.dashboard] [ui.userfront.dashboard]
last_auth_label = "최근 인증" last_auth_label = "최근 인증"
status_history = "상태 이력" link_status_label = "연동 상태"
status_history = "연동 정보"
[ui.userfront.device] [ui.userfront.device]
android = "Mobile(Android)" android = "Mobile(Android)"
@@ -694,7 +696,7 @@ confirm_button = "해지하기"
title = "연동 해지" title = "연동 해지"
[ui.userfront.dashboard.scopes] [ui.userfront.dashboard.scopes]
title = "권한 (Scopes)" title = "동의 범위"
[ui.userfront.dashboard.status] [ui.userfront.dashboard.status]
revoked = "해지됨" revoked = "해지됨"

View File

@@ -148,6 +148,7 @@ ory = ""
session = "" session = ""
[ui.userfront.dashboard] [ui.userfront.dashboard]
link_status_label = ""
last_auth_label = "" last_auth_label = ""
status_history = "" status_history = ""

View File

@@ -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<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return child;
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeController extends ValueNotifier<ThemeMode> {
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<void> restore() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(storageKey);
value = stored == 'dark' ? ThemeMode.dark : ThemeMode.light;
}
Future<void> setThemeMode(ThemeMode mode) async {
if (value != mode) {
value = mode;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
storageKey,
mode == ThemeMode.dark ? 'dark' : 'light',
);
}
Future<void> toggle() {
return setThemeMode(isDark ? ThemeMode.light : ThemeMode.dark);
}
}

View File

@@ -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<ThemeScope>();
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<ThemeMode>(
valueListenable: controller,
builder: (context, mode, _) {
return Theme(
data: mode == ThemeMode.dark ? buildDarkTheme() : buildLightTheme(),
child: child,
);
},
),
);
}
}

View File

@@ -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<ThemeMode>(
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),
);
},
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/constants/error_whitelist.dart'; import '../../../core/constants/error_whitelist.dart';
import '../../../core/i18n/locale_utils.dart'; import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
import '../../../core/widgets/theme_toggle_button.dart';
import 'package:userfront/i18n.dart'; import 'package:userfront/i18n.dart';
class ErrorScreen extends StatelessWidget { class ErrorScreen extends StatelessWidget {
@@ -22,6 +23,7 @@ class ErrorScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isProd = isProdOverride ?? AuthProxyService.isProdEnv; final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (errorCode ?? '').trim(); final normalizedCode = (errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty; final hasCode = normalizedCode.isNotEmpty;
@@ -62,7 +64,7 @@ class ErrorScreen extends StatelessWidget {
: tr('msg.userfront.error.detail_request'))); : tr('msg.userfront.error.detail_request')));
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F8FA), backgroundColor: colorScheme.surfaceContainerLowest,
body: Center( body: Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
@@ -71,7 +73,7 @@ class ErrorScreen extends StatelessWidget {
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFFE5E7EB)), side: BorderSide(color: colorScheme.outlineVariant),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
@@ -79,18 +81,25 @@ class ErrorScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
children: [
Expanded(
child: Text(
title, title,
style: theme.textTheme.titleLarge?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: const Color(0xFF111827), color: colorScheme.onSurface,
), ),
), ),
),
const ThemeToggleButton(compact: true),
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
detail, detail,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: const Color(0xFF4B5563), color: colorScheme.onSurfaceVariant,
height: 1.5, height: 1.5,
), ),
), ),
@@ -98,7 +107,7 @@ class ErrorScreen extends StatelessWidget {
Text( Text(
tr('msg.userfront.error.type', params: {'type': errorType}), tr('msg.userfront.error.type', params: {'type': errorType}),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280), color: colorScheme.onSurfaceVariant,
), ),
), ),
if (errorId != null && errorId!.isNotEmpty) ...[ if (errorId != null && errorId!.isNotEmpty) ...[
@@ -106,7 +115,7 @@ class ErrorScreen extends StatelessWidget {
Text( Text(
tr('msg.userfront.error.id', params: {'id': errorId!}), tr('msg.userfront.error.id', params: {'id': errorId!}),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280), color: colorScheme.onSurfaceVariant,
), ),
), ),
], ],
@@ -118,8 +127,8 @@ class ErrorScreen extends StatelessWidget {
ElevatedButton( ElevatedButton(
onPressed: () => context.go('/login'), onPressed: () => context.go('/login'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF111827), backgroundColor: colorScheme.primary,
foregroundColor: Colors.white, foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 12, vertical: 12,
@@ -134,12 +143,12 @@ class ErrorScreen extends StatelessWidget {
onPressed: () => onPressed: () =>
context.go(buildLocalizedHomePath(Uri.base)), context.go(buildLocalizedHomePath(Uri.base)),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF111827), foregroundColor: colorScheme.onSurface,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 12, vertical: 12,
), ),
side: const BorderSide(color: Color(0xFFCBD5F5)), side: BorderSide(color: colorScheme.outline),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),

View File

@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:userfront/i18n.dart'; import 'package:userfront/i18n.dart';
import '../../../core/widgets/language_selector.dart'; import '../../../core/widgets/language_selector.dart';
import '../../../core/widgets/theme_toggle_button.dart';
import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/web_auth_integration.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/auth_token_store.dart'; import '../../../core/services/auth_token_store.dart';
@@ -1385,6 +1386,77 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
@override @override
Widget build(BuildContext context) { 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) { if (_verificationOnly && _verificationApproved) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -1393,44 +1465,54 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), onPressed: () => context.go(buildLocalizedHomePath(Uri.base)),
), ),
actions: const [ThemeToggleButton(compact: true)],
), ),
body: _buildVerificationResultView(), body: _buildVerificationResultView(),
); );
} }
return Scaffold( return Scaffold(
backgroundColor: colorScheme.surfaceContainerLowest,
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return SingleChildScrollView( return Theme(
data: localTheme,
child: SingleChildScrollView(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight), constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center( child: Center(
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 480),
padding: const EdgeInsets.all(24), padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 40,
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
tr('ui.userfront.app_title'), tr('ui.userfront.app_title'),
style: const TextStyle( style: theme.textTheme.headlineMedium?.copyWith(
fontSize: 32, fontSize: 34,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
letterSpacing: -0.7,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (_drySendEnabled) ...[ if (_drySendEnabled) ...[
const SizedBox(height: 16), const SizedBox(height: 20),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 14,
vertical: 10, vertical: 12,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFFF3CD), color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFFC107)), border: Border.all(
color: const Color(0xFFFFC107),
),
), ),
child: Row( child: Row(
children: [ children: [
@@ -1438,7 +1520,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Icons.warning_amber_rounded, Icons.warning_amber_rounded,
color: Color(0xFF8A6D3B), color: Color(0xFF8A6D3B),
), ),
const SizedBox(width: 8), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
tr('msg.userfront.login.dry_send'), tr('msg.userfront.login.dry_send'),
@@ -1452,46 +1534,56 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
), ),
], ],
const SizedBox(height: 40), const SizedBox(height: 52),
Padding(
TabBar( padding: const EdgeInsets.symmetric(horizontal: 34),
child: TabBar(
controller: _tabController, controller: _tabController,
indicatorSize: TabBarIndicatorSize.label,
tabs: [ tabs: [
Tab(text: tr('ui.userfront.login.tabs.password')), Tab(text: tr('ui.userfront.login.tabs.password')),
Tab(text: tr('ui.userfront.login.tabs.link')), Tab(text: tr('ui.userfront.login.tabs.link')),
Tab(text: tr('ui.userfront.login.tabs.qr')), Tab(text: tr('ui.userfront.login.tabs.qr')),
], ],
), ),
const SizedBox(height: 24), ),
const SizedBox(height: 28),
SizedBox( SizedBox(
height: 350, height: 360,
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 20),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 356,
),
child: Column( child: Column(
children: [ children: [
TextField( TextField(
key: const ValueKey( key: const ValueKey(
'password_login_id_input', 'password_login_id_input',
), ),
controller: _passwordLoginIdController, controller:
_passwordLoginIdController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: labelText:
_loginIdLabel ?? _loginIdLabel ??
tr( tr(
'ui.userfront.login.field.login_id', 'ui.userfront.login.field.login_id',
), ),
border: const OutlineInputBorder(),
prefixIcon: const Icon( prefixIcon: const Icon(
Icons.person_outline, Icons.person_outline,
size: 22,
), ),
), ),
onSubmitted: (_) => _handlePasswordLogin(), onSubmitted: (_) =>
_handlePasswordLogin(),
), ),
const SizedBox(height: 16), const SizedBox(height: 18),
TextField( TextField(
key: const ValueKey( key: const ValueKey(
'password_login_password_input', 'password_login_password_input',
@@ -1503,15 +1595,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
labelText: tr( labelText: tr(
'ui.userfront.login.field.password', 'ui.userfront.login.field.password',
), ),
border: const OutlineInputBorder(),
prefixIcon: const Icon( prefixIcon: const Icon(
Icons.lock_outline, Icons.lock_outline,
size: 22,
), ),
), ),
onSubmitted: (_) => _handlePasswordLogin(), onSubmitted: (_) =>
_handlePasswordLogin(),
), ),
if (_isPasswordCapsLockOn) ...[ if (_isPasswordCapsLockOn) ...[
const SizedBox(height: 8), const SizedBox(height: 10),
Row( Row(
children: [ children: [
const Icon( const Icon(
@@ -1533,25 +1626,31 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
], ],
), ),
], ],
const SizedBox(height: 24), const SizedBox(height: 28),
FilledButton( FilledButton(
key: const ValueKey( key: const ValueKey(
'password_login_submit_button', 'password_login_submit_button',
), ),
onPressed: _handlePasswordLogin, onPressed: _handlePasswordLogin,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: Text( child: Text(
tr('ui.userfront.login.action.submit'), tr(
'ui.userfront.login.action.submit',
),
), ),
), ),
], ],
), ),
), ),
),
),
Padding( Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 20),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 356,
),
child: Column( child: Column(
children: [ children: [
if (_linkPendingRef == null) ...[ if (_linkPendingRef == null) ...[
@@ -1562,29 +1661,32 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'ui.userfront.login.field.login_id', 'ui.userfront.login.field.login_id',
), ),
hintText: '', hintText: '',
border: const OutlineInputBorder(),
prefixIcon: const Icon( prefixIcon: const Icon(
Icons.person_outline, Icons.person_outline,
size: 22,
), ),
), ),
onSubmitted: (_) => _handleLinkLogin(), onSubmitted: (_) =>
_handleLinkLogin(),
), ),
const SizedBox(height: 24), const SizedBox(height: 28),
FilledButton( FilledButton(
onPressed: _handleLinkLogin, onPressed: _handleLinkLogin,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: Text( child: Text(
tr('ui.userfront.login.link.send'), tr(
'ui.userfront.login.link.send',
),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
tr('msg.userfront.login.link.helper'), tr(
style: const TextStyle( 'msg.userfront.login.link.helper',
color: Colors.grey, ),
style: TextStyle(
color: mutedColor,
fontSize: 12, fontSize: 12,
height: 1.5,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -1592,37 +1694,37 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_linkPendingRef != null) ...[ if (_linkPendingRef != null) ...[
if (_linkExpired) ...[ if (_linkExpired) ...[
Text( Text(
tr('msg.userfront.login.link_timeout'), tr(
'msg.userfront.login.link_timeout',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: TextStyle(
color: Colors.grey, color: mutedColor,
fontSize: 12, fontSize: 12,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
setState(_resetLinkLoginState); setState(_resetLinkLoginState);
}, },
style: FilledButton.styleFrom( child: Text(
minimumSize: const Size.fromHeight( tr('ui.common.refresh'),
45,
), ),
), ),
child: Text(tr('ui.common.refresh')),
),
] else ...[ ] else ...[
Text( Text(
tr( tr(
'msg.userfront.login.link.short_code_help', 'msg.userfront.login.link.short_code_help',
), ),
style: const TextStyle( style: TextStyle(
color: Colors.grey, color: mutedColor,
fontSize: 12, fontSize: 12,
height: 1.5,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -1631,22 +1733,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
controller: controller:
_shortCodePrefixController, _shortCodePrefixController,
textCapitalization: textCapitalization:
TextCapitalization.characters, TextCapitalization
.characters,
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr(
'ui.userfront.login.short_code.prefix', 'ui.userfront.login.short_code.prefix',
), ),
border:
const OutlineInputBorder(),
hintText: 'AB', hintText: 'AB',
hintStyle: const TextStyle( hintStyle: TextStyle(
color: Colors.grey, color: mutedColor,
), ),
counterText: '',
), ),
maxLength: 2, maxLength: 2,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 10),
Expanded( Expanded(
flex: 4, flex: 4,
child: TextField( child: TextField(
@@ -1658,12 +1760,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
labelText: tr( labelText: tr(
'ui.userfront.login.short_code.digits', 'ui.userfront.login.short_code.digits',
), ),
border:
const OutlineInputBorder(),
hintText: '345678', hintText: '345678',
hintStyle: const TextStyle( hintStyle: TextStyle(
color: Colors.grey, color: mutedColor,
), ),
counterText: '',
suffixText: suffixText:
_linkExpireSeconds > 0 _linkExpireSeconds > 0
? tr( ? tr(
@@ -1681,15 +1782,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
final prefix = final prefix =
_shortCodePrefixController.text _shortCodePrefixController
.text
.trim() .trim()
.toUpperCase(); .toUpperCase();
final digits = final digits =
_shortCodeDigitsController.text _shortCodeDigitsController
.text
.trim(); .trim();
if (prefix.length != 2 || if (prefix.length != 2 ||
digits.length != 6) { digits.length != 6) {
@@ -1700,20 +1803,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
); );
return; return;
} }
_verifyShortCode(prefix + digits); _verifyShortCode(
prefix + digits,
);
}, },
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(
45,
),
),
child: Text( child: Text(
tr( tr(
'ui.userfront.login.short_code.submit', 'ui.userfront.login.short_code.submit',
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
TextButton( TextButton(
onPressed: () { onPressed: () {
if (_linkResendSeconds > 0) { if (_linkResendSeconds > 0) {
@@ -1731,7 +1831,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
final loginId = final loginId =
_lastLinkLoginId ?? _lastLinkLoginId ??
_linkIdController.text.trim(); _linkIdController.text
.trim();
if (loginId.isEmpty) { if (loginId.isEmpty) {
_showError( _showError(
tr( tr(
@@ -1780,7 +1881,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
} }
final loginId = final loginId =
_lastLinkLoginId ?? _lastLinkLoginId ??
_linkIdController.text.trim(); _linkIdController.text
.trim();
if (loginId.isEmpty) { if (loginId.isEmpty) {
_showError( _showError(
tr( tr(
@@ -1812,7 +1914,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
], ],
), ),
), ),
),
),
Column( Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -1825,19 +1928,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Text( Text(
tr('msg.userfront.login.qr_expired'), tr('msg.userfront.login.qr_expired'),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: TextStyle(
color: Colors.grey, color: mutedColor,
fontSize: 12, fontSize: 12,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
FilledButton( FilledButton(
onPressed: _startQrFlow, onPressed: _startQrFlow,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(
45,
),
),
child: Text(tr('ui.common.refresh')), child: Text(tr('ui.common.refresh')),
), ),
], ],
@@ -1848,22 +1946,23 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
CrossAxisAlignment.center, CrossAxisAlignment.center,
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(18),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: Colors.grey.shade300, color: colorScheme.outline,
), ),
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
12, 18,
), ),
), ),
child: QrImageView( child: QrImageView(
data: _qrImageBase64!, data: _qrImageBase64!,
version: QrVersions.auto, version: QrVersions.auto,
size: 200.0, size: 200.0,
backgroundColor: Colors.white,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
Text( Text(
_qrRemainingSeconds > 0 _qrRemainingSeconds > 0
? tr( ? tr(
@@ -1880,18 +1979,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: _qrRemainingSeconds > 30 color: _qrRemainingSeconds > 30
? Colors.blue ? primaryColor
: Colors.red, : Colors.red,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
tr('msg.userfront.login.qr.scan_hint'), tr(
'msg.userfront.login.qr.scan_hint',
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: TextStyle(
color: Colors.grey, color: mutedColor,
fontSize: 12, fontSize: 12,
height: 1.5,
), ),
), ),
TextButton( TextButton(
@@ -1912,7 +2014,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
], ],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 18),
Column( Column(
children: [ children: [
TextButton( TextButton(
@@ -1926,8 +2028,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
children: [ children: [
Text( Text(
tr('msg.userfront.login.no_account'), tr('msg.userfront.login.no_account'),
style: const TextStyle( style: TextStyle(
color: Colors.grey, color: mutedColor,
fontSize: 14, fontSize: 14,
), ),
), ),
@@ -1939,16 +2041,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 12),
const Align( const Wrap(
alignment: Alignment.center, alignment: WrapAlignment.center,
child: LanguageSelector(), spacing: 10,
runSpacing: 10,
children: [ThemeToggleButton(), LanguageSelector()],
), ),
], ],
), ),
), ),
), ),
), ),
),
); );
}, },
), ),

View File

@@ -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;
}

View File

@@ -96,6 +96,7 @@ class LinkedRp {
final String name; final String name;
final String logo; final String logo;
final String url; final String url;
final String initUrl;
final String status; final String status;
final List<String> scopes; final List<String> scopes;
final DateTime? lastAuthenticatedAt; final DateTime? lastAuthenticatedAt;
@@ -105,6 +106,7 @@ class LinkedRp {
required this.name, required this.name,
required this.logo, required this.logo,
required this.url, required this.url,
required this.initUrl,
required this.status, required this.status,
required this.scopes, required this.scopes,
this.lastAuthenticatedAt, this.lastAuthenticatedAt,
@@ -126,6 +128,7 @@ class LinkedRp {
name: json['name']?.toString() ?? '', name: json['name']?.toString() ?? '',
logo: json['logo']?.toString() ?? '', logo: json['logo']?.toString() ?? '',
url: json['url']?.toString() ?? '', url: json['url']?.toString() ?? '',
initUrl: json['init_url']?.toString() ?? '',
status: json['status']?.toString() ?? '', status: json['status']?.toString() ?? '',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [], scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastAuthenticatedAt: parsedLastAuth, lastAuthenticatedAt: parsedLastAuth,

View File

@@ -10,6 +10,7 @@ class LinkedRp {
final String name; final String name;
final String logo; final String logo;
final String url; final String url;
final String initUrl;
final String status; final String status;
final List<String> scopes; final List<String> scopes;
final DateTime? lastAuthenticatedAt; final DateTime? lastAuthenticatedAt;
@@ -19,6 +20,7 @@ class LinkedRp {
required this.name, required this.name,
required this.logo, required this.logo,
required this.url, required this.url,
required this.initUrl,
required this.status, required this.status,
required this.scopes, required this.scopes,
required this.lastAuthenticatedAt, required this.lastAuthenticatedAt,
@@ -40,6 +42,7 @@ class LinkedRp {
name: json['name']?.toString() ?? '', name: json['name']?.toString() ?? '',
logo: json['logo']?.toString() ?? '', logo: json['logo']?.toString() ?? '',
url: json['url']?.toString() ?? '', url: json['url']?.toString() ?? '',
initUrl: json['init_url']?.toString() ?? '',
status: json['status']?.toString() ?? 'unknown', status: json['status']?.toString() ?? 'unknown',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [], scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastAuthenticatedAt: parsedLastAuth, lastAuthenticatedAt: parsedLastAuth,

View File

@@ -2,11 +2,13 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../domain/linked_rp_launch.dart';
import '../domain/session_time_resolver.dart'; import '../domain/session_time_resolver.dart';
import '../domain/providers/linked_rps_provider.dart'; import '../domain/providers/linked_rps_provider.dart';
import '../domain/providers/user_sessions_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/services/http_client.dart';
import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/widgets/language_selector.dart'; import '../../../../core/widgets/language_selector.dart';
import '../../../../core/widgets/theme_toggle_button.dart';
import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart'; import '../../../../core/ui/toast_service.dart';
import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart';
@@ -32,11 +35,9 @@ class DashboardScreen extends ConsumerStatefulWidget {
} }
class _DashboardScreenState extends ConsumerState<DashboardScreen> { class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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 _dashboardCardSpacing = 12;
static const double _dashboardCardMaxWidth = 228;
static const double _activityDialogMaxWidth = 360;
static const double _historySessionMinWidth = 92; static const double _historySessionMinWidth = 92;
static const double _historyOtherColumnsBaselineWidth = 780; static const double _historyOtherColumnsBaselineWidth = 780;
static const int _historySessionMinVisibleChars = 8; static const int _historySessionMinVisibleChars = 8;
@@ -63,8 +64,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
bool _showAllActivities = false; bool _showAllActivities = false;
bool _showActiveSessionsOnly = false; bool _showActiveSessionsOnly = false;
bool _isDesktopSideMenuOpen = true;
final Set<String> _revokedClientIds = {}; final Set<String> _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 _renderTranslatedText(
String key, { String key, {
String? fallback, String? fallback,
@@ -234,90 +241,230 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
context: context, context: context,
builder: (context) => Consumer( builder: (context) => Consumer(
builder: (context, ref, _) { 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( 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( content: SizedBox(
width: double.maxFinite, width: dialogWidth,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Container(
tr('ui.userfront.dashboard.scopes.title'), width: double.infinity,
style: const TextStyle(fontWeight: FontWeight.bold), padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: _border),
), ),
const SizedBox(height: 8), child: Column(
if (item.scopes.isEmpty) crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( 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: 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'), tr('msg.userfront.dashboard.scopes.empty'),
style: const TextStyle(color: Colors.grey), style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
) )
else : Wrap(
Wrap(
spacing: 8, spacing: 8,
runSpacing: 4, runSpacing: 8,
children: item.scopes children: item.scopes
.map( .map(
(s) => Chip( (scope) => Container(
label: Text( padding: const EdgeInsets.symmetric(
s, horizontal: 10,
style: const TextStyle(fontSize: 12), 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,
),
),
],
), ),
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
), ),
) )
.toList(), .toList(),
), ),
const SizedBox(height: 24),
Text(
tr('ui.userfront.dashboard.status_history'),
style: const TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), ],
Column( ),
),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
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),
),
),
),
],
);
},
),
);
}
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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
tr( title,
'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( style: TextStyle(
color: item.status == 'active' fontSize: 13,
? Colors.green fontWeight: FontWeight.w700,
: Colors.grey, 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,
),
), ),
], ],
), ),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(tr('ui.common.close')),
),
],
);
},
),
); );
} }
@@ -376,7 +523,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
const Padding( const Padding(
padding: EdgeInsets.only(bottom: 16), 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<DashboardScreen> {
return Scaffold( return Scaffold(
backgroundColor: _subtle, backgroundColor: _subtle,
appBar: AppBar( 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( title: Text(
tr('ui.userfront.app_title'), tr('ui.userfront.app_title'),
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
elevation: 0,
backgroundColor: _surface,
foregroundColor: Colors.black,
actions: [ actions: [
const ThemeToggleButton(compact: true),
IconButton( IconButton(
icon: const Icon(Icons.person_outline), icon: const Icon(Icons.person_outline),
tooltip: tr('ui.userfront.nav.profile'), tooltip: tr('ui.userfront.nav.profile'),
@@ -829,7 +1004,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
: Drawer(child: _buildSideMenu(context, closeOnTap: true)), : Drawer(child: _buildSideMenu(context, closeOnTap: true)),
body: Row( body: Row(
children: [ children: [
if (isWide) if (isWide && _isDesktopSideMenuOpen)
SizedBox( SizedBox(
width: 240, width: 240,
child: _buildSideMenu(context, closeOnTap: false), child: _buildSideMenu(context, closeOnTap: false),
@@ -922,7 +1097,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
fallback: 'Hello, {{name}}.', fallback: 'Hello, {{name}}.',
values: {'name': userName}, values: {'name': userName},
), ),
style: const TextStyle( style: TextStyle(
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _ink, color: _ink,
@@ -974,7 +1149,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [ children: [
Text( Text(
title, title,
style: const TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: _ink, color: _ink,
@@ -1128,7 +1303,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
label, label,
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: _ink, color: _ink,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -1210,12 +1385,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
_ActivityItem( _ActivityItem(
clientId: rp.id, clientId: rp.id,
appName: name, appName: name,
logo: rp.logo.trim(),
lastAuthAt: lastAuthLabel, lastAuthAt: lastAuthLabel,
status: statusCode, status: statusCode,
scopes: rp.scopes, scopes: rp.scopes,
isRevoked: isRevoked, isRevoked: isRevoked,
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
url: rp.url, url: rp.url,
launchUrl: resolveLinkedRpLaunchUrl(rp),
lastAuthDateTime: rp.lastAuthenticatedAt, lastAuthDateTime: rp.lastAuthenticatedAt,
), ),
); );
@@ -1317,7 +1494,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) { Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
final isActive = item.status == 'active'; final isActive = item.status == 'active';
final statusColor = isActive ? Colors.green : Colors.grey; final statusColor = _activityStatusColor(item.status);
final borderColor = isActive final borderColor = isActive
? Colors.green.withValues(alpha: 128) ? Colors.green.withValues(alpha: 128)
: _border; : _border;
@@ -1329,10 +1506,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
// 카드 컨텐츠 // 카드 컨텐츠
final cardContent = Container( final cardContent = Container(
width: cardWidth ?? 260, width: cardWidth ?? 260,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _surface, color: _surface,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor, width: borderWidth), border: Border.all(color: borderColor, width: borderWidth),
boxShadow: isActive boxShadow: isActive
? [ ? [
@@ -1347,38 +1524,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( _buildActivityCardHeader(item, statusColor),
children: [ const SizedBox(height: 10),
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),
Text( Text(
tr('ui.userfront.dashboard.last_auth_label'), tr('ui.userfront.dashboard.last_auth_label'),
style: TextStyle(fontSize: 12, color: Colors.grey[600]), style: TextStyle(fontSize: 12, color: Colors.grey[600]),
@@ -1386,13 +1533,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
item.lastAuthAt, item.lastAuthAt,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _ink, color: _ink,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 14),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -1400,8 +1547,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
onPressed: () => _showRpDetails(item), onPressed: () => _showRpDetails(item),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: _ink, foregroundColor: _ink,
side: const BorderSide(color: _border), side: BorderSide(color: _border),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 7),
), ),
child: Text( child: Text(
tr('ui.common.details'), tr('ui.common.details'),
@@ -1423,7 +1570,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
color: item.isRevoked ? Colors.grey : Colors.redAccent, color: item.isRevoked ? Colors.grey : Colors.redAccent,
width: 0.5, width: 0.5,
), ),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 7),
), ),
child: _isRevoking && !item.isRevoked child: _isRevoking && !item.isRevoked
? const SizedBox( ? const SizedBox(
@@ -1460,7 +1607,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
onTap: () async { onTap: () async {
final itemUrl = item.url; final itemUrl = item.launchUrl;
if (itemUrl != null && itemUrl.isNotEmpty) { if (itemUrl != null && itemUrl.isNotEmpty) {
final uri = Uri.parse(itemUrl); final uri = Uri.parse(itemUrl);
final canOpen = await canLaunchUrl(uri); final canOpen = await canLaunchUrl(uri);
@@ -1483,6 +1630,115 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return opaqueCard; 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) { Widget _buildAccessHistory(AuthTimelineState state, bool isWide) {
final sessionsState = ref.watch(userSessionsProvider); final sessionsState = ref.watch(userSessionsProvider);
if (state.isLoading && state.items.isEmpty) { if (state.isLoading && state.items.isEmpty) {
@@ -1601,7 +1857,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [ children: [
Text( Text(
tr('ui.userfront.audit.filter.title'), tr('ui.userfront.audit.filter.title'),
style: const TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: _ink, color: _ink,
@@ -1621,7 +1877,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [ children: [
Text( Text(
tr('ui.userfront.audit.filter.toggle_label'), tr('ui.userfront.audit.filter.toggle_label'),
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _ink, color: _ink,
@@ -1781,8 +2037,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
} }
double _dashboardCardWidth(double maxWidth, int crossAxisCount) { double _dashboardCardWidth(double maxWidth, int crossAxisCount) {
return (maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) / return math.min(
crossAxisCount; (maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) /
crossAxisCount,
_dashboardCardMaxWidth,
);
}
Color _activityStatusColor(String status) {
return status == 'active' ? Colors.green : Colors.grey;
} }
Widget _buildCenteredHistoryHeader(String label, {double? width}) { Widget _buildCenteredHistoryHeader(String label, {double? width}) {
@@ -2073,7 +2336,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Expanded( Expanded(
child: _buildAppCell( child: _buildAppCell(
log, log,
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _ink, color: _ink,
), ),
@@ -2288,9 +2551,11 @@ enum _HistorySessionStatus { current, active, inactive }
class _ActivityItem { class _ActivityItem {
final String clientId; final String clientId;
final String appName; final String appName;
final String logo;
final String lastAuthAt; final String lastAuthAt;
final String status; final String status;
final String? url; final String? url;
final String? launchUrl;
final List<String> scopes; final List<String> scopes;
final bool isRevoked; final bool isRevoked;
final VoidCallback? onRevoke; final VoidCallback? onRevoke;
@@ -2299,10 +2564,12 @@ class _ActivityItem {
_ActivityItem({ _ActivityItem({
required this.clientId, required this.clientId,
required this.appName, required this.appName,
required this.logo,
required this.lastAuthAt, required this.lastAuthAt,
required this.status, required this.status,
required this.scopes, required this.scopes,
this.url, this.url,
this.launchUrl,
this.isRevoked = false, this.isRevoked = false,
this.onRevoke, this.onRevoke,
this.lastAuthDateTime, this.lastAuthDateTime,

View File

@@ -9,6 +9,7 @@ import '../../../../core/services/logout_service.dart';
import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart'; import '../../../../core/ui/toast_service.dart';
import '../../../../core/widgets/language_selector.dart'; import '../../../../core/widgets/language_selector.dart';
import '../../../../core/widgets/theme_toggle_button.dart';
import '../../data/models/user_profile_model.dart'; import '../../data/models/user_profile_model.dart';
import '../../domain/notifiers/profile_notifier.dart'; import '../../domain/notifiers/profile_notifier.dart';
@@ -20,10 +21,6 @@ class ProfilePage extends ConsumerStatefulWidget {
} }
class _ProfilePageState extends ConsumerState<ProfilePage> { class _ProfilePageState extends ConsumerState<ProfilePage> {
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'); static final _log = Logger('ProfilePage');
UserProfile? _cachedProfile; UserProfile? _cachedProfile;
@@ -54,9 +51,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
bool _showCurrentPassword = false; bool _showCurrentPassword = false;
bool _showNewPassword = false; bool _showNewPassword = false;
bool _showConfirmPassword = false; bool _showConfirmPassword = false;
bool _isDesktopSideMenuOpen = true;
Map<String, dynamic>? _passwordPolicy; Map<String, dynamic>? _passwordPolicy;
bool _isPasswordPolicyLoading = false; 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 _renderTranslatedText(
String key, { String key, {
String? fallback, String? fallback,
@@ -615,7 +618,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
), ),
const Padding( const Padding(
padding: EdgeInsets.only(bottom: 16), 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<ProfilePage> {
children: [ children: [
Text( Text(
title, title,
style: const TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: _ink, color: _ink,
@@ -654,7 +664,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
label, label,
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: _ink, color: _ink,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -705,7 +715,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
fallback: 'Hello, {{name}}.', fallback: 'Hello, {{name}}.',
values: {'name': name}, values: {'name': name},
), ),
style: const TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: _ink, color: _ink,
@@ -996,12 +1006,17 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
tr('msg.userfront.profile.password.subtitle'), tr('msg.userfront.profile.password.subtitle'),
style: const TextStyle(color: Color(0xFF6B7280)), style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_buildPasswordPolicyDescription(), _buildPasswordPolicyDescription(),
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12), style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
@@ -1231,14 +1246,35 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
return Scaffold( return Scaffold(
backgroundColor: _subtle, backgroundColor: _subtle,
appBar: AppBar( 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( title: Text(
tr('ui.userfront.app_title'), tr('ui.userfront.app_title'),
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
elevation: 0,
backgroundColor: _surface,
foregroundColor: Colors.black,
actions: [ actions: [
const ThemeToggleButton(compact: true),
IconButton( IconButton(
icon: const Icon(Icons.home_outlined), icon: const Icon(Icons.home_outlined),
tooltip: tr('ui.userfront.nav.dashboard'), tooltip: tr('ui.userfront.nav.dashboard'),
@@ -1259,7 +1295,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)), drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
body: Row( body: Row(
children: [ children: [
if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)), if (isWide && _isDesktopSideMenuOpen)
SizedBox(width: 240, child: _buildSideMenu(context)),
Expanded(child: _buildContent(profile, isUpdating)), Expanded(child: _buildContent(profile, isUpdating)),
], ],
), ),

View File

@@ -418,6 +418,7 @@ const Map<String, String> koStrings = {
"msg.userfront.audit.device": "접속환경: {{value}}", "msg.userfront.audit.device": "접속환경: {{value}}",
"msg.userfront.audit.end": "더 이상 항목이 없습니다.", "msg.userfront.audit.end": "더 이상 항목이 없습니다.",
"msg.userfront.audit.filter.description": "활성화된 세션만 보려면 토글을 켜주세요.", "msg.userfront.audit.filter.description": "활성화된 세션만 보려면 토글을 켜주세요.",
"msg.userfront.audit.filtered_empty": "활성 세션으로 필터링된 접속 이력이 없습니다.",
"msg.userfront.audit.ip": "접속 IP: {{value}}", "msg.userfront.audit.ip": "접속 IP: {{value}}",
"msg.userfront.audit.load_more_error": "더 불러오지 못했습니다.", "msg.userfront.audit.load_more_error": "더 불러오지 못했습니다.",
"msg.userfront.audit.result": "인증결과: {{value}}", "msg.userfront.audit.result": "인증결과: {{value}}",
@@ -460,6 +461,7 @@ const Map<String, String> koStrings = {
"msg.userfront.dashboard.last_auth": "최근 인증: {{value}}", "msg.userfront.dashboard.last_auth": "최근 인증: {{value}}",
"msg.userfront.dashboard.link_missing": "이동할 페이지 주소(Client URI)가 설정되지 않았습니다.", "msg.userfront.dashboard.link_missing": "이동할 페이지 주소(Client URI)가 설정되지 않았습니다.",
"msg.userfront.dashboard.link_open_error": "해당 링크를 열 수 없습니다.", "msg.userfront.dashboard.link_open_error": "해당 링크를 열 수 없습니다.",
"msg.userfront.dashboard.link_status": "연동 상태: {{status}}",
"msg.userfront.dashboard.render_error": "대시보드 렌더링 오류: {{error}}", "msg.userfront.dashboard.render_error": "대시보드 렌더링 오류: {{error}}",
"msg.userfront.dashboard.revoke.confirm": "msg.userfront.dashboard.revoke.confirm":
"{{app}} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.", "{{app}} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.",
@@ -1422,8 +1424,8 @@ const Map<String, String> koStrings = {
"ui.common.status.pending": "준비 중", "ui.common.status.pending": "준비 중",
"ui.common.status.success": "성공", "ui.common.status.success": "성공",
"ui.common.success": "성공", "ui.common.success": "성공",
"ui.common.theme_dark": "Dark", "ui.common.theme_dark": "다크",
"ui.common.theme_light": "Light", "ui.common.theme_light": "라이트",
"ui.common.theme_toggle": "테마 전환", "ui.common.theme_toggle": "테마 전환",
"ui.common.unknown": "Unknown", "ui.common.unknown": "Unknown",
"ui.common.view": "보기", "ui.common.view": "보기",
@@ -1717,9 +1719,10 @@ const Map<String, String> koStrings = {
"ui.userfront.dashboard.approved_session.default": "승인한 세션 ID", "ui.userfront.dashboard.approved_session.default": "승인한 세션 ID",
"ui.userfront.dashboard.approved_session.userfront": "승인한 Userfront 세션 ID", "ui.userfront.dashboard.approved_session.userfront": "승인한 Userfront 세션 ID",
"ui.userfront.dashboard.last_auth_label": "최근 인증", "ui.userfront.dashboard.last_auth_label": "최근 인증",
"ui.userfront.dashboard.link_status_label": "연동 상태",
"ui.userfront.dashboard.revoke.confirm_button": "해지하기", "ui.userfront.dashboard.revoke.confirm_button": "해지하기",
"ui.userfront.dashboard.revoke.title": "연동 해지", "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.active_badge": "활성화",
"ui.userfront.dashboard.sessions.current_badge": "접속중", "ui.userfront.dashboard.sessions.current_badge": "접속중",
"ui.userfront.dashboard.sessions.current_disabled": "현재 세션", "ui.userfront.dashboard.sessions.current_disabled": "현재 세션",
@@ -2324,6 +2327,8 @@ const Map<String, String> enStrings = {
"msg.userfront.audit.end": "No more items to show.", "msg.userfront.audit.end": "No more items to show.",
"msg.userfront.audit.filter.description": "msg.userfront.audit.filter.description":
"Toggle to view only active sessions.", "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.ip": "IP address: {{value}}",
"msg.userfront.audit.load_more_error": "Could not load more history.", "msg.userfront.audit.load_more_error": "Could not load more history.",
"msg.userfront.audit.result": "Result: {{value}}", "msg.userfront.audit.result": "Result: {{value}}",
@@ -2376,6 +2381,7 @@ const Map<String, String> enStrings = {
"msg.userfront.dashboard.link_missing": "msg.userfront.dashboard.link_missing":
"This app does not have a launch URL configured.", "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_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.render_error": "Dashboard render error: {{error}}",
"msg.userfront.dashboard.revoke.confirm": "msg.userfront.dashboard.revoke.confirm":
"Disconnect {{app}}?\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in.", "Disconnect {{app}}?\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in.",
@@ -3728,9 +3734,10 @@ const Map<String, String> enStrings = {
"ui.userfront.dashboard.approved_session.userfront": "ui.userfront.dashboard.approved_session.userfront":
"Approved UserFront session ID", "Approved UserFront session ID",
"ui.userfront.dashboard.last_auth_label": "Last sign-in", "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.confirm_button": "Disconnect",
"ui.userfront.dashboard.revoke.title": "Disconnect app", "ui.userfront.dashboard.revoke.title": "Disconnect",
"ui.userfront.dashboard.scopes.title": "Permission (Scopes)", "ui.userfront.dashboard.scopes.title": "Consent scopes",
"ui.userfront.dashboard.sessions.active_badge": "Active", "ui.userfront.dashboard.sessions.active_badge": "Active",
"ui.userfront.dashboard.sessions.current_badge": "Current", "ui.userfront.dashboard.sessions.current_badge": "Current",
"ui.userfront.dashboard.sessions.current_disabled": "Current session", "ui.userfront.dashboard.sessions.current_disabled": "Current session",
@@ -3739,7 +3746,7 @@ const Map<String, String> enStrings = {
"ui.userfront.dashboard.sessions.unknown_device": "Unknown device", "ui.userfront.dashboard.sessions.unknown_device": "Unknown device",
"ui.userfront.dashboard.sessions.unknown_session": "Session", "ui.userfront.dashboard.sessions.unknown_session": "Session",
"ui.userfront.dashboard.status.revoked": "Revoked", "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.android": "Mobile(Android)",
"ui.userfront.device.ios": "Mobile(iOS)", "ui.userfront.device.ios": "Mobile(iOS)",
"ui.userfront.device.linux": "Desktop(Linux)", "ui.userfront.device.linux": "Desktop(Linux)",

View File

@@ -24,6 +24,9 @@ import 'core/services/logger_service.dart';
import 'core/services/null_check_recovery.dart'; import 'core/services/null_check_recovery.dart';
import 'core/services/web_window.dart'; import 'core/services/web_window.dart';
import 'core/notifiers/auth_notifier.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_gate.dart';
import 'core/i18n/locale_registry.dart'; import 'core/i18n/locale_registry.dart';
import 'core/i18n/locale_utils.dart'; import 'core/i18n/locale_utils.dart';
@@ -106,6 +109,8 @@ void main() async {
// 0. Initialize Logger // 0. Initialize Logger
LoggerService.init(); LoggerService.init();
await ThemeController.app.restore();
await ThemeController.auth.restore();
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
await _loadBundledFonts(); await _loadBundledFonts();
@@ -177,12 +182,18 @@ final _router = GoRouter(
GoRoute( GoRoute(
path: 'dashboard', path: 'dashboard',
builder: (context, state) { builder: (context, state) {
return const DashboardScreen(); return ScopedTheme(
controller: ThemeController.app,
child: const DashboardScreen(),
);
}, },
), ),
GoRoute( GoRoute(
path: 'profile', path: 'profile',
builder: (context, state) => const ProfilePage(), builder: (context, state) => ScopedTheme(
controller: ThemeController.app,
child: const ProfilePage(),
),
), ),
GoRoute( GoRoute(
path: 'signin', path: 'signin',
@@ -192,10 +203,13 @@ final _router = GoRouter(
final redirectUrl = final redirectUrl =
state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url']; state.uri.queryParameters['redirect_url'];
return LoginScreen( return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey, key: state.pageKey,
loginChallenge: loginChallenge, loginChallenge: loginChallenge,
redirectUrl: redirectUrl, redirectUrl: redirectUrl,
),
); );
}, },
), ),
@@ -208,10 +222,13 @@ final _router = GoRouter(
final redirectUrl = final redirectUrl =
state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url']; state.uri.queryParameters['redirect_url'];
return LoginScreen( return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey, key: state.pageKey,
loginChallenge: loginChallenge, loginChallenge: loginChallenge,
redirectUrl: redirectUrl, redirectUrl: redirectUrl,
),
); );
}, },
), ),
@@ -227,88 +244,137 @@ final _router = GoRouter(
), ),
); );
} }
return ConsentScreen(consentChallenge: consentChallenge); return ScopedTheme(
controller: ThemeController.auth,
child: ConsentScreen(consentChallenge: consentChallenge),
);
}, },
), ),
GoRoute( GoRoute(
path: 'signup', path: 'signup',
builder: (context, state) => const SignupScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const SignupScreen(),
),
), ),
GoRoute( GoRoute(
path: 'registration', path: 'registration',
builder: (context, state) => const SignupScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const SignupScreen(),
),
), ),
GoRoute( GoRoute(
path: 'verify', path: 'verify',
builder: (context, state) => LoginScreen(key: state.pageKey), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
),
), ),
GoRoute( GoRoute(
path: 'verify/:token', path: 'verify/:token',
builder: (context, state) { builder: (context, state) {
final token = state.pathParameters['token']; final token = state.pathParameters['token'];
return LoginScreen( return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey, key: state.pageKey,
verificationToken: token, verificationToken: token,
),
); );
}, },
), ),
GoRoute( GoRoute(
path: 'verification', path: 'verification',
builder: (context, state) => LoginScreen(key: state.pageKey), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
),
), ),
GoRoute( GoRoute(
path: 'l/:shortCode', path: 'l/:shortCode',
builder: (context, state) { builder: (context, state) {
return LoginScreen(key: state.pageKey); return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
);
}, },
), ),
GoRoute( GoRoute(
path: 'forgot-password', path: 'forgot-password',
builder: (context, state) => const ForgotPasswordScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ForgotPasswordScreen(),
),
), ),
GoRoute( GoRoute(
path: 'recovery', path: 'recovery',
builder: (context, state) => const ForgotPasswordScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ForgotPasswordScreen(),
),
), ),
GoRoute( GoRoute(
path: 'reset-password', path: 'reset-password',
builder: (context, state) => const ResetPasswordScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ResetPasswordScreen(),
),
), ),
GoRoute( GoRoute(
path: 'error', path: 'error',
builder: (context, state) { builder: (context, state) {
final params = state.uri.queryParameters; final params = state.uri.queryParameters;
return ErrorScreen( return ScopedTheme(
controller: ThemeController.auth,
child: ErrorScreen(
errorId: params['id'], errorId: params['id'],
errorCode: params['error'], errorCode: params['error'],
description: params['error_description'] ?? params['message'], description:
params['error_description'] ?? params['message'],
),
); );
}, },
), ),
GoRoute( GoRoute(
path: 'settings', path: 'settings',
builder: (context, state) => ErrorScreen( builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ErrorScreen(
errorCode: 'settings_disabled', errorCode: 'settings_disabled',
description: tr('msg.userfront.settings.disabled'), description: tr('msg.userfront.settings.disabled'),
), ),
), ),
),
GoRoute( GoRoute(
path: 'approve', path: 'approve',
builder: (context, state) => builder: (context, state) => ScopedTheme(
ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']), controller: ThemeController.auth,
child: ApproveQrScreen(
pendingRef: state.uri.queryParameters['ref'],
),
),
), ),
GoRoute( GoRoute(
path: 'ql/:ref', path: 'ql/:ref',
builder: (context, state) => builder: (context, state) => ScopedTheme(
ApproveQrScreen(pendingRef: state.pathParameters['ref']), controller: ThemeController.auth,
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
),
), ),
GoRoute( GoRoute(
path: 'scan', path: 'scan',
builder: (context, state) => const QRScanScreen(), builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const QRScanScreen(),
),
), ),
GoRoute( GoRoute(
path: 'admin/users', 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()], children: [if (child != null) child, const ToastViewport()],
); );
}, },
theme: ThemeData( theme: buildLightTheme(),
colorScheme: ColorScheme.fromSeed( darkTheme: buildDarkTheme(),
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base themeMode: ThemeMode.light,
brightness: Brightness.light,
),
useMaterial3: true,
fontFamily: 'NotoSansKR',
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: NoTransitionsBuilder(),
TargetPlatform.iOS: NoTransitionsBuilder(),
TargetPlatform.linux: NoTransitionsBuilder(),
TargetPlatform.macOS: NoTransitionsBuilder(),
TargetPlatform.windows: NoTransitionsBuilder(),
TargetPlatform.fuchsia: NoTransitionsBuilder(),
},
),
),
routerConfig: _router, routerConfig: _router,
); );
} }
} }
class NoTransitionsBuilder extends PageTransitionsBuilder {
const NoTransitionsBuilder();
@override
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return child;
}
}

View File

@@ -184,6 +184,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -388,6 +396,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -485,7 +501,7 @@ packages:
source: hosted source: hosted
version: "3.2.0" version: "3.2.0"
shared_preferences: shared_preferences:
dependency: transitive dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
@@ -753,6 +769,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.5" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -825,6 +865,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@@ -40,6 +40,7 @@ dependencies:
go_router: ^17.0.1 go_router: ^17.0.1
http: ^1.6.0 http: ^1.6.0
flutter_dotenv: ^6.0.0 flutter_dotenv: ^6.0.0
flutter_svg: ^2.2.1
url_launcher: ^6.3.2 url_launcher: ^6.3.2
logging: ^1.2.0 logging: ^1.2.0
logger: ^2.0.0 logger: ^2.0.0
@@ -48,6 +49,7 @@ dependencies:
easy_localization: ^3.0.7 easy_localization: ^3.0.7
toml: ^0.15.0 toml: ^0.15.0
web: ^1.1.0 web: ^1.1.0
shared_preferences: ^2.5.4
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -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);
});
}

View File

@@ -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');
});
}