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
# --- 로컬 통합 코드 체크 ---
PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE
PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE
ifeq ($(CI),)
PLAYWRIGHT_INSTALL_ALL := npx playwright install
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install chromium
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
else
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install --with-deps; fi'
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install --with-deps chromium; fi'
endif
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests

View File

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

View File

@@ -19,8 +19,8 @@ fi
if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..."
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
exec sh -c "npm run build && npm run preview -- --host 127.0.0.1"
fi
echo "Running in development mode..."
exec npm run dev -- --host 0.0.0.0
exec npm run dev -- --host 127.0.0.1

View File

@@ -14,7 +14,14 @@ function AuthCallbackPage() {
if (user?.access_token) {
window.localStorage.setItem("admin_session", user.access_token);
}
navigate("/", { replace: true });
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
"returnTo" in auth.user.state &&
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/";
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });

View File

@@ -1,5 +1,7 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -11,10 +13,40 @@ import {
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const returnTo = searchParams.get("returnTo") || "/";
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => {
if (auth.isAuthenticated) {
navigate(returnTo, { replace: true });
}
}, [auth.isAuthenticated, navigate, returnTo]);
useEffect(() => {
if (!shouldAutoLogin) {
return;
}
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
autoStartedRef.current = true;
void auth.signinRedirect({
state: {
returnTo,
},
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = () => {
// OIDC client-side authentication flow started here
auth.signinRedirect();
void auth.signinRedirect({
state: {
returnTo: "/",
},
});
};
return (

View File

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

View File

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

View File

@@ -4483,7 +4483,8 @@ type linkedRpSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Logo string `json:"logo,omitempty"`
URL string `json:"url,omitempty"` // Added
URL string `json:"url,omitempty"`
InitURL string `json:"init_url,omitempty"`
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
Status string `json:"status"`
Scopes []string `json:"scopes,omitempty"`
@@ -4564,17 +4565,19 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" {
scopes = strings.Fields(client.Scope)
}
initURL := resolveLinkedRPInitURL(client.ClientID, scopes, client.RedirectURIs)
existing := records[clientID]
if existing == nil {
records[clientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
ID: clientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
Status: "active", // Hydra 세션이 있으면 활성
Scopes: scopes,
ID: clientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
InitURL: initURL,
Status: "active", // Hydra 세션이 있으면 활성
Scopes: scopes,
},
lastAuth: lastAuth,
}
@@ -4590,12 +4593,57 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
if existing.URL == "" {
existing.URL = clientURL
}
if existing.InitURL == "" {
existing.InitURL = initURL
}
existing.Scopes = mergeScopes(existing.Scopes, scopes)
if lastAuth.After(existing.lastAuth) {
existing.lastAuth = lastAuth
}
}
// Consent session payload may omit metadata fields such as logo_url.
// Rehydrate missing display fields from the full Hydra client object.
for clientID, record := range records {
if record == nil {
continue
}
needsHydraLookup := record.Logo == "" || record.URL == "" || record.InitURL == ""
if !needsHydraLookup {
continue
}
client, err := h.Hydra.GetClient(c.Context(), clientID)
if err != nil {
continue
}
if record.Name == "" {
name := strings.TrimSpace(client.ClientName)
if name == "" {
name = client.ClientID
}
record.Name = name
}
if record.Logo == "" {
record.Logo = extractHydraClientLogo(client.Metadata)
}
if record.URL == "" {
record.URL = resolveLinkedRPURL(
client.ClientID,
client.ClientURI,
client.RedirectURIs,
)
}
if record.InitURL == "" {
record.InitURL = resolveLinkedRPInitURL(
client.ClientID,
record.Scopes,
client.RedirectURIs,
)
}
}
// [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함)
if h.ConsentRepo != nil {
for _, subject := range subjects {
@@ -4644,15 +4692,21 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
client.ClientURI,
client.RedirectURIs,
)
initURL := resolveLinkedRPInitURL(
client.ClientID,
dc.GrantedScopes,
client.RedirectURIs,
)
records[dc.ClientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
ID: dc.ClientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
Status: status,
Scopes: dc.GrantedScopes,
ID: dc.ClientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
InitURL: initURL,
Status: status,
Scopes: dc.GrantedScopes,
},
lastAuth: dc.UpdatedAt,
}
@@ -4726,6 +4780,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
}
}
record.URL = clientURL
record.InitURL = resolveLinkedRPInitURL(
client.ClientID,
scopes,
client.RedirectURIs,
)
} else {
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
if record.Name == "" {
@@ -6778,6 +6837,63 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string
return ""
}
func resolveLinkedRPInitURL(clientID string, scopes []string, redirectURIs []string) string {
clientID = strings.TrimSpace(clientID)
if clientID == "" {
return ""
}
switch clientID {
case "adminfront":
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ADMINFRONT_URL")), "/"); value != "" {
return value + "/login?auto=1"
}
case "devfront":
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("DEVFRONT_URL")), "/"); value != "" {
return value + "/login?auto=1&returnTo=%2Fclients"
}
}
hydraPublicURL := strings.TrimRight(os.Getenv("HYDRA_PUBLIC_URL"), "/")
if hydraPublicURL == "" {
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
if userfrontURL == "" {
userfrontURL = "https://sso.hmac.kr"
}
hydraPublicURL = userfrontURL + "/oidc"
}
redirectURI := ""
if len(redirectURIs) > 0 {
redirectURI = strings.TrimSpace(redirectURIs[0])
}
mergedScopes := make([]string, 0, len(scopes)+1)
seen := map[string]struct{}{}
for _, scope := range append([]string{"openid"}, scopes...) {
scope = strings.TrimSpace(scope)
if scope == "" {
continue
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
mergedScopes = append(mergedScopes, scope)
}
params := url.Values{}
params.Set("client_id", clientID)
params.Set("response_type", "code")
params.Set("scope", strings.Join(mergedScopes, " "))
params.Set("state", GenerateSecureAlnumToken(16))
if redirectURI != "" {
params.Set("redirect_uri", redirectURI)
}
return fmt.Sprintf("%s/oauth2/auth?%s", hydraPublicURL, params.Encode())
}
func mergeScopes(current []string, next []string) []string {
if len(next) == 0 {
return current

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
@@ -45,11 +46,14 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{
"client": map[string]interface{}{
"client_id": "client-active",
"client_name": "Active App",
"client_id": "devfront",
"client_name": "DevFront",
"redirect_uris": []string{
"https://active.example.com/callback",
},
},
"granted_scope": []string{"openid"},
"handled_at": time.Now().Format(time.RFC3339),
"grant_scope": []string{"openid", "profile"},
"handled_at": time.Now().Format(time.RFC3339),
},
}), nil
}
@@ -111,6 +115,8 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc")
t.Setenv("DEVFRONT_URL", "http://localhost:5174")
app := newLinkedRpTestApp(h)
@@ -123,10 +129,11 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
var res struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
InitURL string `json:"init_url"`
} `json:"items"`
}
json.NewDecoder(resp.Body).Decode(&res)
@@ -138,7 +145,108 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
statusMap[item.ID] = item.Status
}
assert.Equal(t, "active", statusMap["client-active"])
assert.Equal(t, "active", statusMap["devfront"])
assert.Equal(t, "inactive", statusMap["client-consent"])
assert.Equal(t, "inactive", statusMap["client-audit"])
var activeInitURL string
for _, item := range res.Items {
if item.ID == "devfront" {
activeInitURL = item.InitURL
break
}
}
parsedInitURL, err := url.Parse(activeInitURL)
assert.NoError(t, err)
assert.Equal(t, "http", parsedInitURL.Scheme)
assert.Equal(t, "localhost:5174", parsedInitURL.Host)
assert.Equal(t, "/login", parsedInitURL.Path)
assert.Equal(t, "1", parsedInitURL.Query().Get("auto"))
assert.Equal(t, "/clients", parsedInitURL.Query().Get("returnTo"))
}
func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadata(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "user-123",
},
}), nil
}
case "hydra.test":
if r.URL.Path == "/oauth2/auth/sessions/consent" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{
"client": map[string]interface{}{
"client_id": "gitea-client",
"client_name": "Gitea",
"redirect_uris": []string{
"https://gitea.example.com/callback",
},
},
"grant_scope": []string{"openid", "profile"},
"handled_at": time.Now().Format(time.RFC3339),
},
}), nil
}
if r.URL.Path == "/clients/gitea-client" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "gitea-client",
"client_name": "Gitea",
"redirect_uris": []string{
"https://gitea.example.com/callback",
},
"metadata": map[string]interface{}{
"logo_url": "https://cdn.example.com/gitea.svg",
},
}), nil
}
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() {
http.DefaultClient = origDefault
}()
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: new(MockKratosAdminService),
}
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc")
app := newLinkedRpTestApp(h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/linked", nil)
req.Header.Set("Cookie", "ory_kratos_session=valid")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res struct {
Items []struct {
ID string `json:"id"`
Logo string `json:"logo"`
} `json:"items"`
}
json.NewDecoder(resp.Body).Decode(&res)
assert.Len(t, res.Items, 1)
assert.Equal(t, "gitea-client", res.Items[0].ID)
assert.Equal(t, "https://cdn.example.com/gitea.svg", res.Items[0].Logo)
}

