diff --git a/.env.sample b/.env.sample index af98d230..6b98db23 100644 --- a/.env.sample +++ b/.env.sample @@ -146,6 +146,8 @@ HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc # HYDRA_LOGIN_URL=https://sso.hmac.kr/login # HYDRA_CONSENT_URL=https://sso.hmac.kr/consent # HYDRA_ERROR_URL=https://sso.hmac.kr/error +# Refresh Token 만료시각 source of truth (Hydra + backend ID Token rt_expires_at claim) +HYDRA_REFRESH_TOKEN_TTL=720h # Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택) # 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다. @@ -183,4 +185,3 @@ VITE_ORGCHART_URL= # promtail에서 로그를 전송받을 Loki 서버 엔드포인트 URL LOKI_URL=http://loki:3100/loki/api/v1/push - diff --git a/.gitea/workflows/production_release.yml b/.gitea/workflows/production_release.yml index 9c51ad89..5b9cd194 100644 --- a/.gitea/workflows/production_release.yml +++ b/.gitea/workflows/production_release.yml @@ -124,6 +124,7 @@ jobs: "ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \ "BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \ "VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}" \ + "HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}" \ "ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}" \ "DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}" \ "ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}" \ @@ -135,7 +136,7 @@ jobs: DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER - USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY + USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY HYDRA_REFRESH_TOKEN_TTL ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS " for key in ${required_dotenv_keys}; do diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index f648ada4..04ae1003 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -115,6 +115,7 @@ jobs: KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }} HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }} HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }} + HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }} JWKS_URL=${{ vars.JWKS_URL }} OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }} OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }} diff --git a/.gitea/workflows/staging_release.yml b/.gitea/workflows/staging_release.yml index fa1c9eba..c8b9d59a 100644 --- a/.gitea/workflows/staging_release.yml +++ b/.gitea/workflows/staging_release.yml @@ -123,6 +123,7 @@ jobs: KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }} HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }} HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }} + HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }} JWKS_URL=${{ vars.JWKS_URL }} OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }} OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }} @@ -152,7 +153,7 @@ jobs: USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL ORY_POSTGRES_TAG ORY_POSTGRES_USER ORY_POSTGRES_PASSWORD ORY_POSTGRES_DB KRATOS_DB HYDRA_DB KETO_DB KRATOS_VERSION KRATOS_UI_NODE_VERSION HYDRA_VERSION KETO_VERSION ORY_SDK_URL KRATOS_PUBLIC_URL - KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL JWKS_URL + KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL HYDRA_REFRESH_TOKEN_TTL JWKS_URL OATHKEEPER_VERSION OATHKEEPER_UID OATHKEEPER_GID OATHKEEPER_HEALTH_URL OATHKEEPER_HEALTH_INTERVAL_SECONDS OATHKEEPER_HEALTH_TIMEOUT_SECONDS OATHKEEPER_HEALTH_ENABLED CSRF_COOKIE_NAME CSRF_COOKIE_SECRET VITE_OIDC_AUTHORITY ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 2d293bf4..a1666bbe 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -86,6 +86,7 @@ const ( linkResendCooldown = 60 * time.Second prefixDrySend = "dry_send:" headlessJWKSFetchTTL = 5 * time.Second + defaultRefreshTokenTTL = 30 * 24 * time.Hour ) type AuthHandler struct { @@ -1241,9 +1242,31 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string return claims } +func hydraRefreshTokenTTL() time.Duration { + raw := strings.TrimSpace(os.Getenv("HYDRA_REFRESH_TOKEN_TTL")) + if raw == "" { + return defaultRefreshTokenTTL + } + ttl, err := time.ParseDuration(raw) + if err != nil || ttl <= 0 { + slog.Warn("invalid HYDRA_REFRESH_TOKEN_TTL, falling back to default", "value", raw, "default", defaultRefreshTokenTTL.String(), "error", err) + return defaultRefreshTokenTTL + } + return ttl +} + +func withRefreshTokenExpiryClaim(claims map[string]any, issuedAt time.Time) map[string]any { + if claims == nil { + claims = map[string]any{} + } + claims["rt_expires_at"] = issuedAt.Add(hydraRefreshTokenTTL()).Unix() + return claims +} + func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any { claims := buildOidcClaimsFromTraits(traits, scopes, tenantID) claims = applyConfiguredIDTokenClaims(claims, client.Metadata) + claims = withRefreshTokenExpiryClaim(claims, time.Now()) return withOidcSessionMetadata(claims, sessionID) } diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go index f6d310e8..f634f568 100644 --- a/backend/internal/handler/auth_handler_dynamic_claims_test.go +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -11,12 +11,63 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) +func assertRefreshTokenExpiryClaimWithin(t *testing.T, claims map[string]any, issuedAfter, issuedBefore time.Time, ttl time.Duration) { + t.Helper() + + rawExpiresAt, ok := claims["rt_expires_at"] + if !assert.True(t, ok, "rt_expires_at claim should exist") { + return + } + + expiresAtFloat, ok := rawExpiresAt.(float64) + if !assert.True(t, ok, "rt_expires_at should be encoded as a unix timestamp number") { + return + } + + expiresAt := time.Unix(int64(expiresAtFloat), 0) + assert.False(t, expiresAt.Before(issuedAfter.Add(ttl).Add(-time.Second)), "rt_expires_at should be after or equal to request start + ttl with second precision tolerance") + assert.False(t, expiresAt.After(issuedBefore.Add(ttl).Add(time.Second)), "rt_expires_at should be before or equal to request end + ttl") +} + +func TestHydraRefreshTokenTTL_DefaultAndFallback(t *testing.T) { + t.Run("uses explicit env value", func(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "96h") + assert.Equal(t, 96*time.Hour, hydraRefreshTokenTTL()) + }) + + t.Run("uses default when env is empty", func(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "") + assert.Equal(t, defaultRefreshTokenTTL, hydraRefreshTokenTTL()) + }) + + t.Run("uses default when env is invalid", func(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "not-a-duration") + assert.Equal(t, defaultRefreshTokenTTL, hydraRefreshTokenTTL()) + }) + + t.Run("uses default when env is non-positive", func(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "0h") + assert.Equal(t, defaultRefreshTokenTTL, hydraRefreshTokenTTL()) + }) +} + +func TestWithRefreshTokenExpiryClaim_UsesHydraRefreshTokenTTL(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "36h") + + issuedAt := time.Date(2026, time.June, 15, 14, 0, 0, 0, time.UTC) + claims := withRefreshTokenExpiryClaim(map[string]any{"email": "user@test.com"}, issuedAt) + + assert.Equal(t, "user@test.com", claims["email"]) + assert.Equal(t, issuedAt.Add(36*time.Hour).Unix(), claims["rt_expires_at"]) +} + func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { traits := map[string]any{ "email": "user@baron.com", @@ -131,6 +182,7 @@ func TestRepresentativeTenantIDFromTraits(t *testing.T) { } func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "48h") var capturedClaims map[string]any transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -213,7 +265,9 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") + issuedAfter := time.Now() resp, err := app.Test(req) + issuedBefore := time.Now() assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -223,6 +277,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { assert.Equal(t, "tenant-abc", capturedClaims["tenant_id"]) assert.Equal(t, "Innovation", capturedClaims["department"]) assert.Equal(t, "Architect", capturedClaims["position"]) + assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 48*time.Hour) } func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) { @@ -603,6 +658,7 @@ func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) { } func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "24h") var capturedClaims map[string]any transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -678,7 +734,9 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { app.Get("/api/v1/auth/consent", h.GetConsentRequest) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip-dynamic", nil) + issuedAfter := time.Now() resp, err := app.Test(req) + issuedBefore := time.Now() assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -688,6 +746,87 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { assert.Equal(t, "tenant-xyz", capturedClaims["tenant_id"]) assert.Equal(t, "Security", capturedClaims["department"]) assert.Equal(t, "Officer", capturedClaims["position"]) + assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 24*time.Hour) +} + +func TestGetConsentRequest_LocalConsentAutoApprove_IncludesRefreshTokenExpiryClaim(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "72h") + + var capturedClaims map[string]any + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-local-auto-approve" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "challenge": "challenge-local-auto-approve", + "requested_scope": []string{"openid", "profile"}, + "skip": false, + "subject": "user-local-123", + "client": map[string]any{ + "client_id": "local-app", + "metadata": map[string]any{ + "tenant_id": "tenant-local", + }, + }, + }), nil + } + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-local-auto-approve" { + body, _ := io.ReadAll(r.Body) + var acceptReq map[string]any + json.Unmarshal(body, &acceptReq) + if session, ok := acceptReq["session"].(map[string]any); ok { + capturedClaims = session["id_token"].(map[string]any) + } + return httpJSONAny(r, http.StatusOK, map[string]any{ + "redirect_to": "http://rp/cb", + }), 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 }() + + consentRepo := &mockConsentRepo{ + consents: []domain.ClientConsent{ + { + ClientID: "local-app", + Subject: "user-local-123", + GrantedScopes: []string{"openid", "profile"}, + }, + }, + } + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + ConsentRepo: consentRepo, + } + h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-local-123").Return(&service.KratosIdentity{ + ID: "user-local-123", + Traits: map[string]any{ + "email": "local@test.com", + "name": "Local User", + }, + }, nil) + + app := fiber.New() + app.Get("/api/v1/auth/consent", h.GetConsentRequest) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-local-auto-approve", nil) + issuedAfter := time.Now() + resp, err := app.Test(req) + issuedBefore := time.Now() + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.NotNil(t, capturedClaims) + assert.Equal(t, "local@test.com", capturedClaims["email"]) + assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 72*time.Hour) } func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) { diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx index 2aa94e53..726bfe06 100644 --- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx @@ -10,7 +10,7 @@ const navigateMock = vi.fn(); const fetchClientMock = vi.fn(); const updateClientMock = vi.fn(); const fetchClientRelationsMock = vi.fn(); -const fetchMyTenantsMock = vi.fn(); +const fetchTenantsMock = vi.fn(); const fetchMeMock = vi.fn(); let authState = { @@ -45,7 +45,7 @@ vi.mock("../../lib/devApi", () => ({ fetchClient: (...args: unknown[]) => fetchClientMock(...args), fetchClientRelations: (...args: unknown[]) => fetchClientRelationsMock(...args), - fetchMyTenants: (...args: unknown[]) => fetchMyTenantsMock(...args), + fetchTenants: (...args: unknown[]) => fetchTenantsMock(...args), refreshHeadlessJwksCache: vi.fn(), revokeHeadlessJwksCache: vi.fn(), updateClient: (...args: unknown[]) => updateClientMock(...args), @@ -217,7 +217,12 @@ describe("ClientGeneralPage RP claims", () => { fetchClientMock.mockResolvedValue(makeClientDetail("old_claim")); updateClientMock.mockResolvedValue(makeClientDetail("new_claim")); fetchClientRelationsMock.mockResolvedValue({ items: [] }); - fetchMyTenantsMock.mockResolvedValue([]); + fetchTenantsMock.mockResolvedValue({ + items: [], + limit: 1000, + offset: 0, + total: 0, + }); fetchMeMock.mockResolvedValue({ id: "admin-user", role: "super_admin", diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 172e9366..c68afeec 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -5,7 +5,6 @@ import { Info, Plus, Save, - Search, Shield, ShieldHalf, Sparkles, @@ -27,16 +26,24 @@ import { CardHeader, CardTitle, } from "../../components/ui/card"; +import { CopyButton } from "../../components/ui/copy-button"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { Switch } from "../../components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../components/ui/table"; import { Textarea } from "../../components/ui/textarea"; import { toast } from "../../components/ui/use-toast"; import type { ClientStatus, ClientType, ClientUpsertRequest, - MyTenantSummary, TenantSummary, } from "../../lib/devApi"; import { @@ -45,7 +52,7 @@ import { deleteClient, fetchClient, fetchClientRelations, - fetchMyTenants, + fetchTenants, refreshHeadlessJwksCache, revokeHeadlessJwksCache, updateClient, @@ -57,7 +64,7 @@ import { cn } from "../../lib/utils"; import { fetchMe, type UserProfile } from "../auth/authApi"; import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { ClientDetailTabs } from "./ClientDetailTabs"; -import { AllowedTenantBadge } from "./components/AllowedTenantBadge"; +import { TenantAccessPicker } from "./components/TenantAccessPicker"; import { claimDateTimeValueToInputString, dateTimeInputToUnixSeconds, @@ -597,8 +604,8 @@ function ClientGeneralPage() { retry: false, }); const { data: tenantData } = useQuery({ - queryKey: ["my-tenants"], - queryFn: fetchMyTenants, + queryKey: ["tenants", "all"], + queryFn: () => fetchTenants(), }); const [name, setName] = useState(""); @@ -622,8 +629,6 @@ function ClientGeneralPage() { ] = useState(false); const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false); const [allowedTenantIds, setAllowedTenantIds] = useState([]); - const [tenantSearch, setTenantSearch] = useState(""); - const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false); const [autoLoginSupported, setAutoLoginSupported] = useState(false); const [autoLoginUrl, setAutoLoginUrl] = useState(""); @@ -990,10 +995,6 @@ function ClientGeneralPage() { const handleTenantAccessToggle = (enabled: boolean) => { setTenantAccessRestricted(enabled); - setIsTenantSearchOpen(enabled); - if (!enabled) { - setTenantSearch(""); - } setScopes((current) => normalizeScopesForTenantAccess(current, enabled)); }; @@ -1009,8 +1010,6 @@ function ClientGeneralPage() { setAllowedTenantIds((current) => current.includes(tenantId) ? current : [...current, tenantId], ); - setTenantSearch(""); - setIsTenantSearchOpen(true); }; const addScope = () => { @@ -1270,25 +1269,10 @@ function ClientGeneralPage() { normalizedIdTokenClaimItems, ); const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2); - const normalizedTenantSearch = tenantSearch.trim().toLowerCase(); - const tenantOptions: Array = - tenantData ?? []; - const filteredTenants = tenantOptions.filter((tenant) => { - if (!normalizedTenantSearch) { - return true; - } - const searchable = - `${tenant.name} ${tenant.slug} ${tenant.description ?? ""} ${tenant.type ?? ""}`.toLowerCase(); - return searchable.includes(normalizedTenantSearch); - }); - const tenantSuggestions = filteredTenants - .filter((tenant) => !allowedTenantIds.includes(tenant.id)) - .slice(0, 8); + const tenantOptions: TenantSummary[] = tenantData?.items ?? []; const selectedAllowedTenants = allowedTenantIds .map((tenantId) => tenantOptions.find((item) => item.id === tenantId)) - .filter( - (tenant): tenant is TenantSummary | MyTenantSummary => tenant != null, - ); + .filter((tenant): tenant is TenantSummary => tenant != null); const refreshHeadlessJwksCacheMutation = useMutation({ mutationFn: async () => { @@ -2322,12 +2306,20 @@ function ClientGeneralPage() { "테넌트 접근 제한", )} - - {t( - "ui.dev.clients.general.tenant_access.subtitle", - "허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.", - )} - +
+

