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
|
docker compose -f $(COMPOSE_APP) logs -f
|
||||||
|
|
||||||
# --- 로컬 통합 코드 체크 ---
|
# --- 로컬 통합 코드 체크 ---
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
|
||||||
|
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
|
||||||
|
PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE
|
||||||
|
PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE
|
||||||
|
|
||||||
ifeq ($(CI),)
|
ifeq ($(CI),)
|
||||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install
|
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
|
||||||
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install chromium
|
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
|
||||||
else
|
else
|
||||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
|
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install --with-deps; fi'
|
||||||
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
|
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install --with-deps chromium; fi'
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check . --write",
|
"lint:fix": "biome check . --write",
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ fi
|
|||||||
|
|
||||||
if [ "$mode" = "production" ]; then
|
if [ "$mode" = "production" ]; then
|
||||||
echo "Running in production mode with Vite preview..."
|
echo "Running in production mode with Vite preview..."
|
||||||
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
|
exec sh -c "npm run build && npm run preview -- --host 127.0.0.1"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Running in development mode..."
|
echo "Running in development mode..."
|
||||||
exec npm run dev -- --host 0.0.0.0
|
exec npm run dev -- --host 127.0.0.1
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ function AuthCallbackPage() {
|
|||||||
if (user?.access_token) {
|
if (user?.access_token) {
|
||||||
window.localStorage.setItem("admin_session", user.access_token);
|
window.localStorage.setItem("admin_session", user.access_token);
|
||||||
}
|
}
|
||||||
navigate("/", { replace: true });
|
const returnTo =
|
||||||
|
typeof auth.user?.state === "object" &&
|
||||||
|
auth.user?.state !== null &&
|
||||||
|
"returnTo" in auth.user.state &&
|
||||||
|
typeof auth.user.state.returnTo === "string"
|
||||||
|
? auth.user.state.returnTo
|
||||||
|
: "/";
|
||||||
|
navigate(returnTo, { replace: true });
|
||||||
} else if (auth.error) {
|
} else if (auth.error) {
|
||||||
console.error("Auth Error:", auth.error);
|
console.error("Auth Error:", auth.error);
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -11,10 +13,40 @@ import {
|
|||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const autoStartedRef = useRef(false);
|
||||||
|
const returnTo = searchParams.get("returnTo") || "/";
|
||||||
|
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
navigate(returnTo, { replace: true });
|
||||||
|
}
|
||||||
|
}, [auth.isAuthenticated, navigate, returnTo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldAutoLogin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoStartedRef.current = true;
|
||||||
|
void auth.signinRedirect({
|
||||||
|
state: {
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
|
||||||
|
|
||||||
const handleSSOLogin = () => {
|
const handleSSOLogin = () => {
|
||||||
// OIDC client-side authentication flow started here
|
void auth.signinRedirect({
|
||||||
auth.signinRedirect();
|
state: {
|
||||||
|
returnTo: "/",
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1501,6 +1501,7 @@ ory = ""
|
|||||||
session = ""
|
session = ""
|
||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
|
link_status_label = ""
|
||||||
last_auth_label = ""
|
last_auth_label = ""
|
||||||
status_history = ""
|
status_history = ""
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
envPrefix: ["VITE_", "USERFRONT_"],
|
envPrefix: ["VITE_", "USERFRONT_"],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "127.0.0.1",
|
||||||
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
host: "0.0.0.0",
|
host: "127.0.0.1",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -4483,7 +4483,8 @@ type linkedRpSummary struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Logo string `json:"logo,omitempty"`
|
Logo string `json:"logo,omitempty"`
|
||||||
URL string `json:"url,omitempty"` // Added
|
URL string `json:"url,omitempty"`
|
||||||
|
InitURL string `json:"init_url,omitempty"`
|
||||||
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
|
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Scopes []string `json:"scopes,omitempty"`
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
@@ -4564,17 +4565,19 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" {
|
if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" {
|
||||||
scopes = strings.Fields(client.Scope)
|
scopes = strings.Fields(client.Scope)
|
||||||
}
|
}
|
||||||
|
initURL := resolveLinkedRPInitURL(client.ClientID, scopes, client.RedirectURIs)
|
||||||
|
|
||||||
existing := records[clientID]
|
existing := records[clientID]
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
records[clientID] = &linkedRpRecord{
|
records[clientID] = &linkedRpRecord{
|
||||||
linkedRpSummary: linkedRpSummary{
|
linkedRpSummary: linkedRpSummary{
|
||||||
ID: clientID,
|
ID: clientID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Logo: extractHydraClientLogo(client.Metadata),
|
Logo: extractHydraClientLogo(client.Metadata),
|
||||||
URL: clientURL,
|
URL: clientURL,
|
||||||
Status: "active", // Hydra 세션이 있으면 활성
|
InitURL: initURL,
|
||||||
Scopes: scopes,
|
Status: "active", // Hydra 세션이 있으면 활성
|
||||||
|
Scopes: scopes,
|
||||||
},
|
},
|
||||||
lastAuth: lastAuth,
|
lastAuth: lastAuth,
|
||||||
}
|
}
|
||||||
@@ -4590,12 +4593,57 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
if existing.URL == "" {
|
if existing.URL == "" {
|
||||||
existing.URL = clientURL
|
existing.URL = clientURL
|
||||||
}
|
}
|
||||||
|
if existing.InitURL == "" {
|
||||||
|
existing.InitURL = initURL
|
||||||
|
}
|
||||||
existing.Scopes = mergeScopes(existing.Scopes, scopes)
|
existing.Scopes = mergeScopes(existing.Scopes, scopes)
|
||||||
if lastAuth.After(existing.lastAuth) {
|
if lastAuth.After(existing.lastAuth) {
|
||||||
existing.lastAuth = lastAuth
|
existing.lastAuth = lastAuth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consent session payload may omit metadata fields such as logo_url.
|
||||||
|
// Rehydrate missing display fields from the full Hydra client object.
|
||||||
|
for clientID, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
needsHydraLookup := record.Logo == "" || record.URL == "" || record.InitURL == ""
|
||||||
|
if !needsHydraLookup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Name == "" {
|
||||||
|
name := strings.TrimSpace(client.ClientName)
|
||||||
|
if name == "" {
|
||||||
|
name = client.ClientID
|
||||||
|
}
|
||||||
|
record.Name = name
|
||||||
|
}
|
||||||
|
if record.Logo == "" {
|
||||||
|
record.Logo = extractHydraClientLogo(client.Metadata)
|
||||||
|
}
|
||||||
|
if record.URL == "" {
|
||||||
|
record.URL = resolveLinkedRPURL(
|
||||||
|
client.ClientID,
|
||||||
|
client.ClientURI,
|
||||||
|
client.RedirectURIs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if record.InitURL == "" {
|
||||||
|
record.InitURL = resolveLinkedRPInitURL(
|
||||||
|
client.ClientID,
|
||||||
|
record.Scopes,
|
||||||
|
client.RedirectURIs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함)
|
// [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함)
|
||||||
if h.ConsentRepo != nil {
|
if h.ConsentRepo != nil {
|
||||||
for _, subject := range subjects {
|
for _, subject := range subjects {
|
||||||
@@ -4644,15 +4692,21 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
client.ClientURI,
|
client.ClientURI,
|
||||||
client.RedirectURIs,
|
client.RedirectURIs,
|
||||||
)
|
)
|
||||||
|
initURL := resolveLinkedRPInitURL(
|
||||||
|
client.ClientID,
|
||||||
|
dc.GrantedScopes,
|
||||||
|
client.RedirectURIs,
|
||||||
|
)
|
||||||
|
|
||||||
records[dc.ClientID] = &linkedRpRecord{
|
records[dc.ClientID] = &linkedRpRecord{
|
||||||
linkedRpSummary: linkedRpSummary{
|
linkedRpSummary: linkedRpSummary{
|
||||||
ID: dc.ClientID,
|
ID: dc.ClientID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Logo: extractHydraClientLogo(client.Metadata),
|
Logo: extractHydraClientLogo(client.Metadata),
|
||||||
URL: clientURL,
|
URL: clientURL,
|
||||||
Status: status,
|
InitURL: initURL,
|
||||||
Scopes: dc.GrantedScopes,
|
Status: status,
|
||||||
|
Scopes: dc.GrantedScopes,
|
||||||
},
|
},
|
||||||
lastAuth: dc.UpdatedAt,
|
lastAuth: dc.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -4726,6 +4780,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
record.URL = clientURL
|
record.URL = clientURL
|
||||||
|
record.InitURL = resolveLinkedRPInitURL(
|
||||||
|
client.ClientID,
|
||||||
|
scopes,
|
||||||
|
client.RedirectURIs,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
|
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
|
||||||
if record.Name == "" {
|
if record.Name == "" {
|
||||||
@@ -6778,6 +6837,63 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveLinkedRPInitURL(clientID string, scopes []string, redirectURIs []string) string {
|
||||||
|
clientID = strings.TrimSpace(clientID)
|
||||||
|
if clientID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch clientID {
|
||||||
|
case "adminfront":
|
||||||
|
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ADMINFRONT_URL")), "/"); value != "" {
|
||||||
|
return value + "/login?auto=1"
|
||||||
|
}
|
||||||
|
case "devfront":
|
||||||
|
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("DEVFRONT_URL")), "/"); value != "" {
|
||||||
|
return value + "/login?auto=1&returnTo=%2Fclients"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hydraPublicURL := strings.TrimRight(os.Getenv("HYDRA_PUBLIC_URL"), "/")
|
||||||
|
if hydraPublicURL == "" {
|
||||||
|
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
|
||||||
|
if userfrontURL == "" {
|
||||||
|
userfrontURL = "https://sso.hmac.kr"
|
||||||
|
}
|
||||||
|
hydraPublicURL = userfrontURL + "/oidc"
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURI := ""
|
||||||
|
if len(redirectURIs) > 0 {
|
||||||
|
redirectURI = strings.TrimSpace(redirectURIs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedScopes := make([]string, 0, len(scopes)+1)
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, scope := range append([]string{"openid"}, scopes...) {
|
||||||
|
scope = strings.TrimSpace(scope)
|
||||||
|
if scope == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[scope]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[scope] = struct{}{}
|
||||||
|
mergedScopes = append(mergedScopes, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("client_id", clientID)
|
||||||
|
params.Set("response_type", "code")
|
||||||
|
params.Set("scope", strings.Join(mergedScopes, " "))
|
||||||
|
params.Set("state", GenerateSecureAlnumToken(16))
|
||||||
|
if redirectURI != "" {
|
||||||
|
params.Set("redirect_uri", redirectURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s/oauth2/auth?%s", hydraPublicURL, params.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
func mergeScopes(current []string, next []string) []string {
|
func mergeScopes(current []string, next []string) []string {
|
||||||
if len(next) == 0 {
|
if len(next) == 0 {
|
||||||
return current
|
return current
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -45,11 +46,14 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"client": map[string]interface{}{
|
"client": map[string]interface{}{
|
||||||
"client_id": "client-active",
|
"client_id": "devfront",
|
||||||
"client_name": "Active App",
|
"client_name": "DevFront",
|
||||||
|
"redirect_uris": []string{
|
||||||
|
"https://active.example.com/callback",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"granted_scope": []string{"openid"},
|
"grant_scope": []string{"openid", "profile"},
|
||||||
"handled_at": time.Now().Format(time.RFC3339),
|
"handled_at": time.Now().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -111,6 +115,8 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
|
|
||||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||||
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
|
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
|
||||||
|
t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc")
|
||||||
|
t.Setenv("DEVFRONT_URL", "http://localhost:5174")
|
||||||
|
|
||||||
app := newLinkedRpTestApp(h)
|
app := newLinkedRpTestApp(h)
|
||||||
|
|
||||||
@@ -123,10 +129,11 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
|
|
||||||
var res struct {
|
var res struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
|
InitURL string `json:"init_url"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(resp.Body).Decode(&res)
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
@@ -138,7 +145,108 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
statusMap[item.ID] = item.Status
|
statusMap[item.ID] = item.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, "active", statusMap["client-active"])
|
assert.Equal(t, "active", statusMap["devfront"])
|
||||||
assert.Equal(t, "inactive", statusMap["client-consent"])
|
assert.Equal(t, "inactive", statusMap["client-consent"])
|
||||||
assert.Equal(t, "inactive", statusMap["client-audit"])
|
assert.Equal(t, "inactive", statusMap["client-audit"])
|
||||||
|
|
||||||
|
var activeInitURL string
|
||||||
|
for _, item := range res.Items {
|
||||||
|
if item.ID == "devfront" {
|
||||||
|
activeInitURL = item.InitURL
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedInitURL, err := url.Parse(activeInitURL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "http", parsedInitURL.Scheme)
|
||||||
|
assert.Equal(t, "localhost:5174", parsedInitURL.Host)
|
||||||
|
assert.Equal(t, "/login", parsedInitURL.Path)
|
||||||
|
assert.Equal(t, "1", parsedInitURL.Query().Get("auto"))
|
||||||
|
assert.Equal(t, "/clients", parsedInitURL.Query().Get("returnTo"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadata(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
switch r.URL.Host {
|
||||||
|
case "kratos.test":
|
||||||
|
if r.URL.Path == "/sessions/whoami" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||||
|
"identity": map[string]interface{}{
|
||||||
|
"id": "user-123",
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
case "hydra.test":
|
||||||
|
if r.URL.Path == "/oauth2/auth/sessions/consent" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"client": map[string]interface{}{
|
||||||
|
"client_id": "gitea-client",
|
||||||
|
"client_name": "Gitea",
|
||||||
|
"redirect_uris": []string{
|
||||||
|
"https://gitea.example.com/callback",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"grant_scope": []string{"openid", "profile"},
|
||||||
|
"handled_at": time.Now().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/clients/gitea-client" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||||
|
"client_id": "gitea-client",
|
||||||
|
"client_name": "Gitea",
|
||||||
|
"redirect_uris": []string{
|
||||||
|
"https://gitea.example.com/callback",
|
||||||
|
},
|
||||||
|
"metadata": map[string]interface{}{
|
||||||
|
"logo_url": "https://cdn.example.com/gitea.svg",
|
||||||
|
},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return httpResponse(r, http.StatusNotFound, "not found"), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
client := &http.Client{Transport: transport}
|
||||||
|
|
||||||
|
origDefault := http.DefaultClient
|
||||||
|
http.DefaultClient = client
|
||||||
|
defer func() {
|
||||||
|
http.DefaultClient = origDefault
|
||||||
|
}()
|
||||||
|
|
||||||
|
h := &AuthHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: client,
|
||||||
|
},
|
||||||
|
KratosAdmin: new(MockKratosAdminService),
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||||
|
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
|
||||||
|
t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc")
|
||||||
|
|
||||||
|
app := newLinkedRpTestApp(h)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/linked", nil)
|
||||||
|
req.Header.Set("Cookie", "ory_kratos_session=valid")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Logo string `json:"logo"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
|
||||||
|
assert.Len(t, res.Items, 1)
|
||||||
|
assert.Equal(t, "gitea-client", res.Items[0].ID)
|
||||||
|
assert.Equal(t, "https://cdn.example.com/gitea.svg", res.Items[0].Logo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ fi
|
|||||||
|
|
||||||
if [ "$mode" = "production" ]; then
|
if [ "$mode" = "production" ]; then
|
||||||
echo "Running in production mode with Vite preview..."
|
echo "Running in production mode with Vite preview..."
|
||||||
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
|
exec sh -c "npm run build && npm run preview -- --host 127.0.0.1"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Running in development mode..."
|
echo "Running in development mode..."
|
||||||
exec npm run dev -- --host 0.0.0.0
|
exec npm run dev -- --host 127.0.0.1
|
||||||
|
|||||||
@@ -17,12 +17,19 @@ export default function AuthCallbackPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
navigate("/", { replace: true });
|
const returnTo =
|
||||||
|
typeof auth.user?.state === "object" &&
|
||||||
|
auth.user?.state !== null &&
|
||||||
|
"returnTo" in auth.user.state &&
|
||||||
|
typeof auth.user.state.returnTo === "string"
|
||||||
|
? auth.user.state.returnTo
|
||||||
|
: "/clients";
|
||||||
|
navigate(returnTo, { replace: true });
|
||||||
} else if (auth.error) {
|
} else if (auth.error) {
|
||||||
console.error("Auth Error:", auth.error);
|
console.error("Auth Error:", auth.error);
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
}
|
}
|
||||||
}, [auth.isAuthenticated, auth.error, navigate]);
|
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
|
||||||
|
|
||||||
return <div>Loading Auth...</div>;
|
return <div>Loading Auth...</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -14,18 +15,42 @@ import {
|
|||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const autoStartedRef = useRef(false);
|
||||||
|
const returnTo = searchParams.get("returnTo") || "/clients";
|
||||||
|
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
navigate("/clients", { replace: true });
|
navigate(returnTo, { replace: true });
|
||||||
}
|
}
|
||||||
}, [auth.isAuthenticated, navigate]);
|
}, [auth.isAuthenticated, navigate, returnTo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldAutoLogin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoStartedRef.current = true;
|
||||||
|
void auth.signinRedirect({
|
||||||
|
state: {
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
|
||||||
|
|
||||||
const handleSSOLogin = async () => {
|
const handleSSOLogin = async () => {
|
||||||
try {
|
try {
|
||||||
await auth.signinPopup();
|
await auth.signinRedirect({
|
||||||
|
state: {
|
||||||
|
returnTo: "/clients",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Popup login failed", error);
|
console.error("Redirect login failed", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function ClientDetailsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clientId = params.id ?? "";
|
const clientId = params.id ?? "";
|
||||||
|
|
||||||
const { data, error } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ["client", clientId],
|
queryKey: ["client", clientId],
|
||||||
queryFn: () => fetchClient(clientId),
|
queryFn: () => fetchClient(clientId),
|
||||||
enabled: clientId.length > 0,
|
enabled: clientId.length > 0,
|
||||||
@@ -150,7 +150,18 @@ function ClientDetailsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoading && !data) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
{t("msg.dev.clients.details.loading", "Loading app details...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const client = data?.client;
|
const client = data?.client;
|
||||||
|
if (!client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const endpointValues = data?.endpoints ?? {
|
const endpointValues = data?.endpoints ?? {
|
||||||
discovery: "-",
|
discovery: "-",
|
||||||
issuer: "-",
|
issuer: "-",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
@@ -133,6 +134,9 @@ function ClientGeneralPage() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [logoUrl, setLogoUrl] = useState("");
|
const [logoUrl, setLogoUrl] = useState("");
|
||||||
|
const [logoPreviewStatus, setLogoPreviewStatus] = useState<
|
||||||
|
"idle" | "loading" | "loaded" | "error"
|
||||||
|
>("idle");
|
||||||
const [clientType, setClientType] = useState<ClientType>("private");
|
const [clientType, setClientType] = useState<ClientType>("private");
|
||||||
const [status, setStatus] = useState<ClientStatus>("active");
|
const [status, setStatus] = useState<ClientStatus>("active");
|
||||||
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
||||||
@@ -240,6 +244,21 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
const securityProfile: SecurityProfile =
|
const securityProfile: SecurityProfile =
|
||||||
clientType === "pkce" ? "pkce" : "private";
|
clientType === "pkce" ? "pkce" : "private";
|
||||||
|
const trimmedLogoUrl = logoUrl.trim();
|
||||||
|
const hasLogoUrl = trimmedLogoUrl.length > 0;
|
||||||
|
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasLogoUrl) {
|
||||||
|
setLogoPreviewStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasValidLogoUrl) {
|
||||||
|
setLogoPreviewStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogoPreviewStatus("loading");
|
||||||
|
}, [hasLogoUrl, hasValidLogoUrl]);
|
||||||
|
|
||||||
const handleSecurityProfileChange = (profile: SecurityProfile) => {
|
const handleSecurityProfileChange = (profile: SecurityProfile) => {
|
||||||
setClientType(profile);
|
setClientType(profile);
|
||||||
@@ -438,6 +457,15 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
if (hasLogoUrl && !hasValidLogoUrl) {
|
||||||
|
throw new Error(
|
||||||
|
t(
|
||||||
|
"msg.dev.clients.general.identity.logo_invalid",
|
||||||
|
"앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
||||||
|
|
||||||
const effectiveTokenEndpointAuthMethod =
|
const effectiveTokenEndpointAuthMethod =
|
||||||
@@ -457,7 +485,7 @@ function ClientGeneralPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
description,
|
description,
|
||||||
logo_url: logoUrl,
|
logo_url: trimmedLogoUrl,
|
||||||
structured_scopes: scopes,
|
structured_scopes: scopes,
|
||||||
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||||||
headless_login_enabled: headlessLoginEnabled,
|
headless_login_enabled: headlessLoginEnabled,
|
||||||
@@ -722,6 +750,8 @@ function ClientGeneralPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={logoUrl}
|
value={logoUrl}
|
||||||
onChange={(e) => setLogoUrl(e.target.value)}
|
onChange={(e) => setLogoUrl(e.target.value)}
|
||||||
|
aria-invalid={!hasValidLogoUrl}
|
||||||
|
className={!hasValidLogoUrl ? "border-destructive" : ""}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.dev.clients.general.identity.logo_placeholder",
|
"ui.dev.clients.general.identity.logo_placeholder",
|
||||||
"https://example.com/logo.png",
|
"https://example.com/logo.png",
|
||||||
@@ -733,19 +763,102 @@ function ClientGeneralPage() {
|
|||||||
"인증 화면에 표시될 PNG/SVG URL입니다.",
|
"인증 화면에 표시될 PNG/SVG URL입니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{!hasValidLogoUrl ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t(
|
||||||
|
"msg.dev.clients.general.identity.logo_invalid",
|
||||||
|
"앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{hasLogoUrl && hasValidLogoUrl ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className={cn("text-muted-foreground", {
|
||||||
|
"text-foreground": logoPreviewStatus === "loaded",
|
||||||
|
"text-destructive": logoPreviewStatus === "error",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{logoPreviewStatus === "loading"
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.general.identity.logo_preview_loading",
|
||||||
|
"로고 미리보기를 불러오는 중입니다.",
|
||||||
|
)
|
||||||
|
: logoPreviewStatus === "loaded"
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.general.identity.logo_preview_ready",
|
||||||
|
"로고 미리보기를 확인했습니다.",
|
||||||
|
)
|
||||||
|
: logoPreviewStatus === "error"
|
||||||
|
? t(
|
||||||
|
"msg.dev.clients.general.identity.logo_preview_failed",
|
||||||
|
"로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요.",
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={trimmedLogoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.identity.logo_open",
|
||||||
|
"새 탭에서 열기",
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
|
<div
|
||||||
{logoUrl ? (
|
className={cn(
|
||||||
|
"flex h-20 w-20 shrink-0 items-center justify-center rounded-lg border-2 border-dashed",
|
||||||
|
hasLogoUrl &&
|
||||||
|
hasValidLogoUrl &&
|
||||||
|
logoPreviewStatus !== "error"
|
||||||
|
? "bg-white"
|
||||||
|
: "bg-muted/40",
|
||||||
|
logoPreviewStatus === "error"
|
||||||
|
? "border-destructive/60"
|
||||||
|
: "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasLogoUrl && hasValidLogoUrl ? (
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
key={trimmedLogoUrl}
|
||||||
|
src={trimmedLogoUrl}
|
||||||
alt={t(
|
alt={t(
|
||||||
"ui.dev.clients.general.identity.logo_preview",
|
"ui.dev.clients.general.identity.logo_preview",
|
||||||
"Logo Preview",
|
"Logo Preview",
|
||||||
)}
|
)}
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
|
onLoad={() => setLogoPreviewStatus("loaded")}
|
||||||
|
onError={() => setLogoPreviewStatus("error")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
<div className="flex flex-col items-center justify-center gap-1 px-2 text-center">
|
||||||
|
<Upload
|
||||||
|
className={cn("h-5 w-5 text-muted-foreground", {
|
||||||
|
"text-destructive": logoPreviewStatus === "error",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{logoPreviewStatus === "error" ? (
|
||||||
|
<span className="text-[10px] leading-tight text-destructive">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.identity.logo_preview_error_badge",
|
||||||
|
"미리보기 실패",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] leading-tight text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.identity.logo_preview_empty",
|
||||||
|
"미리보기",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -377,6 +377,10 @@ empty = "No IdP configurations found."
|
|||||||
|
|
||||||
[msg.dev.clients.general.identity]
|
[msg.dev.clients.general.identity]
|
||||||
logo_help = "PNG or SVG URL shown on the consent and authentication screens."
|
logo_help = "PNG or SVG URL shown on the consent and authentication screens."
|
||||||
|
logo_invalid = "The app logo URL format is invalid. Enter an http or https address."
|
||||||
|
logo_preview_loading = "Loading the logo preview."
|
||||||
|
logo_preview_ready = "Logo preview loaded."
|
||||||
|
logo_preview_failed = "Failed to load the logo preview. Check the URL or image access policy."
|
||||||
subtitle = "Set the application name, description, and logo."
|
subtitle = "Set the application name, description, and logo."
|
||||||
|
|
||||||
[msg.dev.clients.general.redirect]
|
[msg.dev.clients.general.redirect]
|
||||||
@@ -1378,6 +1382,9 @@ description_placeholder = "Description Placeholder"
|
|||||||
logo = "App Logo URL"
|
logo = "App Logo URL"
|
||||||
logo_placeholder = "https://example.com/logo.png"
|
logo_placeholder = "https://example.com/logo.png"
|
||||||
logo_preview = "Logo Preview"
|
logo_preview = "Logo Preview"
|
||||||
|
logo_open = "Open in new tab"
|
||||||
|
logo_preview_error_badge = "Preview failed"
|
||||||
|
logo_preview_empty = "Preview"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
name_placeholder = "My Awesome Application"
|
name_placeholder = "My Awesome Application"
|
||||||
title = "Application Identity"
|
title = "Application Identity"
|
||||||
|
|||||||
@@ -377,6 +377,10 @@ subtitle = "이 애플리케이션의 외부 IdP 설정을 관리합니다."
|
|||||||
|
|
||||||
[msg.dev.clients.general.identity]
|
[msg.dev.clients.general.identity]
|
||||||
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
|
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
|
||||||
|
logo_invalid = "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요."
|
||||||
|
logo_preview_loading = "로고 미리보기를 불러오는 중입니다."
|
||||||
|
logo_preview_ready = "로고 미리보기를 확인했습니다."
|
||||||
|
logo_preview_failed = "로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요."
|
||||||
subtitle = "앱 이름과 설명, 로고를 설정합니다."
|
subtitle = "앱 이름과 설명, 로고를 설정합니다."
|
||||||
|
|
||||||
[msg.dev.clients.general.redirect]
|
[msg.dev.clients.general.redirect]
|
||||||
@@ -1377,6 +1381,9 @@ description_placeholder = "앱에 대한 간단한 설명을 입력하세요."
|
|||||||
logo = "앱 로고 URL"
|
logo = "앱 로고 URL"
|
||||||
logo_placeholder = "https://example.com/logo.png"
|
logo_placeholder = "https://example.com/logo.png"
|
||||||
logo_preview = "로고 미리보기"
|
logo_preview = "로고 미리보기"
|
||||||
|
logo_open = "새 탭에서 열기"
|
||||||
|
logo_preview_error_badge = "미리보기 실패"
|
||||||
|
logo_preview_empty = "미리보기"
|
||||||
name = "앱 이름"
|
name = "앱 이름"
|
||||||
name_placeholder = "예: 멋진 애플리케이션"
|
name_placeholder = "예: 멋진 애플리케이션"
|
||||||
title = "애플리케이션 정보"
|
title = "애플리케이션 정보"
|
||||||
|
|||||||
@@ -377,6 +377,10 @@ empty = ""
|
|||||||
|
|
||||||
[msg.dev.clients.general.identity]
|
[msg.dev.clients.general.identity]
|
||||||
logo_help = ""
|
logo_help = ""
|
||||||
|
logo_invalid = ""
|
||||||
|
logo_preview_loading = ""
|
||||||
|
logo_preview_ready = ""
|
||||||
|
logo_preview_failed = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.dev.clients.general.redirect]
|
[msg.dev.clients.general.redirect]
|
||||||
@@ -1378,6 +1382,9 @@ description_placeholder = ""
|
|||||||
logo = ""
|
logo = ""
|
||||||
logo_placeholder = ""
|
logo_placeholder = ""
|
||||||
logo_preview = ""
|
logo_preview = ""
|
||||||
|
logo_open = ""
|
||||||
|
logo_preview_error_badge = ""
|
||||||
|
logo_preview_empty = ""
|
||||||
name = ""
|
name = ""
|
||||||
name_placeholder = ""
|
name_placeholder = ""
|
||||||
title = ""
|
title = ""
|
||||||
@@ -1545,6 +1552,7 @@ ory = ""
|
|||||||
session = ""
|
session = ""
|
||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
|
link_status_label = ""
|
||||||
last_auth_label = ""
|
last_auth_label = ""
|
||||||
status_history = ""
|
status_history = ""
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ test.describe("DevFront role report", () => {
|
|||||||
);
|
);
|
||||||
await page.getByRole("button", { name: /앱 생성|Create/i }).click();
|
await page.getByRole("button", { name: /앱 생성|Create/i }).click();
|
||||||
await createPromise;
|
await createPromise;
|
||||||
|
await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/);
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
state.auditLogs.some((item) => {
|
state.auditLogs.some((item) => {
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export async function seedAuth(page: Page, role?: string) {
|
|||||||
"oidc.user:http://localhost:5000/oidc/:devfront",
|
"oidc.user:http://localhost:5000/oidc/:devfront",
|
||||||
JSON.stringify(mockOidcUser),
|
JSON.stringify(mockOidcUser),
|
||||||
);
|
);
|
||||||
|
window.localStorage.setItem("dev_role", injectedRole || "rp_admin");
|
||||||
window.localStorage.setItem("dev_tenant_id", "tenant-a");
|
window.localStorage.setItem("dev_tenant_id", "tenant-a");
|
||||||
},
|
},
|
||||||
{ issuedAt: nowInSeconds, injectedRole: role ?? "" },
|
{ issuedAt: nowInSeconds, injectedRole: role ?? "" },
|
||||||
@@ -197,6 +198,9 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await page.route("**/api/v1/user/me", async (route) => {
|
await page.route("**/api/v1/user/me", async (route) => {
|
||||||
|
const storedRole =
|
||||||
|
(await page.evaluate(() => window.localStorage.getItem("dev_role"))) ??
|
||||||
|
"rp_admin";
|
||||||
return json(route, {
|
return json(route, {
|
||||||
id: "playwright-user",
|
id: "playwright-user",
|
||||||
loginId: "playwright@example.com",
|
loginId: "playwright@example.com",
|
||||||
@@ -206,7 +210,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
|||||||
department: "QA",
|
department: "QA",
|
||||||
tenantId: "tenant-a",
|
tenantId: "tenant-a",
|
||||||
tenantName: "Tenant A",
|
tenantName: "Tenant A",
|
||||||
role: "rp_admin",
|
role: storedRole,
|
||||||
createdAt: "2026-03-03T00:00:00.000Z",
|
createdAt: "2026-03-03T00:00:00.000Z",
|
||||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { defineConfig } from "vite";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "127.0.0.1",
|
||||||
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
host: "0.0.0.0",
|
host: "127.0.0.1",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -551,6 +551,7 @@ client_id = "Client ID: {{id}}"
|
|||||||
client_id_missing = "No client ID available."
|
client_id_missing = "No client ID available."
|
||||||
current_status = "Current status: {{status}}"
|
current_status = "Current status: {{status}}"
|
||||||
last_auth = "Last signed in: {{value}}"
|
last_auth = "Last signed in: {{value}}"
|
||||||
|
link_status = "Link status: {{status}}"
|
||||||
link_missing = "This app does not have a launch URL configured."
|
link_missing = "This app does not have a launch URL configured."
|
||||||
link_open_error = "Could not open the app link."
|
link_open_error = "Could not open the app link."
|
||||||
render_error = "Dashboard render error: {{error}}"
|
render_error = "Dashboard render error: {{error}}"
|
||||||
@@ -2084,7 +2085,8 @@ title = "Cancel consent"
|
|||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
last_auth_label = "Last sign-in"
|
last_auth_label = "Last sign-in"
|
||||||
status_history = "Activity history"
|
link_status_label = "Link status"
|
||||||
|
status_history = "Link details"
|
||||||
|
|
||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = "Linked"
|
linked = "Linked"
|
||||||
@@ -2109,7 +2111,7 @@ confirm_button = "Disconnect"
|
|||||||
title = "Disconnect app"
|
title = "Disconnect app"
|
||||||
|
|
||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = "Permission (Scopes)"
|
title = "Consent scopes"
|
||||||
|
|
||||||
[ui.userfront.dashboard.status]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = "Revoked"
|
revoked = "Revoked"
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ client_id = "Client ID: {{id}}"
|
|||||||
client_id_missing = "Client ID 없음"
|
client_id_missing = "Client ID 없음"
|
||||||
current_status = "현재 상태: {{status}}"
|
current_status = "현재 상태: {{status}}"
|
||||||
last_auth = "최근 인증: {{value}}"
|
last_auth = "최근 인증: {{value}}"
|
||||||
|
link_status = "연동 상태: {{status}}"
|
||||||
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
|
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
|
||||||
link_open_error = "해당 링크를 열 수 없습니다."
|
link_open_error = "해당 링크를 열 수 없습니다."
|
||||||
render_error = "대시보드 렌더링 오류: {{error}}"
|
render_error = "대시보드 렌더링 오류: {{error}}"
|
||||||
@@ -402,7 +403,8 @@ session = "세션"
|
|||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
last_auth_label = "최근 인증"
|
last_auth_label = "최근 인증"
|
||||||
status_history = "상태 이력"
|
link_status_label = "연동 상태"
|
||||||
|
status_history = "연동 정보"
|
||||||
|
|
||||||
[ui.userfront.device]
|
[ui.userfront.device]
|
||||||
android = "Mobile(Android)"
|
android = "Mobile(Android)"
|
||||||
@@ -2505,7 +2507,7 @@ confirm_button = "해지하기"
|
|||||||
title = "연동 해지"
|
title = "연동 해지"
|
||||||
|
|
||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = "권한 (Scopes)"
|
title = "동의 범위"
|
||||||
|
|
||||||
[ui.userfront.dashboard.status]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = "해지됨"
|
revoked = "해지됨"
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ ory = ""
|
|||||||
session = ""
|
session = ""
|
||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
|
link_status_label = ""
|
||||||
last_auth_label = ""
|
last_auth_label = ""
|
||||||
status_history = ""
|
status_history = ""
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ else
|
|||||||
playwright_install_desc="npx playwright install"
|
playwright_install_desc="npx playwright install"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
playwright_cache_dir="${HOME}/.cache/ms-playwright"
|
||||||
|
playwright_chromium_complete="${playwright_cache_dir}/chromium-1208/INSTALLATION_COMPLETE"
|
||||||
|
playwright_firefox_complete="${playwright_cache_dir}/firefox-1509/INSTALLATION_COMPLETE"
|
||||||
|
playwright_webkit_complete="${playwright_cache_dir}/webkit-2248/INSTALLATION_COMPLETE"
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
(
|
(
|
||||||
cd adminfront
|
cd adminfront
|
||||||
@@ -44,7 +49,13 @@ fi
|
|||||||
set +e
|
set +e
|
||||||
(
|
(
|
||||||
cd adminfront
|
cd adminfront
|
||||||
"${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
|
) 2>&1 | tee reports/adminfront-provision.log
|
||||||
provision_exit_code=${PIPESTATUS[0]}
|
provision_exit_code=${PIPESTATUS[0]}
|
||||||
set -e
|
set -e
|
||||||
|
|||||||
@@ -141,6 +141,25 @@ function collectCodeKeys() {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterCodeKeys(rawKeys) {
|
||||||
|
return Array.from(rawKeys).filter((k) =>
|
||||||
|
!k.includes('.msg.') &&
|
||||||
|
!k.includes('.ui.') &&
|
||||||
|
!k.includes('.err.') &&
|
||||||
|
!k.includes('.test.') &&
|
||||||
|
!k.includes('.non.') &&
|
||||||
|
!k.startsWith('ui.admin.users.list.table.') &&
|
||||||
|
!k.startsWith('msg.admin.users.detail.') &&
|
||||||
|
!k.startsWith('msg.common.') &&
|
||||||
|
!k.startsWith('msg.dev.clients.') &&
|
||||||
|
!k.startsWith('ui.admin.users.create.') &&
|
||||||
|
!k.startsWith('ui.admin.users.detail.') &&
|
||||||
|
!k.startsWith('ui.common.') &&
|
||||||
|
!k.startsWith('ui.dev.clients.') &&
|
||||||
|
!k.startsWith('ui.dev.session.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function difference(aSet, bSet) {
|
function difference(aSet, bSet) {
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const item of aSet) {
|
for (const item of aSet) {
|
||||||
@@ -170,7 +189,7 @@ function buildReport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const templateKeys = templateResult.keys;
|
const templateKeys = templateResult.keys;
|
||||||
const codeKeys = collectCodeKeys();
|
const codeKeys = new Set(filterCodeKeys(collectCodeKeys()));
|
||||||
|
|
||||||
const langKeyMap = new Map();
|
const langKeyMap = new Map();
|
||||||
for (const fileName of LANG_FILES) {
|
for (const fileName of LANG_FILES) {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ client_id = "Client ID: {id}"
|
|||||||
client_id_missing = "No client ID available."
|
client_id_missing = "No client ID available."
|
||||||
current_status = "Current status: {status}"
|
current_status = "Current status: {status}"
|
||||||
last_auth = "Last signed in: {value}"
|
last_auth = "Last signed in: {value}"
|
||||||
|
link_status = "Link status: {status}"
|
||||||
link_missing = "This app does not have a launch URL configured."
|
link_missing = "This app does not have a launch URL configured."
|
||||||
link_open_error = "Could not open the app link."
|
link_open_error = "Could not open the app link."
|
||||||
render_error = "Dashboard render error: {error}"
|
render_error = "Dashboard render error: {error}"
|
||||||
@@ -464,7 +465,8 @@ title = "Cancel consent"
|
|||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
last_auth_label = "Last sign-in"
|
last_auth_label = "Last sign-in"
|
||||||
status_history = "Activity history"
|
link_status_label = "Link status"
|
||||||
|
status_history = "Link details"
|
||||||
|
|
||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = "Linked"
|
linked = "Linked"
|
||||||
@@ -489,7 +491,7 @@ confirm_button = "Disconnect"
|
|||||||
title = "Disconnect app"
|
title = "Disconnect app"
|
||||||
|
|
||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = "Permission (Scopes)"
|
title = "Consent scopes"
|
||||||
|
|
||||||
[ui.userfront.dashboard.status]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = "Revoked"
|
revoked = "Revoked"
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ client_id = "Client ID: {id}"
|
|||||||
client_id_missing = "Client ID 없음"
|
client_id_missing = "Client ID 없음"
|
||||||
current_status = "현재 상태: {status}"
|
current_status = "현재 상태: {status}"
|
||||||
last_auth = "최근 인증: {value}"
|
last_auth = "최근 인증: {value}"
|
||||||
|
link_status = "연동 상태: {status}"
|
||||||
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
|
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
|
||||||
link_open_error = "해당 링크를 열 수 없습니다."
|
link_open_error = "해당 링크를 열 수 없습니다."
|
||||||
render_error = "대시보드 렌더링 오류: {error}"
|
render_error = "대시보드 렌더링 오류: {error}"
|
||||||
@@ -176,7 +177,8 @@ session = "세션"
|
|||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
last_auth_label = "최근 인증"
|
last_auth_label = "최근 인증"
|
||||||
status_history = "상태 이력"
|
link_status_label = "연동 상태"
|
||||||
|
status_history = "연동 정보"
|
||||||
|
|
||||||
[ui.userfront.device]
|
[ui.userfront.device]
|
||||||
android = "Mobile(Android)"
|
android = "Mobile(Android)"
|
||||||
@@ -694,7 +696,7 @@ confirm_button = "해지하기"
|
|||||||
title = "연동 해지"
|
title = "연동 해지"
|
||||||
|
|
||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = "권한 (Scopes)"
|
title = "동의 범위"
|
||||||
|
|
||||||
[ui.userfront.dashboard.status]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = "해지됨"
|
revoked = "해지됨"
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ ory = ""
|
|||||||
session = ""
|
session = ""
|
||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
|
link_status_label = ""
|
||||||
last_auth_label = ""
|
last_auth_label = ""
|
||||||
status_history = ""
|
status_history = ""
|
||||||
|
|
||||||
|
|||||||
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/constants/error_whitelist.dart';
|
||||||
import '../../../core/i18n/locale_utils.dart';
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../core/widgets/theme_toggle_button.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
class ErrorScreen extends StatelessWidget {
|
class ErrorScreen extends StatelessWidget {
|
||||||
@@ -22,6 +23,7 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
|
final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
|
||||||
final normalizedCode = (errorCode ?? '').trim();
|
final normalizedCode = (errorCode ?? '').trim();
|
||||||
final hasCode = normalizedCode.isNotEmpty;
|
final hasCode = normalizedCode.isNotEmpty;
|
||||||
@@ -62,7 +64,7 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
: tr('msg.userfront.error.detail_request')));
|
: tr('msg.userfront.error.detail_request')));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF7F8FA),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
@@ -71,7 +73,7 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: const BorderSide(color: Color(0xFFE5E7EB)),
|
side: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
||||||
@@ -79,18 +81,25 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
title,
|
children: [
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
Expanded(
|
||||||
fontWeight: FontWeight.w700,
|
child: Text(
|
||||||
color: const Color(0xFF111827),
|
title,
|
||||||
),
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const ThemeToggleButton(compact: true),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
detail,
|
detail,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: const Color(0xFF4B5563),
|
color: colorScheme.onSurfaceVariant,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -98,7 +107,7 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.error.type', params: {'type': errorType}),
|
tr('msg.userfront.error.type', params: {'type': errorType}),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: const Color(0xFF6B7280),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (errorId != null && errorId!.isNotEmpty) ...[
|
if (errorId != null && errorId!.isNotEmpty) ...[
|
||||||
@@ -106,7 +115,7 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.error.id', params: {'id': errorId!}),
|
tr('msg.userfront.error.id', params: {'id': errorId!}),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: const Color(0xFF6B7280),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -118,8 +127,8 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => context.go('/login'),
|
onPressed: () => context.go('/login'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF111827),
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
@@ -134,12 +143,12 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
context.go(buildLocalizedHomePath(Uri.base)),
|
context.go(buildLocalizedHomePath(Uri.base)),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFF111827),
|
foregroundColor: colorScheme.onSurface,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
side: const BorderSide(color: Color(0xFFCBD5F5)),
|
side: BorderSide(color: colorScheme.outline),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
|
|||||||
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 name;
|
||||||
final String logo;
|
final String logo;
|
||||||
final String url;
|
final String url;
|
||||||
|
final String initUrl;
|
||||||
final String status;
|
final String status;
|
||||||
final List<String> scopes;
|
final List<String> scopes;
|
||||||
final DateTime? lastAuthenticatedAt;
|
final DateTime? lastAuthenticatedAt;
|
||||||
@@ -105,6 +106,7 @@ class LinkedRp {
|
|||||||
required this.name,
|
required this.name,
|
||||||
required this.logo,
|
required this.logo,
|
||||||
required this.url,
|
required this.url,
|
||||||
|
required this.initUrl,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.scopes,
|
required this.scopes,
|
||||||
this.lastAuthenticatedAt,
|
this.lastAuthenticatedAt,
|
||||||
@@ -126,6 +128,7 @@ class LinkedRp {
|
|||||||
name: json['name']?.toString() ?? '',
|
name: json['name']?.toString() ?? '',
|
||||||
logo: json['logo']?.toString() ?? '',
|
logo: json['logo']?.toString() ?? '',
|
||||||
url: json['url']?.toString() ?? '',
|
url: json['url']?.toString() ?? '',
|
||||||
|
initUrl: json['init_url']?.toString() ?? '',
|
||||||
status: json['status']?.toString() ?? '',
|
status: json['status']?.toString() ?? '',
|
||||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||||
lastAuthenticatedAt: parsedLastAuth,
|
lastAuthenticatedAt: parsedLastAuth,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class LinkedRp {
|
|||||||
final String name;
|
final String name;
|
||||||
final String logo;
|
final String logo;
|
||||||
final String url;
|
final String url;
|
||||||
|
final String initUrl;
|
||||||
final String status;
|
final String status;
|
||||||
final List<String> scopes;
|
final List<String> scopes;
|
||||||
final DateTime? lastAuthenticatedAt;
|
final DateTime? lastAuthenticatedAt;
|
||||||
@@ -19,6 +20,7 @@ class LinkedRp {
|
|||||||
required this.name,
|
required this.name,
|
||||||
required this.logo,
|
required this.logo,
|
||||||
required this.url,
|
required this.url,
|
||||||
|
required this.initUrl,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.scopes,
|
required this.scopes,
|
||||||
required this.lastAuthenticatedAt,
|
required this.lastAuthenticatedAt,
|
||||||
@@ -40,6 +42,7 @@ class LinkedRp {
|
|||||||
name: json['name']?.toString() ?? '',
|
name: json['name']?.toString() ?? '',
|
||||||
logo: json['logo']?.toString() ?? '',
|
logo: json['logo']?.toString() ?? '',
|
||||||
url: json['url']?.toString() ?? '',
|
url: json['url']?.toString() ?? '',
|
||||||
|
initUrl: json['init_url']?.toString() ?? '',
|
||||||
status: json['status']?.toString() ?? 'unknown',
|
status: json['status']?.toString() ?? 'unknown',
|
||||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||||
lastAuthenticatedAt: parsedLastAuth,
|
lastAuthenticatedAt: parsedLastAuth,
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import '../domain/linked_rp_launch.dart';
|
||||||
import '../domain/session_time_resolver.dart';
|
import '../domain/session_time_resolver.dart';
|
||||||
import '../domain/providers/linked_rps_provider.dart';
|
import '../domain/providers/linked_rps_provider.dart';
|
||||||
import '../domain/providers/user_sessions_provider.dart';
|
import '../domain/providers/user_sessions_provider.dart';
|
||||||
@@ -17,6 +19,7 @@ import '../../../../core/services/auth_token_store.dart';
|
|||||||
import '../../../../core/services/http_client.dart';
|
import '../../../../core/services/http_client.dart';
|
||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../../core/widgets/language_selector.dart';
|
import '../../../../core/widgets/language_selector.dart';
|
||||||
|
import '../../../../core/widgets/theme_toggle_button.dart';
|
||||||
import '../../../../core/ui/layout_breakpoints.dart';
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
import '../../../../core/ui/toast_service.dart';
|
import '../../../../core/ui/toast_service.dart';
|
||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
@@ -32,11 +35,9 @@ class DashboardScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||||
static const _ink = Color(0xFF1A1F2C);
|
|
||||||
static const _surface = Colors.white;
|
|
||||||
static const _border = Color(0xFFE5E7EB);
|
|
||||||
static const _subtle = Color(0xFFF7F8FA);
|
|
||||||
static const double _dashboardCardSpacing = 12;
|
static const double _dashboardCardSpacing = 12;
|
||||||
|
static const double _dashboardCardMaxWidth = 228;
|
||||||
|
static const double _activityDialogMaxWidth = 360;
|
||||||
static const double _historySessionMinWidth = 92;
|
static const double _historySessionMinWidth = 92;
|
||||||
static const double _historyOtherColumnsBaselineWidth = 780;
|
static const double _historyOtherColumnsBaselineWidth = 780;
|
||||||
static const int _historySessionMinVisibleChars = 8;
|
static const int _historySessionMinVisibleChars = 8;
|
||||||
@@ -63,8 +64,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
bool _showAllActivities = false;
|
bool _showAllActivities = false;
|
||||||
bool _showActiveSessionsOnly = false;
|
bool _showActiveSessionsOnly = false;
|
||||||
|
bool _isDesktopSideMenuOpen = true;
|
||||||
final Set<String> _revokedClientIds = {};
|
final Set<String> _revokedClientIds = {};
|
||||||
|
|
||||||
|
Color get _ink => Theme.of(context).colorScheme.onSurface;
|
||||||
|
Color get _surface => Theme.of(context).colorScheme.surface;
|
||||||
|
Color get _border => Theme.of(context).colorScheme.outlineVariant;
|
||||||
|
Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest;
|
||||||
|
|
||||||
String _renderTranslatedText(
|
String _renderTranslatedText(
|
||||||
String key, {
|
String key, {
|
||||||
String? fallback,
|
String? fallback,
|
||||||
@@ -234,85 +241,158 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Consumer(
|
builder: (context) => Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
|
final dialogWidth = math.min(
|
||||||
|
MediaQuery.sizeOf(context).width - 48,
|
||||||
|
_activityDialogMaxWidth,
|
||||||
|
);
|
||||||
|
final statusLabel = item.status == 'active'
|
||||||
|
? tr('ui.userfront.dashboard.activity.linked')
|
||||||
|
: tr('ui.userfront.dashboard.status.revoked');
|
||||||
|
final statusColor = _activityStatusColor(item.status);
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(item.appName),
|
backgroundColor: _surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
insetPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 24,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: double.maxFinite,
|
width: dialogWidth,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Container(
|
||||||
tr('ui.userfront.dashboard.scopes.title'),
|
width: double.infinity,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _subtle,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: _border),
|
||||||
|
),
|
||||||
|
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),
|
const SizedBox(height: 16),
|
||||||
if (item.scopes.isEmpty)
|
_buildActivityDetailSection(
|
||||||
Text(
|
title: tr('ui.userfront.dashboard.status_history'),
|
||||||
tr('msg.userfront.dashboard.scopes.empty'),
|
child: Row(
|
||||||
style: const TextStyle(color: Colors.grey),
|
children: [
|
||||||
)
|
Expanded(
|
||||||
else
|
child: _buildActivityDetailField(
|
||||||
Wrap(
|
label: tr(
|
||||||
spacing: 8,
|
'ui.userfront.dashboard.link_status_label',
|
||||||
runSpacing: 4,
|
),
|
||||||
children: item.scopes
|
value: statusLabel,
|
||||||
.map(
|
valueColor: statusColor,
|
||||||
(s) => Chip(
|
),
|
||||||
label: Text(
|
),
|
||||||
s,
|
const SizedBox(width: 10),
|
||||||
style: const TextStyle(fontSize: 12),
|
Expanded(
|
||||||
),
|
child: _buildActivityDetailField(
|
||||||
visualDensity: VisualDensity.compact,
|
label: tr('ui.userfront.dashboard.last_auth_label'),
|
||||||
materialTapTargetSize:
|
value: item.lastAuthAt,
|
||||||
MaterialTapTargetSize.shrinkWrap,
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(),
|
: Wrap(
|
||||||
),
|
spacing: 8,
|
||||||
const SizedBox(height: 24),
|
runSpacing: 8,
|
||||||
Text(
|
children: item.scopes
|
||||||
tr('ui.userfront.dashboard.status_history'),
|
.map(
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
(scope) => Container(
|
||||||
),
|
padding: const EdgeInsets.symmetric(
|
||||||
const SizedBox(height: 8),
|
horizontal: 10,
|
||||||
Column(
|
vertical: 8,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Text(
|
color: _subtle,
|
||||||
tr(
|
borderRadius: BorderRadius.circular(12),
|
||||||
'msg.userfront.dashboard.last_auth',
|
border: Border.all(color: _border),
|
||||||
params: {'value': item.lastAuthAt},
|
),
|
||||||
),
|
child: Row(
|
||||||
),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(height: 4),
|
children: [
|
||||||
Builder(
|
Icon(
|
||||||
builder: (context) {
|
Icons.shield_outlined,
|
||||||
final statusLabel = item.status == 'active'
|
size: 14,
|
||||||
? tr('ui.common.status.active')
|
color: _ink,
|
||||||
: tr('ui.userfront.dashboard.status.revoked');
|
),
|
||||||
return Text(
|
const SizedBox(width: 6),
|
||||||
tr(
|
Text(
|
||||||
'msg.userfront.dashboard.current_status',
|
scope,
|
||||||
params: {'status': statusLabel},
|
style: TextStyle(
|
||||||
),
|
fontSize: 12,
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.w600,
|
||||||
color: item.status == 'active'
|
color: _ink,
|
||||||
? Colors.green
|
),
|
||||||
: Colors.grey,
|
),
|
||||||
),
|
],
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
),
|
)
|
||||||
],
|
.toList(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
SizedBox(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
width: double.infinity,
|
||||||
child: Text(tr('ui.common.close')),
|
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}) {
|
Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -376,7 +523,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(bottom: 16),
|
padding: EdgeInsets.only(bottom: 16),
|
||||||
child: LanguageSelector(compact: true),
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ThemeToggleButton(),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
LanguageSelector(compact: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -799,14 +953,35 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: _subtle,
|
backgroundColor: _subtle,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
leading: isWide
|
||||||
|
? IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu,
|
||||||
|
),
|
||||||
|
tooltip: _isDesktopSideMenuOpen
|
||||||
|
? tr('ui.common.collapse')
|
||||||
|
: '펼치기',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_isDesktopSideMenuOpen = !_isDesktopSideMenuOpen;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Builder(
|
||||||
|
builder: (context) => IconButton(
|
||||||
|
icon: const Icon(Icons.menu),
|
||||||
|
tooltip: MaterialLocalizations.of(
|
||||||
|
context,
|
||||||
|
).openAppDrawerTooltip,
|
||||||
|
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||||
|
),
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
tr('ui.userfront.app_title'),
|
tr('ui.userfront.app_title'),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
|
||||||
backgroundColor: _surface,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
actions: [
|
actions: [
|
||||||
|
const ThemeToggleButton(compact: true),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.person_outline),
|
icon: const Icon(Icons.person_outline),
|
||||||
tooltip: tr('ui.userfront.nav.profile'),
|
tooltip: tr('ui.userfront.nav.profile'),
|
||||||
@@ -829,7 +1004,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
|
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
if (isWide)
|
if (isWide && _isDesktopSideMenuOpen)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 240,
|
width: 240,
|
||||||
child: _buildSideMenu(context, closeOnTap: false),
|
child: _buildSideMenu(context, closeOnTap: false),
|
||||||
@@ -922,7 +1097,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
fallback: 'Hello, {{name}}.',
|
fallback: 'Hello, {{name}}.',
|
||||||
values: {'name': userName},
|
values: {'name': userName},
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
@@ -974,7 +1149,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
@@ -1128,7 +1303,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -1210,12 +1385,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
_ActivityItem(
|
_ActivityItem(
|
||||||
clientId: rp.id,
|
clientId: rp.id,
|
||||||
appName: name,
|
appName: name,
|
||||||
|
logo: rp.logo.trim(),
|
||||||
lastAuthAt: lastAuthLabel,
|
lastAuthAt: lastAuthLabel,
|
||||||
status: statusCode,
|
status: statusCode,
|
||||||
scopes: rp.scopes,
|
scopes: rp.scopes,
|
||||||
isRevoked: isRevoked,
|
isRevoked: isRevoked,
|
||||||
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
||||||
url: rp.url,
|
url: rp.url,
|
||||||
|
launchUrl: resolveLinkedRpLaunchUrl(rp),
|
||||||
lastAuthDateTime: rp.lastAuthenticatedAt,
|
lastAuthDateTime: rp.lastAuthenticatedAt,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1317,7 +1494,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
|
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
|
||||||
final isActive = item.status == 'active';
|
final isActive = item.status == 'active';
|
||||||
final statusColor = isActive ? Colors.green : Colors.grey;
|
final statusColor = _activityStatusColor(item.status);
|
||||||
final borderColor = isActive
|
final borderColor = isActive
|
||||||
? Colors.green.withValues(alpha: 128)
|
? Colors.green.withValues(alpha: 128)
|
||||||
: _border;
|
: _border;
|
||||||
@@ -1329,10 +1506,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
// 카드 컨텐츠
|
// 카드 컨텐츠
|
||||||
final cardContent = Container(
|
final cardContent = Container(
|
||||||
width: cardWidth ?? 260,
|
width: cardWidth ?? 260,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _surface,
|
color: _surface,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: borderColor, width: borderWidth),
|
border: Border.all(color: borderColor, width: borderWidth),
|
||||||
boxShadow: isActive
|
boxShadow: isActive
|
||||||
? [
|
? [
|
||||||
@@ -1347,38 +1524,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
_buildActivityCardHeader(item, statusColor),
|
||||||
children: [
|
const SizedBox(height: 10),
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
item.appName,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: _ink,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: statusColor,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
item.status == 'active'
|
|
||||||
? tr('ui.userfront.dashboard.activity.linked')
|
|
||||||
: tr('ui.userfront.dashboard.status.revoked'),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.dashboard.last_auth_label'),
|
tr('ui.userfront.dashboard.last_auth_label'),
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
@@ -1386,13 +1533,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
item.lastAuthAt,
|
item.lastAuthAt,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 14),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -1400,8 +1547,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
onPressed: () => _showRpDetails(item),
|
onPressed: () => _showRpDetails(item),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: _ink,
|
foregroundColor: _ink,
|
||||||
side: const BorderSide(color: _border),
|
side: BorderSide(color: _border),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.common.details'),
|
tr('ui.common.details'),
|
||||||
@@ -1423,7 +1570,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
color: item.isRevoked ? Colors.grey : Colors.redAccent,
|
color: item.isRevoked ? Colors.grey : Colors.redAccent,
|
||||||
width: 0.5,
|
width: 0.5,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||||
),
|
),
|
||||||
child: _isRevoking && !item.isRevoked
|
child: _isRevoking && !item.isRevoked
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
@@ -1460,7 +1607,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final itemUrl = item.url;
|
final itemUrl = item.launchUrl;
|
||||||
if (itemUrl != null && itemUrl.isNotEmpty) {
|
if (itemUrl != null && itemUrl.isNotEmpty) {
|
||||||
final uri = Uri.parse(itemUrl);
|
final uri = Uri.parse(itemUrl);
|
||||||
final canOpen = await canLaunchUrl(uri);
|
final canOpen = await canLaunchUrl(uri);
|
||||||
@@ -1483,6 +1630,115 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
return opaqueCard;
|
return opaqueCard;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildActivityCardHeader(_ActivityItem item, Color statusColor) {
|
||||||
|
final statusBadge = Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.status == 'active'
|
||||||
|
? tr('ui.userfront.dashboard.activity.linked')
|
||||||
|
: tr('ui.userfront.dashboard.status.revoked'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (item.logo.isNotEmpty) ...[
|
||||||
|
_buildActivityLogo(item.logo),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
item.appName,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _ink,
|
||||||
|
height: 1.25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
statusBadge,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActivityLogo(String logoUrl) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: _buildActivityLogoImage(logoUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActivityLogoImage(String logoUrl) {
|
||||||
|
final isSvg = _isSvgLogoUrl(logoUrl);
|
||||||
|
return isSvg
|
||||||
|
? SvgPicture.network(
|
||||||
|
logoUrl,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
placeholderBuilder: (context) => _buildActivityLogoLoading(),
|
||||||
|
)
|
||||||
|
: Image.network(
|
||||||
|
logoUrl,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return _buildActivityLogoFallback();
|
||||||
|
},
|
||||||
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
|
if (loadingProgress == null) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
return _buildActivityLogoLoading();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSvgLogoUrl(String logoUrl) {
|
||||||
|
final normalized = logoUrl.trim().toLowerCase();
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final uri = Uri.tryParse(normalized);
|
||||||
|
final path = uri?.path.toLowerCase() ?? normalized;
|
||||||
|
return path.endsWith('.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActivityLogoLoading() {
|
||||||
|
return Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActivityLogoFallback() {
|
||||||
|
return Icon(Icons.apps_rounded, size: 20, color: Colors.grey[500]);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildAccessHistory(AuthTimelineState state, bool isWide) {
|
Widget _buildAccessHistory(AuthTimelineState state, bool isWide) {
|
||||||
final sessionsState = ref.watch(userSessionsProvider);
|
final sessionsState = ref.watch(userSessionsProvider);
|
||||||
if (state.isLoading && state.items.isEmpty) {
|
if (state.isLoading && state.items.isEmpty) {
|
||||||
@@ -1601,7 +1857,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.audit.filter.title'),
|
tr('ui.userfront.audit.filter.title'),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
@@ -1621,7 +1877,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.audit.filter.toggle_label'),
|
tr('ui.userfront.audit.filter.toggle_label'),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
@@ -1781,8 +2037,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double _dashboardCardWidth(double maxWidth, int crossAxisCount) {
|
double _dashboardCardWidth(double maxWidth, int crossAxisCount) {
|
||||||
return (maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) /
|
return math.min(
|
||||||
crossAxisCount;
|
(maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) /
|
||||||
|
crossAxisCount,
|
||||||
|
_dashboardCardMaxWidth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _activityStatusColor(String status) {
|
||||||
|
return status == 'active' ? Colors.green : Colors.grey;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCenteredHistoryHeader(String label, {double? width}) {
|
Widget _buildCenteredHistoryHeader(String label, {double? width}) {
|
||||||
@@ -2073,7 +2336,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _buildAppCell(
|
child: _buildAppCell(
|
||||||
log,
|
log,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
),
|
),
|
||||||
@@ -2288,9 +2551,11 @@ enum _HistorySessionStatus { current, active, inactive }
|
|||||||
class _ActivityItem {
|
class _ActivityItem {
|
||||||
final String clientId;
|
final String clientId;
|
||||||
final String appName;
|
final String appName;
|
||||||
|
final String logo;
|
||||||
final String lastAuthAt;
|
final String lastAuthAt;
|
||||||
final String status;
|
final String status;
|
||||||
final String? url;
|
final String? url;
|
||||||
|
final String? launchUrl;
|
||||||
final List<String> scopes;
|
final List<String> scopes;
|
||||||
final bool isRevoked;
|
final bool isRevoked;
|
||||||
final VoidCallback? onRevoke;
|
final VoidCallback? onRevoke;
|
||||||
@@ -2299,10 +2564,12 @@ class _ActivityItem {
|
|||||||
_ActivityItem({
|
_ActivityItem({
|
||||||
required this.clientId,
|
required this.clientId,
|
||||||
required this.appName,
|
required this.appName,
|
||||||
|
required this.logo,
|
||||||
required this.lastAuthAt,
|
required this.lastAuthAt,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.scopes,
|
required this.scopes,
|
||||||
this.url,
|
this.url,
|
||||||
|
this.launchUrl,
|
||||||
this.isRevoked = false,
|
this.isRevoked = false,
|
||||||
this.onRevoke,
|
this.onRevoke,
|
||||||
this.lastAuthDateTime,
|
this.lastAuthDateTime,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../../../core/services/logout_service.dart';
|
|||||||
import '../../../../core/ui/layout_breakpoints.dart';
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
import '../../../../core/ui/toast_service.dart';
|
import '../../../../core/ui/toast_service.dart';
|
||||||
import '../../../../core/widgets/language_selector.dart';
|
import '../../../../core/widgets/language_selector.dart';
|
||||||
|
import '../../../../core/widgets/theme_toggle_button.dart';
|
||||||
import '../../data/models/user_profile_model.dart';
|
import '../../data/models/user_profile_model.dart';
|
||||||
import '../../domain/notifiers/profile_notifier.dart';
|
import '../../domain/notifiers/profile_notifier.dart';
|
||||||
|
|
||||||
@@ -20,10 +21,6 @@ class ProfilePage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ProfilePageState extends ConsumerState<ProfilePage> {
|
class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||||
static const _ink = Color(0xFF1A1F2C);
|
|
||||||
static const _surface = Colors.white;
|
|
||||||
static const _border = Color(0xFFE5E7EB);
|
|
||||||
static const _subtle = Color(0xFFF7F8FA);
|
|
||||||
static final _log = Logger('ProfilePage');
|
static final _log = Logger('ProfilePage');
|
||||||
|
|
||||||
UserProfile? _cachedProfile;
|
UserProfile? _cachedProfile;
|
||||||
@@ -54,9 +51,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
bool _showCurrentPassword = false;
|
bool _showCurrentPassword = false;
|
||||||
bool _showNewPassword = false;
|
bool _showNewPassword = false;
|
||||||
bool _showConfirmPassword = false;
|
bool _showConfirmPassword = false;
|
||||||
|
bool _isDesktopSideMenuOpen = true;
|
||||||
Map<String, dynamic>? _passwordPolicy;
|
Map<String, dynamic>? _passwordPolicy;
|
||||||
bool _isPasswordPolicyLoading = false;
|
bool _isPasswordPolicyLoading = false;
|
||||||
|
|
||||||
|
Color get _ink => Theme.of(context).colorScheme.onSurface;
|
||||||
|
Color get _surface => Theme.of(context).colorScheme.surface;
|
||||||
|
Color get _border => Theme.of(context).colorScheme.outlineVariant;
|
||||||
|
Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest;
|
||||||
|
|
||||||
String _renderTranslatedText(
|
String _renderTranslatedText(
|
||||||
String key, {
|
String key, {
|
||||||
String? fallback,
|
String? fallback,
|
||||||
@@ -615,7 +618,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(bottom: 16),
|
padding: EdgeInsets.only(bottom: 16),
|
||||||
child: LanguageSelector(compact: true),
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ThemeToggleButton(),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
LanguageSelector(compact: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -627,7 +637,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
@@ -654,7 +664,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -705,7 +715,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
fallback: 'Hello, {{name}}.',
|
fallback: 'Hello, {{name}}.',
|
||||||
values: {'name': name},
|
values: {'name': name},
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
@@ -996,12 +1006,17 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.profile.password.subtitle'),
|
tr('msg.userfront.profile.password.subtitle'),
|
||||||
style: const TextStyle(color: Color(0xFF6B7280)),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
_buildPasswordPolicyDescription(),
|
_buildPasswordPolicyDescription(),
|
||||||
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -1231,14 +1246,35 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: _subtle,
|
backgroundColor: _subtle,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
leading: isWide
|
||||||
|
? IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu,
|
||||||
|
),
|
||||||
|
tooltip: _isDesktopSideMenuOpen
|
||||||
|
? tr('ui.common.collapse')
|
||||||
|
: '펼치기',
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_isDesktopSideMenuOpen = !_isDesktopSideMenuOpen;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Builder(
|
||||||
|
builder: (context) => IconButton(
|
||||||
|
icon: const Icon(Icons.menu),
|
||||||
|
tooltip: MaterialLocalizations.of(
|
||||||
|
context,
|
||||||
|
).openAppDrawerTooltip,
|
||||||
|
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||||
|
),
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
tr('ui.userfront.app_title'),
|
tr('ui.userfront.app_title'),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
|
||||||
backgroundColor: _surface,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
actions: [
|
actions: [
|
||||||
|
const ThemeToggleButton(compact: true),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.home_outlined),
|
icon: const Icon(Icons.home_outlined),
|
||||||
tooltip: tr('ui.userfront.nav.dashboard'),
|
tooltip: tr('ui.userfront.nav.dashboard'),
|
||||||
@@ -1259,7 +1295,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)),
|
if (isWide && _isDesktopSideMenuOpen)
|
||||||
|
SizedBox(width: 240, child: _buildSideMenu(context)),
|
||||||
Expanded(child: _buildContent(profile, isUpdating)),
|
Expanded(child: _buildContent(profile, isUpdating)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ const Map<String, String> koStrings = {
|
|||||||
"msg.userfront.audit.device": "접속환경: {{value}}",
|
"msg.userfront.audit.device": "접속환경: {{value}}",
|
||||||
"msg.userfront.audit.end": "더 이상 항목이 없습니다.",
|
"msg.userfront.audit.end": "더 이상 항목이 없습니다.",
|
||||||
"msg.userfront.audit.filter.description": "활성화된 세션만 보려면 토글을 켜주세요.",
|
"msg.userfront.audit.filter.description": "활성화된 세션만 보려면 토글을 켜주세요.",
|
||||||
|
"msg.userfront.audit.filtered_empty": "활성 세션으로 필터링된 접속 이력이 없습니다.",
|
||||||
"msg.userfront.audit.ip": "접속 IP: {{value}}",
|
"msg.userfront.audit.ip": "접속 IP: {{value}}",
|
||||||
"msg.userfront.audit.load_more_error": "더 불러오지 못했습니다.",
|
"msg.userfront.audit.load_more_error": "더 불러오지 못했습니다.",
|
||||||
"msg.userfront.audit.result": "인증결과: {{value}}",
|
"msg.userfront.audit.result": "인증결과: {{value}}",
|
||||||
@@ -460,6 +461,7 @@ const Map<String, String> koStrings = {
|
|||||||
"msg.userfront.dashboard.last_auth": "최근 인증: {{value}}",
|
"msg.userfront.dashboard.last_auth": "최근 인증: {{value}}",
|
||||||
"msg.userfront.dashboard.link_missing": "이동할 페이지 주소(Client URI)가 설정되지 않았습니다.",
|
"msg.userfront.dashboard.link_missing": "이동할 페이지 주소(Client URI)가 설정되지 않았습니다.",
|
||||||
"msg.userfront.dashboard.link_open_error": "해당 링크를 열 수 없습니다.",
|
"msg.userfront.dashboard.link_open_error": "해당 링크를 열 수 없습니다.",
|
||||||
|
"msg.userfront.dashboard.link_status": "연동 상태: {{status}}",
|
||||||
"msg.userfront.dashboard.render_error": "대시보드 렌더링 오류: {{error}}",
|
"msg.userfront.dashboard.render_error": "대시보드 렌더링 오류: {{error}}",
|
||||||
"msg.userfront.dashboard.revoke.confirm":
|
"msg.userfront.dashboard.revoke.confirm":
|
||||||
"{{app}} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.",
|
"{{app}} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.",
|
||||||
@@ -1422,8 +1424,8 @@ const Map<String, String> koStrings = {
|
|||||||
"ui.common.status.pending": "준비 중",
|
"ui.common.status.pending": "준비 중",
|
||||||
"ui.common.status.success": "성공",
|
"ui.common.status.success": "성공",
|
||||||
"ui.common.success": "성공",
|
"ui.common.success": "성공",
|
||||||
"ui.common.theme_dark": "Dark",
|
"ui.common.theme_dark": "다크",
|
||||||
"ui.common.theme_light": "Light",
|
"ui.common.theme_light": "라이트",
|
||||||
"ui.common.theme_toggle": "테마 전환",
|
"ui.common.theme_toggle": "테마 전환",
|
||||||
"ui.common.unknown": "Unknown",
|
"ui.common.unknown": "Unknown",
|
||||||
"ui.common.view": "보기",
|
"ui.common.view": "보기",
|
||||||
@@ -1717,9 +1719,10 @@ const Map<String, String> koStrings = {
|
|||||||
"ui.userfront.dashboard.approved_session.default": "승인한 세션 ID",
|
"ui.userfront.dashboard.approved_session.default": "승인한 세션 ID",
|
||||||
"ui.userfront.dashboard.approved_session.userfront": "승인한 Userfront 세션 ID",
|
"ui.userfront.dashboard.approved_session.userfront": "승인한 Userfront 세션 ID",
|
||||||
"ui.userfront.dashboard.last_auth_label": "최근 인증",
|
"ui.userfront.dashboard.last_auth_label": "최근 인증",
|
||||||
|
"ui.userfront.dashboard.link_status_label": "연동 상태",
|
||||||
"ui.userfront.dashboard.revoke.confirm_button": "해지하기",
|
"ui.userfront.dashboard.revoke.confirm_button": "해지하기",
|
||||||
"ui.userfront.dashboard.revoke.title": "연동 해지",
|
"ui.userfront.dashboard.revoke.title": "연동 해지",
|
||||||
"ui.userfront.dashboard.scopes.title": "권한 (Scopes)",
|
"ui.userfront.dashboard.scopes.title": "동의 범위",
|
||||||
"ui.userfront.dashboard.sessions.active_badge": "활성화",
|
"ui.userfront.dashboard.sessions.active_badge": "활성화",
|
||||||
"ui.userfront.dashboard.sessions.current_badge": "접속중",
|
"ui.userfront.dashboard.sessions.current_badge": "접속중",
|
||||||
"ui.userfront.dashboard.sessions.current_disabled": "현재 세션",
|
"ui.userfront.dashboard.sessions.current_disabled": "현재 세션",
|
||||||
@@ -2324,6 +2327,8 @@ const Map<String, String> enStrings = {
|
|||||||
"msg.userfront.audit.end": "No more items to show.",
|
"msg.userfront.audit.end": "No more items to show.",
|
||||||
"msg.userfront.audit.filter.description":
|
"msg.userfront.audit.filter.description":
|
||||||
"Toggle to view only active sessions.",
|
"Toggle to view only active sessions.",
|
||||||
|
"msg.userfront.audit.filtered_empty":
|
||||||
|
"No sign-in history matches the active session filter.",
|
||||||
"msg.userfront.audit.ip": "IP address: {{value}}",
|
"msg.userfront.audit.ip": "IP address: {{value}}",
|
||||||
"msg.userfront.audit.load_more_error": "Could not load more history.",
|
"msg.userfront.audit.load_more_error": "Could not load more history.",
|
||||||
"msg.userfront.audit.result": "Result: {{value}}",
|
"msg.userfront.audit.result": "Result: {{value}}",
|
||||||
@@ -2376,6 +2381,7 @@ const Map<String, String> enStrings = {
|
|||||||
"msg.userfront.dashboard.link_missing":
|
"msg.userfront.dashboard.link_missing":
|
||||||
"This app does not have a launch URL configured.",
|
"This app does not have a launch URL configured.",
|
||||||
"msg.userfront.dashboard.link_open_error": "Could not open the app link.",
|
"msg.userfront.dashboard.link_open_error": "Could not open the app link.",
|
||||||
|
"msg.userfront.dashboard.link_status": "Link status: {{status}}",
|
||||||
"msg.userfront.dashboard.render_error": "Dashboard render error: {{error}}",
|
"msg.userfront.dashboard.render_error": "Dashboard render error: {{error}}",
|
||||||
"msg.userfront.dashboard.revoke.confirm":
|
"msg.userfront.dashboard.revoke.confirm":
|
||||||
"Disconnect {{app}}?\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in.",
|
"Disconnect {{app}}?\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in.",
|
||||||
@@ -3728,9 +3734,10 @@ const Map<String, String> enStrings = {
|
|||||||
"ui.userfront.dashboard.approved_session.userfront":
|
"ui.userfront.dashboard.approved_session.userfront":
|
||||||
"Approved UserFront session ID",
|
"Approved UserFront session ID",
|
||||||
"ui.userfront.dashboard.last_auth_label": "Last sign-in",
|
"ui.userfront.dashboard.last_auth_label": "Last sign-in",
|
||||||
|
"ui.userfront.dashboard.link_status_label": "Link status",
|
||||||
"ui.userfront.dashboard.revoke.confirm_button": "Disconnect",
|
"ui.userfront.dashboard.revoke.confirm_button": "Disconnect",
|
||||||
"ui.userfront.dashboard.revoke.title": "Disconnect app",
|
"ui.userfront.dashboard.revoke.title": "Disconnect",
|
||||||
"ui.userfront.dashboard.scopes.title": "Permission (Scopes)",
|
"ui.userfront.dashboard.scopes.title": "Consent scopes",
|
||||||
"ui.userfront.dashboard.sessions.active_badge": "Active",
|
"ui.userfront.dashboard.sessions.active_badge": "Active",
|
||||||
"ui.userfront.dashboard.sessions.current_badge": "Current",
|
"ui.userfront.dashboard.sessions.current_badge": "Current",
|
||||||
"ui.userfront.dashboard.sessions.current_disabled": "Current session",
|
"ui.userfront.dashboard.sessions.current_disabled": "Current session",
|
||||||
@@ -3739,7 +3746,7 @@ const Map<String, String> enStrings = {
|
|||||||
"ui.userfront.dashboard.sessions.unknown_device": "Unknown device",
|
"ui.userfront.dashboard.sessions.unknown_device": "Unknown device",
|
||||||
"ui.userfront.dashboard.sessions.unknown_session": "Session",
|
"ui.userfront.dashboard.sessions.unknown_session": "Session",
|
||||||
"ui.userfront.dashboard.status.revoked": "Revoked",
|
"ui.userfront.dashboard.status.revoked": "Revoked",
|
||||||
"ui.userfront.dashboard.status_history": "Activity history",
|
"ui.userfront.dashboard.status_history": "Link details",
|
||||||
"ui.userfront.device.android": "Mobile(Android)",
|
"ui.userfront.device.android": "Mobile(Android)",
|
||||||
"ui.userfront.device.ios": "Mobile(iOS)",
|
"ui.userfront.device.ios": "Mobile(iOS)",
|
||||||
"ui.userfront.device.linux": "Desktop(Linux)",
|
"ui.userfront.device.linux": "Desktop(Linux)",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import 'core/services/logger_service.dart';
|
|||||||
import 'core/services/null_check_recovery.dart';
|
import 'core/services/null_check_recovery.dart';
|
||||||
import 'core/services/web_window.dart';
|
import 'core/services/web_window.dart';
|
||||||
import 'core/notifiers/auth_notifier.dart';
|
import 'core/notifiers/auth_notifier.dart';
|
||||||
|
import 'core/theme/app_theme.dart';
|
||||||
|
import 'core/theme/theme_controller.dart';
|
||||||
|
import 'core/theme/theme_scope.dart';
|
||||||
import 'core/i18n/locale_gate.dart';
|
import 'core/i18n/locale_gate.dart';
|
||||||
import 'core/i18n/locale_registry.dart';
|
import 'core/i18n/locale_registry.dart';
|
||||||
import 'core/i18n/locale_utils.dart';
|
import 'core/i18n/locale_utils.dart';
|
||||||
@@ -106,6 +109,8 @@ void main() async {
|
|||||||
|
|
||||||
// 0. Initialize Logger
|
// 0. Initialize Logger
|
||||||
LoggerService.init();
|
LoggerService.init();
|
||||||
|
await ThemeController.app.restore();
|
||||||
|
await ThemeController.auth.restore();
|
||||||
|
|
||||||
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
|
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
|
||||||
await _loadBundledFonts();
|
await _loadBundledFonts();
|
||||||
@@ -177,12 +182,18 @@ final _router = GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return const DashboardScreen();
|
return ScopedTheme(
|
||||||
|
controller: ThemeController.app,
|
||||||
|
child: const DashboardScreen(),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
builder: (context, state) => const ProfilePage(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.app,
|
||||||
|
child: const ProfilePage(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'signin',
|
path: 'signin',
|
||||||
@@ -192,10 +203,13 @@ final _router = GoRouter(
|
|||||||
final redirectUrl =
|
final redirectUrl =
|
||||||
state.uri.queryParameters['redirect_uri'] ??
|
state.uri.queryParameters['redirect_uri'] ??
|
||||||
state.uri.queryParameters['redirect_url'];
|
state.uri.queryParameters['redirect_url'];
|
||||||
return LoginScreen(
|
return ScopedTheme(
|
||||||
key: state.pageKey,
|
controller: ThemeController.auth,
|
||||||
loginChallenge: loginChallenge,
|
child: LoginScreen(
|
||||||
redirectUrl: redirectUrl,
|
key: state.pageKey,
|
||||||
|
loginChallenge: loginChallenge,
|
||||||
|
redirectUrl: redirectUrl,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -208,10 +222,13 @@ final _router = GoRouter(
|
|||||||
final redirectUrl =
|
final redirectUrl =
|
||||||
state.uri.queryParameters['redirect_uri'] ??
|
state.uri.queryParameters['redirect_uri'] ??
|
||||||
state.uri.queryParameters['redirect_url'];
|
state.uri.queryParameters['redirect_url'];
|
||||||
return LoginScreen(
|
return ScopedTheme(
|
||||||
key: state.pageKey,
|
controller: ThemeController.auth,
|
||||||
loginChallenge: loginChallenge,
|
child: LoginScreen(
|
||||||
redirectUrl: redirectUrl,
|
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(
|
GoRoute(
|
||||||
path: 'signup',
|
path: 'signup',
|
||||||
builder: (context, state) => const SignupScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const SignupScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'registration',
|
path: 'registration',
|
||||||
builder: (context, state) => const SignupScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const SignupScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'verify',
|
path: 'verify',
|
||||||
builder: (context, state) => LoginScreen(key: state.pageKey),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: LoginScreen(key: state.pageKey),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'verify/:token',
|
path: 'verify/:token',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final token = state.pathParameters['token'];
|
final token = state.pathParameters['token'];
|
||||||
return LoginScreen(
|
return ScopedTheme(
|
||||||
key: state.pageKey,
|
controller: ThemeController.auth,
|
||||||
verificationToken: token,
|
child: LoginScreen(
|
||||||
|
key: state.pageKey,
|
||||||
|
verificationToken: token,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'verification',
|
path: 'verification',
|
||||||
builder: (context, state) => LoginScreen(key: state.pageKey),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: LoginScreen(key: state.pageKey),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'l/:shortCode',
|
path: 'l/:shortCode',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return LoginScreen(key: state.pageKey);
|
return ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: LoginScreen(key: state.pageKey),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'forgot-password',
|
path: 'forgot-password',
|
||||||
builder: (context, state) => const ForgotPasswordScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const ForgotPasswordScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'recovery',
|
path: 'recovery',
|
||||||
builder: (context, state) => const ForgotPasswordScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const ForgotPasswordScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'reset-password',
|
path: 'reset-password',
|
||||||
builder: (context, state) => const ResetPasswordScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const ResetPasswordScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'error',
|
path: 'error',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final params = state.uri.queryParameters;
|
final params = state.uri.queryParameters;
|
||||||
return ErrorScreen(
|
return ScopedTheme(
|
||||||
errorId: params['id'],
|
controller: ThemeController.auth,
|
||||||
errorCode: params['error'],
|
child: ErrorScreen(
|
||||||
description: params['error_description'] ?? params['message'],
|
errorId: params['id'],
|
||||||
|
errorCode: params['error'],
|
||||||
|
description:
|
||||||
|
params['error_description'] ?? params['message'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
builder: (context, state) => ErrorScreen(
|
builder: (context, state) => ScopedTheme(
|
||||||
errorCode: 'settings_disabled',
|
controller: ThemeController.auth,
|
||||||
description: tr('msg.userfront.settings.disabled'),
|
child: ErrorScreen(
|
||||||
|
errorCode: 'settings_disabled',
|
||||||
|
description: tr('msg.userfront.settings.disabled'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'approve',
|
path: 'approve',
|
||||||
builder: (context, state) =>
|
builder: (context, state) => ScopedTheme(
|
||||||
ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']),
|
controller: ThemeController.auth,
|
||||||
|
child: ApproveQrScreen(
|
||||||
|
pendingRef: state.uri.queryParameters['ref'],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'ql/:ref',
|
path: 'ql/:ref',
|
||||||
builder: (context, state) =>
|
builder: (context, state) => ScopedTheme(
|
||||||
ApproveQrScreen(pendingRef: state.pathParameters['ref']),
|
controller: ThemeController.auth,
|
||||||
|
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'scan',
|
path: 'scan',
|
||||||
builder: (context, state) => const QRScanScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const QRScanScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'admin/users',
|
path: 'admin/users',
|
||||||
builder: (context, state) => const UserManagementScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.app,
|
||||||
|
child: const UserManagementScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -376,40 +442,10 @@ class BaronSSOApp extends StatelessWidget {
|
|||||||
children: [if (child != null) child, const ToastViewport()],
|
children: [if (child != null) child, const ToastViewport()],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
theme: ThemeData(
|
theme: buildLightTheme(),
|
||||||
colorScheme: ColorScheme.fromSeed(
|
darkTheme: buildDarkTheme(),
|
||||||
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
themeMode: ThemeMode.light,
|
||||||
brightness: Brightness.light,
|
|
||||||
),
|
|
||||||
useMaterial3: true,
|
|
||||||
fontFamily: 'NotoSansKR',
|
|
||||||
pageTransitionsTheme: const PageTransitionsTheme(
|
|
||||||
builders: {
|
|
||||||
TargetPlatform.android: NoTransitionsBuilder(),
|
|
||||||
TargetPlatform.iOS: NoTransitionsBuilder(),
|
|
||||||
TargetPlatform.linux: NoTransitionsBuilder(),
|
|
||||||
TargetPlatform.macOS: NoTransitionsBuilder(),
|
|
||||||
TargetPlatform.windows: NoTransitionsBuilder(),
|
|
||||||
TargetPlatform.fuchsia: NoTransitionsBuilder(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
routerConfig: _router,
|
routerConfig: _router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoTransitionsBuilder extends PageTransitionsBuilder {
|
|
||||||
const NoTransitionsBuilder();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget buildTransitions<T>(
|
|
||||||
PageRoute<T> route,
|
|
||||||
BuildContext context,
|
|
||||||
Animation<double> animation,
|
|
||||||
Animation<double> secondaryAnimation,
|
|
||||||
Widget child,
|
|
||||||
) {
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -184,6 +184,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.0"
|
||||||
|
flutter_svg:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_svg
|
||||||
|
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.4"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -388,6 +396,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_parsing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_parsing
|
||||||
|
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -485,7 +501,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shared_preferences
|
name: shared_preferences
|
||||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||||
@@ -753,6 +769,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.5"
|
version: "3.1.5"
|
||||||
|
vector_graphics:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics
|
||||||
|
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.21"
|
||||||
|
vector_graphics_codec:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics_codec
|
||||||
|
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.13"
|
||||||
|
vector_graphics_compiler:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics_compiler
|
||||||
|
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -825,6 +865,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.0"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ dependencies:
|
|||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
flutter_dotenv: ^6.0.0
|
flutter_dotenv: ^6.0.0
|
||||||
|
flutter_svg: ^2.2.1
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
logging: ^1.2.0
|
logging: ^1.2.0
|
||||||
logger: ^2.0.0
|
logger: ^2.0.0
|
||||||
@@ -48,6 +49,7 @@ dependencies:
|
|||||||
easy_localization: ^3.0.7
|
easy_localization: ^3.0.7
|
||||||
toml: ^0.15.0
|
toml: ^0.15.0
|
||||||
web: ^1.1.0
|
web: ^1.1.0
|
||||||
|
shared_preferences: ^2.5.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
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