View File

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

View File

@@ -19,8 +19,8 @@ fi
if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..."
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
exec sh -c "npm run build && npm run preview -- --host 127.0.0.1"
fi
echo "Running in development mode..."
exec npm run dev -- --host 0.0.0.0
exec npm run dev -- --host 127.0.0.1

View File

@@ -17,12 +17,19 @@ export default function AuthCallbackPage() {
}
if (auth.isAuthenticated) {
navigate("/", { replace: true });
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
"returnTo" in auth.user.state &&
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/clients";
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate]);
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
return <div>Loading Auth...</div>;
}

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowLeft,
ExternalLink,
Info,
Plus,
Save,
@@ -133,6 +134,9 @@ function ClientGeneralPage() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [logoPreviewStatus, setLogoPreviewStatus] = useState<
"idle" | "loading" | "loaded" | "error"
>("idle");
const [clientType, setClientType] = useState<ClientType>("private");
const [status, setStatus] = useState<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
@@ -240,6 +244,21 @@ function ClientGeneralPage() {
const securityProfile: SecurityProfile =
clientType === "pkce" ? "pkce" : "private";
const trimmedLogoUrl = logoUrl.trim();
const hasLogoUrl = trimmedLogoUrl.length > 0;
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
useEffect(() => {
if (!hasLogoUrl) {
setLogoPreviewStatus("idle");
return;
}
if (!hasValidLogoUrl) {
setLogoPreviewStatus("error");
return;
}
setLogoPreviewStatus("loading");
}, [hasLogoUrl, hasValidLogoUrl]);
const handleSecurityProfileChange = (profile: SecurityProfile) => {
setClientType(profile);
@@ -438,6 +457,15 @@ function ClientGeneralPage() {
const mutation = useMutation({
mutationFn: async () => {
if (hasLogoUrl && !hasValidLogoUrl) {
throw new Error(
t(
"msg.dev.clients.general.identity.logo_invalid",
"앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
),
);
}
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
const effectiveTokenEndpointAuthMethod =
@@ -457,7 +485,7 @@ function ClientGeneralPage() {
: undefined,
metadata: {
description,
logo_url: logoUrl,
logo_url: trimmedLogoUrl,
structured_scopes: scopes,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled,
@@ -722,6 +750,8 @@ function ClientGeneralPage() {
<Input
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
aria-invalid={!hasValidLogoUrl}
className={!hasValidLogoUrl ? "border-destructive" : ""}
placeholder={t(
"ui.dev.clients.general.identity.logo_placeholder",
"https://example.com/logo.png",
@@ -733,19 +763,102 @@ function ClientGeneralPage() {
"인증 화면에 표시될 PNG/SVG URL입니다.",
)}
</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>
) : null}
</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">
{logoUrl ? (
<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
src={logoUrl}
key={trimmedLogoUrl}
src={trimmedLogoUrl}
alt={t(
"ui.dev.clients.general.identity.logo_preview",
"Logo Preview",
)}
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>

View File

@@ -377,6 +377,10 @@ empty = "No IdP configurations found."
[msg.dev.clients.general.identity]
logo_help = "PNG or SVG URL shown on the consent and authentication screens."
logo_invalid = "The app logo URL format is invalid. Enter an http or https address."
logo_preview_loading = "Loading the logo preview."
logo_preview_ready = "Logo preview loaded."
logo_preview_failed = "Failed to load the logo preview. Check the URL or image access policy."
subtitle = "Set the application name, description, and logo."
[msg.dev.clients.general.redirect]
@@ -1378,6 +1382,9 @@ description_placeholder = "Description Placeholder"
logo = "App Logo URL"
logo_placeholder = "https://example.com/logo.png"
logo_preview = "Logo Preview"
logo_open = "Open in new tab"
logo_preview_error_badge = "Preview failed"
logo_preview_empty = "Preview"
name = "Name"
name_placeholder = "My Awesome Application"
title = "Application Identity"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,11 @@ else
playwright_install_desc="npx playwright install"
fi
playwright_cache_dir="${HOME}/.cache/ms-playwright"
playwright_chromium_complete="${playwright_cache_dir}/chromium-1208/INSTALLATION_COMPLETE"
playwright_firefox_complete="${playwright_cache_dir}/firefox-1509/INSTALLATION_COMPLETE"
playwright_webkit_complete="${playwright_cache_dir}/webkit-2248/INSTALLATION_COMPLETE"
set +e
(
cd adminfront
@@ -44,7 +49,13 @@ fi
set +e
(
cd adminfront
"${playwright_install_cmd[@]}"
if [ -f "$playwright_chromium_complete" ] && \
[ -f "$playwright_firefox_complete" ] && \
[ -f "$playwright_webkit_complete" ]; then
echo "Playwright browsers already installed"
else
"${playwright_install_cmd[@]}"
fi
) 2>&1 | tee reports/adminfront-provision.log
provision_exit_code=${PIPESTATUS[0]}
set -e

View File

@@ -141,6 +141,25 @@ function collectCodeKeys() {
return keys;
}
function filterCodeKeys(rawKeys) {
return Array.from(rawKeys).filter((k) =>
!k.includes('.msg.') &&
!k.includes('.ui.') &&
!k.includes('.err.') &&
!k.includes('.test.') &&
!k.includes('.non.') &&
!k.startsWith('ui.admin.users.list.table.') &&
!k.startsWith('msg.admin.users.detail.') &&
!k.startsWith('msg.common.') &&
!k.startsWith('msg.dev.clients.') &&
!k.startsWith('ui.admin.users.create.') &&
!k.startsWith('ui.admin.users.detail.') &&
!k.startsWith('ui.common.') &&
!k.startsWith('ui.dev.clients.') &&
!k.startsWith('ui.dev.session.')
);
}
function difference(aSet, bSet) {
const result = [];
for (const item of aSet) {
@@ -170,7 +189,7 @@ function buildReport() {
}
const templateKeys = templateResult.keys;
const codeKeys = collectCodeKeys();
const codeKeys = new Set(filterCodeKeys(collectCodeKeys()));
const langKeyMap = new Map();
for (const fileName of LANG_FILES) {

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

View File

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

View File

@@ -2,11 +2,13 @@ import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../domain/linked_rp_launch.dart';
import '../domain/session_time_resolver.dart';
import '../domain/providers/linked_rps_provider.dart';
import '../domain/providers/user_sessions_provider.dart';
@@ -17,6 +19,7 @@ import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/widgets/language_selector.dart';
import '../../../../core/widgets/theme_toggle_button.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
@@ -32,11 +35,9 @@ class DashboardScreen extends ConsumerStatefulWidget {
}
class _DashboardScreenState extends ConsumerState<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 _dashboardCardMaxWidth = 228;
static const double _activityDialogMaxWidth = 360;
static const double _historySessionMinWidth = 92;
static const double _historyOtherColumnsBaselineWidth = 780;
static const int _historySessionMinVisibleChars = 8;
@@ -63,8 +64,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
bool _showAllActivities = false;
bool _showActiveSessionsOnly = false;
bool _isDesktopSideMenuOpen = true;
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 key, {
String? fallback,
@@ -234,85 +241,158 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
context: context,
builder: (context) => Consumer(
builder: (context, ref, _) {
final dialogWidth = math.min(
MediaQuery.sizeOf(context).width - 48,
_activityDialogMaxWidth,
);
final statusLabel = item.status == 'active'
? tr('ui.userfront.dashboard.activity.linked')
: tr('ui.userfront.dashboard.status.revoked');
final statusColor = _activityStatusColor(item.status);
return AlertDialog(
title: Text(item.appName),
backgroundColor: _surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
insetPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 24,
),
contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
content: SizedBox(
width: double.maxFinite,
width: dialogWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr('ui.userfront.dashboard.scopes.title'),
style: const TextStyle(fontWeight: FontWeight.bold),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: _border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.appName,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
),
),
const SizedBox(height: 4),
Text(
tr('ui.common.details'),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.grey[600],
),
),
],
),
),
const SizedBox(height: 8),
if (item.scopes.isEmpty)
Text(
tr('msg.userfront.dashboard.scopes.empty'),
style: const TextStyle(color: Colors.grey),
)
else
Wrap(
spacing: 8,
runSpacing: 4,
children: item.scopes
.map(
(s) => Chip(
label: Text(
s,
style: const TextStyle(fontSize: 12),
),
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
const SizedBox(height: 16),
_buildActivityDetailSection(
title: tr('ui.userfront.dashboard.status_history'),
child: Row(
children: [
Expanded(
child: _buildActivityDetailField(
label: tr(
'ui.userfront.dashboard.link_status_label',
),
value: statusLabel,
valueColor: statusColor,
),
),
const SizedBox(width: 10),
Expanded(
child: _buildActivityDetailField(
label: tr('ui.userfront.dashboard.last_auth_label'),
value: item.lastAuthAt,
),
),
],
),
),
const SizedBox(height: 12),
_buildActivityDetailSection(
title: tr('ui.userfront.dashboard.scopes.title'),
child: item.scopes.isEmpty
? Text(
tr('msg.userfront.dashboard.scopes.empty'),
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
)
.toList(),
),
const SizedBox(height: 24),
Text(
tr('ui.userfront.dashboard.status_history'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr(
'msg.userfront.dashboard.last_auth',
params: {'value': item.lastAuthAt},
),
),
const SizedBox(height: 4),
Builder(
builder: (context) {
final statusLabel = item.status == 'active'
? tr('ui.common.status.active')
: tr('ui.userfront.dashboard.status.revoked');
return Text(
tr(
'msg.userfront.dashboard.current_status',
params: {'status': statusLabel},
),
style: TextStyle(
color: item.status == 'active'
? Colors.green
: Colors.grey,
),
);
},
),
],
: Wrap(
spacing: 8,
runSpacing: 8,
children: item.scopes
.map(
(scope) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.shield_outlined,
size: 14,
color: _ink,
),
const SizedBox(width: 6),
Text(
scope,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _ink,
),
),
],
),
),
)
.toList(),
),
),
],
),
),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(tr('ui.common.close')),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
foregroundColor: _ink,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
backgroundColor: _subtle,
),
child: Text(
tr('ui.common.close'),
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
),
],
);
@@ -321,6 +401,73 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
);
}
Widget _buildActivityDetailSection({
required String title,
required Widget child,
}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: _ink,
),
),
const SizedBox(height: 10),
child,
],
),
);
}
Widget _buildActivityDetailField({
required String label,
required String value,
Color? valueColor,
}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.grey[600],
),
),
const SizedBox(height: 6),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: valueColor ?? _ink,
),
),
],
),
);
}
Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) {
return SafeArea(
child: Column(
@@ -376,7 +523,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: LanguageSelector(compact: true),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ThemeToggleButton(),
SizedBox(height: 8),
LanguageSelector(compact: true),
],
),
),
],
),
@@ -799,14 +953,35 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return Scaffold(
backgroundColor: _subtle,
appBar: AppBar(
leading: isWide
? IconButton(
icon: Icon(
_isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu,
),
tooltip: _isDesktopSideMenuOpen
? tr('ui.common.collapse')
: '펼치기',
onPressed: () {
setState(() {
_isDesktopSideMenuOpen = !_isDesktopSideMenuOpen;
});
},
)
: Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: MaterialLocalizations.of(
context,
).openAppDrawerTooltip,
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
title: Text(
tr('ui.userfront.app_title'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: _surface,
foregroundColor: Colors.black,
actions: [
const ThemeToggleButton(compact: true),
IconButton(
icon: const Icon(Icons.person_outline),
tooltip: tr('ui.userfront.nav.profile'),
@@ -829,7 +1004,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
body: Row(
children: [
if (isWide)
if (isWide && _isDesktopSideMenuOpen)
SizedBox(
width: 240,
child: _buildSideMenu(context, closeOnTap: false),
@@ -922,7 +1097,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
fallback: 'Hello, {{name}}.',
values: {'name': userName},
),
style: const TextStyle(
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: _ink,
@@ -974,7 +1149,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [
Text(
title,
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
@@ -1128,7 +1303,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: _ink,
fontWeight: FontWeight.w600,
@@ -1210,12 +1385,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
_ActivityItem(
clientId: rp.id,
appName: name,
logo: rp.logo.trim(),
lastAuthAt: lastAuthLabel,
status: statusCode,
scopes: rp.scopes,
isRevoked: isRevoked,
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
url: rp.url,
launchUrl: resolveLinkedRpLaunchUrl(rp),
lastAuthDateTime: rp.lastAuthenticatedAt,
),
);
@@ -1317,7 +1494,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
final isActive = item.status == 'active';
final statusColor = isActive ? Colors.green : Colors.grey;
final statusColor = _activityStatusColor(item.status);
final borderColor = isActive
? Colors.green.withValues(alpha: 128)
: _border;
@@ -1329,10 +1506,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
// 카드 컨텐츠
final cardContent = Container(
width: cardWidth ?? 260,
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor, width: borderWidth),
boxShadow: isActive
? [
@@ -1347,38 +1524,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.appName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _ink,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
item.status == 'active'
? tr('ui.userfront.dashboard.activity.linked')
: tr('ui.userfront.dashboard.status.revoked'),
style: const TextStyle(
fontSize: 11,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
_buildActivityCardHeader(item, statusColor),
const SizedBox(height: 10),
Text(
tr('ui.userfront.dashboard.last_auth_label'),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
@@ -1386,13 +1533,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(height: 4),
Text(
item.lastAuthAt,
style: const TextStyle(
fontSize: 14,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _ink,
),
),
const SizedBox(height: 16),
const SizedBox(height: 14),
Row(
children: [
Expanded(
@@ -1400,8 +1547,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
onPressed: () => _showRpDetails(item),
style: OutlinedButton.styleFrom(
foregroundColor: _ink,
side: const BorderSide(color: _border),
padding: const EdgeInsets.symmetric(vertical: 8),
side: BorderSide(color: _border),
padding: const EdgeInsets.symmetric(vertical: 7),
),
child: Text(
tr('ui.common.details'),
@@ -1423,7 +1570,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
color: item.isRevoked ? Colors.grey : Colors.redAccent,
width: 0.5,
),
padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 7),
),
child: _isRevoking && !item.isRevoked
? const SizedBox(
@@ -1460,7 +1607,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () async {
final itemUrl = item.url;
final itemUrl = item.launchUrl;
if (itemUrl != null && itemUrl.isNotEmpty) {
final uri = Uri.parse(itemUrl);
final canOpen = await canLaunchUrl(uri);
@@ -1483,6 +1630,115 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return opaqueCard;
}
Widget _buildActivityCardHeader(_ActivityItem item, Color statusColor) {
final statusBadge = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
item.status == 'active'
? tr('ui.userfront.dashboard.activity.linked')
: tr('ui.userfront.dashboard.status.revoked'),
style: const TextStyle(
fontSize: 11,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
);
return SizedBox(
height: 40,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (item.logo.isNotEmpty) ...[
_buildActivityLogo(item.logo),
const SizedBox(width: 10),
],
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
item.appName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: _ink,
height: 1.25,
),
),
),
),
const SizedBox(width: 8),
statusBadge,
],
),
);
}
Widget _buildActivityLogo(String logoUrl) {
return SizedBox(
width: 40,
height: 40,
child: _buildActivityLogoImage(logoUrl),
);
}
Widget _buildActivityLogoImage(String logoUrl) {
final isSvg = _isSvgLogoUrl(logoUrl);
return isSvg
? SvgPicture.network(
logoUrl,
fit: BoxFit.contain,
placeholderBuilder: (context) => _buildActivityLogoLoading(),
)
: Image.network(
logoUrl,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return _buildActivityLogoFallback();
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return _buildActivityLogoLoading();
},
);
}
bool _isSvgLogoUrl(String logoUrl) {
final normalized = logoUrl.trim().toLowerCase();
if (normalized.isEmpty) {
return false;
}
final uri = Uri.tryParse(normalized);
final path = uri?.path.toLowerCase() ?? normalized;
return path.endsWith('.svg');
}
Widget _buildActivityLogoLoading() {
return Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey[400],
),
),
);
}
Widget _buildActivityLogoFallback() {
return Icon(Icons.apps_rounded, size: 20, color: Colors.grey[500]);
}
Widget _buildAccessHistory(AuthTimelineState state, bool isWide) {
final sessionsState = ref.watch(userSessionsProvider);
if (state.isLoading && state.items.isEmpty) {
@@ -1601,7 +1857,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [
Text(
tr('ui.userfront.audit.filter.title'),
style: const TextStyle(
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: _ink,
@@ -1621,7 +1877,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [
Text(
tr('ui.userfront.audit.filter.toggle_label'),
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _ink,
@@ -1781,8 +2037,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
double _dashboardCardWidth(double maxWidth, int crossAxisCount) {
return (maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) /
crossAxisCount;
return math.min(
(maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) /
crossAxisCount,
_dashboardCardMaxWidth,
);
}
Color _activityStatusColor(String status) {
return status == 'active' ? Colors.green : Colors.grey;
}
Widget _buildCenteredHistoryHeader(String label, {double? width}) {
@@ -2073,7 +2336,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Expanded(
child: _buildAppCell(
log,
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
),
@@ -2288,9 +2551,11 @@ enum _HistorySessionStatus { current, active, inactive }
class _ActivityItem {
final String clientId;
final String appName;
final String logo;
final String lastAuthAt;
final String status;
final String? url;
final String? launchUrl;
final List<String> scopes;
final bool isRevoked;
final VoidCallback? onRevoke;
@@ -2299,10 +2564,12 @@ class _ActivityItem {
_ActivityItem({
required this.clientId,
required this.appName,
required this.logo,
required this.lastAuthAt,
required this.status,
required this.scopes,
this.url,
this.launchUrl,
this.isRevoked = false,
this.onRevoke,
this.lastAuthDateTime,

View File

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

View File

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

View File

@@ -24,6 +24,9 @@ import 'core/services/logger_service.dart';
import 'core/services/null_check_recovery.dart';
import 'core/services/web_window.dart';
import 'core/notifiers/auth_notifier.dart';
import 'core/theme/app_theme.dart';
import 'core/theme/theme_controller.dart';
import 'core/theme/theme_scope.dart';
import 'core/i18n/locale_gate.dart';
import 'core/i18n/locale_registry.dart';
import 'core/i18n/locale_utils.dart';
@@ -106,6 +109,8 @@ void main() async {
// 0. Initialize Logger
LoggerService.init();
await ThemeController.app.restore();
await ThemeController.auth.restore();
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
await _loadBundledFonts();
@@ -177,12 +182,18 @@ final _router = GoRouter(
GoRoute(
path: 'dashboard',
builder: (context, state) {
return const DashboardScreen();
return ScopedTheme(
controller: ThemeController.app,
child: const DashboardScreen(),
);
},
),
GoRoute(
path: 'profile',
builder: (context, state) => const ProfilePage(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.app,
child: const ProfilePage(),
),
),
GoRoute(
path: 'signin',
@@ -192,10 +203,13 @@ final _router = GoRouter(
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
return LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
),
);
},
),
@@ -208,10 +222,13 @@ final _router = GoRouter(
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
return LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
),
);
},
),
@@ -227,88 +244,137 @@ final _router = GoRouter(
),
);
}
return ConsentScreen(consentChallenge: consentChallenge);
return ScopedTheme(
controller: ThemeController.auth,
child: ConsentScreen(consentChallenge: consentChallenge),
);
},
),
GoRoute(
path: 'signup',
builder: (context, state) => const SignupScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const SignupScreen(),
),
),
GoRoute(
path: 'registration',
builder: (context, state) => const SignupScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const SignupScreen(),
),
),
GoRoute(
path: 'verify',
builder: (context, state) => LoginScreen(key: state.pageKey),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
),
),
GoRoute(
path: 'verify/:token',
builder: (context, state) {
final token = state.pathParameters['token'];
return LoginScreen(
key: state.pageKey,
verificationToken: token,
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey,
verificationToken: token,
),
);
},
),
GoRoute(
path: 'verification',
builder: (context, state) => LoginScreen(key: state.pageKey),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
),
),
GoRoute(
path: 'l/:shortCode',
builder: (context, state) {
return LoginScreen(key: state.pageKey);
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
);
},
),
GoRoute(
path: 'forgot-password',
builder: (context, state) => const ForgotPasswordScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ForgotPasswordScreen(),
),
),
GoRoute(
path: 'recovery',
builder: (context, state) => const ForgotPasswordScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ForgotPasswordScreen(),
),
),
GoRoute(
path: 'reset-password',
builder: (context, state) => const ResetPasswordScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ResetPasswordScreen(),
),
),
GoRoute(
path: 'error',
builder: (context, state) {
final params = state.uri.queryParameters;
return ErrorScreen(
errorId: params['id'],
errorCode: params['error'],
description: params['error_description'] ?? params['message'],
return ScopedTheme(
controller: ThemeController.auth,
child: ErrorScreen(
errorId: params['id'],
errorCode: params['error'],
description:
params['error_description'] ?? params['message'],
),
);
},
),
GoRoute(
path: 'settings',
builder: (context, state) => ErrorScreen(
errorCode: 'settings_disabled',
description: tr('msg.userfront.settings.disabled'),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ErrorScreen(
errorCode: 'settings_disabled',
description: tr('msg.userfront.settings.disabled'),
),
),
),
GoRoute(
path: 'approve',
builder: (context, state) =>
ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ApproveQrScreen(
pendingRef: state.uri.queryParameters['ref'],
),
),
),
GoRoute(
path: 'ql/:ref',
builder: (context, state) =>
ApproveQrScreen(pendingRef: state.pathParameters['ref']),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
),
),
GoRoute(
path: 'scan',
builder: (context, state) => const QRScanScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const QRScanScreen(),
),
),
GoRoute(
path: 'admin/users',
builder: (context, state) => const UserManagementScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.app,
child: const UserManagementScreen(),
),
),
],
),
@@ -376,40 +442,10 @@ class BaronSSOApp extends StatelessWidget {
children: [if (child != null) child, const ToastViewport()],
);
},
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
brightness: Brightness.light,
),
useMaterial3: true,
fontFamily: 'NotoSansKR',
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: NoTransitionsBuilder(),
TargetPlatform.iOS: NoTransitionsBuilder(),
TargetPlatform.linux: NoTransitionsBuilder(),
TargetPlatform.macOS: NoTransitionsBuilder(),
TargetPlatform.windows: NoTransitionsBuilder(),
TargetPlatform.fuchsia: NoTransitionsBuilder(),
},
),
),
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: ThemeMode.light,
routerConfig: _router,
);
}
}
class NoTransitionsBuilder extends PageTransitionsBuilder {
const NoTransitionsBuilder();
@override
Widget buildTransitions<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"
source: hosted
version: "3.2.0"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
url: "https://pub.dev"
source: hosted
version: "2.2.4"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -388,6 +396,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider_linux:
dependency: transitive
description:
@@ -485,7 +501,7 @@ packages:
source: hosted
version: "3.2.0"
shared_preferences:
dependency: transitive
dependency: "direct main"
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
@@ -753,6 +769,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
url: "https://pub.dev"
source: hosted
version: "1.1.21"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
vector_math:
dependency: transitive
description:
@@ -825,6 +865,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
yaml:
dependency: transitive
description:

View File

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

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