+ {t( + "ui.dev.clients.general.tenant_access.subtitle", + "허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.", + )} +

+

+ {t( + "ui.dev.clients.general.tenant_access.hint", + "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.", + )} +

+
@@ -2358,154 +2350,168 @@ function ClientGeneralPage() {
- -

- {t( - "ui.dev.clients.general.tenant_access.hint", - "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.", - )} -

- -
-
- -
- - { - if (tenantAccessRestricted) { - setIsTenantSearchOpen(true); - } - }} - onBlur={() => { - window.setTimeout(() => setIsTenantSearchOpen(false), 120); - }} - onChange={(e) => { - setTenantSearch(e.target.value); - if (tenantAccessRestricted) { - setIsTenantSearchOpen(true); - } - }} - placeholder={t( - "ui.dev.clients.general.tenant_access.search_placeholder", - "테넌트 이름 또는 슬러그로 검색", - )} - className="pl-10" - disabled={ - isGeneralSettingsReadOnly || !tenantAccessRestricted + + {tenantAccessRestricted ? ( +
+
+ + + handleSelectAllowedTenant(selection.id) } /> - {tenantAccessRestricted && isTenantSearchOpen && ( -
- {tenantSuggestions.length > 0 ? ( - tenantSuggestions.map((tenant) => ( - - )) - ) : ( -
- {t( - "ui.dev.clients.general.tenant_access.empty", - "검색 결과가 없습니다.", - )} -
- )} -
- )}
-
- {tenantAccessRestricted - ? t( - "ui.dev.clients.general.tenant_access.autocomplete_hint", - "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다.", - ) - : t( - "ui.dev.clients.general.tenant_access.disabled", - "제한 없음", - )} -
-
-
- -
- {tenantAccessRestricted && allowedTenantIds.length > 0 ? ( -
- {selectedAllowedTenants.map((tenant) => ( - toggleAllowedTenant(tenant.id)} - disabled={isGeneralSettingsReadOnly} - /> - ))} - {allowedTenantIds - .filter( - (tenantId) => - !selectedAllowedTenants.some( - (tenant) => tenant.id === tenantId, - ), - ) - .map((tenantId) => ( - toggleAllowedTenant(tenantId)} - disabled={isGeneralSettingsReadOnly} - /> - ))} -
- ) : ( -
- {tenantAccessRestricted - ? t( - "ui.dev.clients.general.tenant_access.selected_empty", - "아직 선택된 테넌트가 없습니다.", - ) - : t( - "ui.dev.clients.general.tenant_access.disabled", - "제한 없음", - )} -
- )} +
+ +
+ {allowedTenantIds.length > 0 ? ( +
+ + + + + {t( + "ui.dev.clients.general.tenant_access.table.name", + "테넌트명", + )} + + + {t( + "ui.dev.clients.general.tenant_access.table.slug", + "슬러그", + )} + + + {t( + "ui.dev.clients.general.tenant_access.table.id", + "테넌트 ID", + )} + + + {t( + "ui.dev.clients.general.tenant_access.table.actions", + "작업", + )} + + + + + {selectedAllowedTenants.map((tenant) => ( + + + {tenant.name} + + + {tenant.slug || "-"} + + + {tenant.id} + + +
+ + +
+
+
+ ))} + {allowedTenantIds + .filter( + (tenantId) => + !selectedAllowedTenants.some( + (tenant) => tenant.id === tenantId, + ), + ) + .map((tenantId) => ( + + + {tenantId} + + + - + + + {tenantId} + + +
+ + +
+
+
+ ))} +
+
+
+ ) : ( +
+ {t( + "ui.dev.clients.general.tenant_access.selected_empty", + "아직 선택된 테넌트가 없습니다.", + )} +
+ )} +
-
+ ) : null}
diff --git a/devfront/src/features/clients/components/TenantAccessPicker.tsx b/devfront/src/features/clients/components/TenantAccessPicker.tsx new file mode 100644 index 00000000..259bc938 --- /dev/null +++ b/devfront/src/features/clients/components/TenantAccessPicker.tsx @@ -0,0 +1,146 @@ +import { Building2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { Button } from "../../../components/ui/button"; +import { t } from "../../../lib/i18n"; +import { + buildAuthenticatedOrgChartTenantPickerUrl, + type OrgChartTenantSelection, + parseOrgChartTenantSelection, +} from "../orgChartPicker"; + +type TenantAccessPickerProps = { + disabled?: boolean; + selectedCount: number; + onSelectTenant: (selection: OrgChartTenantSelection) => void; +}; + +function resolveOrgFrontBaseUrl() { + return ( + import.meta.env.VITE_ORGFRONT_PUBLIC_URL || + import.meta.env.ORGFRONT_URL || + "http://localhost:5175" + ); +} + +export function TenantAccessPicker({ + disabled = false, + selectedCount, + onSelectTenant, +}: TenantAccessPickerProps) { + const [pickerOpen, setPickerOpen] = useState(false); + const pickerUrl = useMemo( + () => buildAuthenticatedOrgChartTenantPickerUrl(resolveOrgFrontBaseUrl()), + [], + ); + + useEffect(() => { + if (!pickerOpen) return; + + const onMessage = (event: MessageEvent) => { + const selection = parseOrgChartTenantSelection(event.data); + if (!selection) return; + onSelectTenant(selection); + setPickerOpen(false); + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [onSelectTenant, pickerOpen]); + + const pickerDialog = + pickerOpen && typeof document !== "undefined" + ? createPortal( +
+
+
+
+

+ {t( + "ui.dev.clients.general.tenant_access.picker_title", + "테넌트 선택", + )} +

+

+ {t( + "msg.dev.clients.general.tenant_access.picker_description", + "orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다.", + )} +

+
+ +
+
+