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:
13
Makefile
13
Makefile
@@ -107,12 +107,17 @@ logs-app:
|
||||
docker compose -f $(COMPOSE_APP) logs -f
|
||||
|
||||
# --- 로컬 통합 코드 체크 ---
|
||||
PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
|
||||
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
|
||||
PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE
|
||||
PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE
|
||||
|
||||
ifeq ($(CI),)
|
||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install chromium
|
||||
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
|
||||
else
|
||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
|
||||
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install --with-deps; fi'
|
||||
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install --with-deps chromium; fi'
|
||||
endif
|
||||
|
||||
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1501,6 +1501,7 @@ ory = ""
|
||||
session = ""
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
link_status_label = ""
|
||||
last_auth_label = ""
|
||||
status_history = ""
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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: "-",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "애플리케이션 정보"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "해지됨"
|
||||
|
||||
@@ -277,6 +277,7 @@ ory = ""
|
||||
session = ""
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
link_status_label = ""
|
||||
last_auth_label = ""
|
||||
status_history = ""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "해지됨"
|
||||
|
||||
@@ -148,6 +148,7 @@ ory = ""
|
||||
session = ""
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
link_status_label = ""
|
||||
last_auth_label = ""
|
||||
status_history = ""
|
||||
|
||||
|
||||
148
userfront/lib/core/theme/app_theme.dart
Normal file
148
userfront/lib/core/theme/app_theme.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
37
userfront/lib/core/theme/theme_controller.dart
Normal file
37
userfront/lib/core/theme/theme_controller.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
44
userfront/lib/core/theme/theme_scope.dart
Normal file
44
userfront/lib/core/theme/theme_scope.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
userfront/lib/core/widgets/theme_toggle_button.dart
Normal file
44
userfront/lib/core/widgets/theme_toggle_button.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
72
userfront/test/linked_rp_launch_test.dart
Normal file
72
userfront/test/linked_rp_launch_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
32
userfront/test/theme_controller_test.dart
Normal file
32
userfront/test/theme_controller_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user