From 600961f33d349bafa3fccb72baf0449bb49a4646 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 25 Feb 2026 14:17:45 +0900 Subject: [PATCH 01/10] =?UTF-8?q?slug=20=EB=AA=85=EC=B9=AD=20=ED=95=9C?= =?UTF-8?q?=EA=B8=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/components/ui/card.test.tsx | 35 +++++++++ adminfront/src/components/ui/input.test.tsx | 28 +++++++ adminfront/src/components/ui/label.test.tsx | 27 +++++++ adminfront/src/lib/i18n.test.ts | 33 +++++++++ adminfront/src/lib/utils.test.ts | 13 ++++ adminfront/tests/auth.spec.ts | 82 +++++++++++++++++++++ adminfront/tests/tenants.spec.ts | 75 +++++++++++++++++++ backend/internal/handler/tenant_handler.go | 25 +++---- backend/internal/utils/slug.go | 44 +++++++++++ backend/internal/utils/slug_test.go | 59 +++++++++++++++ 10 files changed, 406 insertions(+), 15 deletions(-) create mode 100644 adminfront/src/components/ui/card.test.tsx create mode 100644 adminfront/src/components/ui/input.test.tsx create mode 100644 adminfront/src/components/ui/label.test.tsx create mode 100644 adminfront/src/lib/i18n.test.ts create mode 100644 adminfront/src/lib/utils.test.ts create mode 100644 adminfront/tests/auth.spec.ts create mode 100644 adminfront/tests/tenants.spec.ts diff --git a/adminfront/src/components/ui/card.test.tsx b/adminfront/src/components/ui/card.test.tsx new file mode 100644 index 00000000..4bde79e9 --- /dev/null +++ b/adminfront/src/components/ui/card.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "./card"; + +describe("Card Component", () => { + it("renders card structure correctly", () => { + render( + + + Card Title + Card Description + + Card Content + Card Footer + , + ); + + expect(screen.getByText("Card Title")).toBeInTheDocument(); + expect(screen.getByText("Card Description")).toBeInTheDocument(); + expect(screen.getByText("Card Content")).toBeInTheDocument(); + expect(screen.getByText("Card Footer")).toBeInTheDocument(); + }); + + it("applies custom className to Card", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("custom-card"); + }); +}); diff --git a/adminfront/src/components/ui/input.test.tsx b/adminfront/src/components/ui/input.test.tsx new file mode 100644 index 00000000..011f8404 --- /dev/null +++ b/adminfront/src/components/ui/input.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { Input } from "./input"; + +describe("Input Component", () => { + it("renders correctly", () => { + render(); + expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument(); + }); + + it("handles value changes", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + const input = screen.getByPlaceholderText("Enter text"); + + await user.type(input, "Hello"); + expect(onChange).toHaveBeenCalled(); + expect(input).toHaveValue("Hello"); + }); + + it("is disabled when the disabled prop is passed", () => { + render(); + const input = screen.getByRole("textbox"); + expect(input).toBeDisabled(); + }); +}); diff --git a/adminfront/src/components/ui/label.test.tsx b/adminfront/src/components/ui/label.test.tsx new file mode 100644 index 00000000..cfde252a --- /dev/null +++ b/adminfront/src/components/ui/label.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Label } from "./label"; + +describe("Label Component", () => { + it("renders correctly with children", () => { + render(); + expect(screen.getByText("Username")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + render(); + const label = screen.getByText("Password"); + expect(label).toHaveClass("custom-label"); + }); + + it("is associated with an input via htmlFor", () => { + render( + <> + + + + ); + const label = screen.getByText("Label Text"); + expect(label).toHaveAttribute("for", "test-input"); + }); +}); diff --git a/adminfront/src/lib/i18n.test.ts b/adminfront/src/lib/i18n.test.ts new file mode 100644 index 00000000..fbfd4e13 --- /dev/null +++ b/adminfront/src/lib/i18n.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { t } from "./i18n"; + +describe("i18n utility", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + }); + + it("returns fallback if key not found", () => { + expect(t("non.existent.key", "Fallback")).toBe("Fallback"); + }); + + it("returns key if fallback not provided and key not found", () => { + expect(t("non.existent.key")).toBe("non.existent.key"); + }); + + it("replaces variables in template", () => { + expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe("Hello World"); + }); + + it("respects locale in localStorage", () => { + window.localStorage.setItem("locale", "en"); + // We expect some key that exists in en.toml + // Let's use a common one or a fallback if we don't know the content + expect(t("ui.common.save", "Save")).toBe("Save"); + }); + + it("defaults to ko if no locale set and browser language is ko", () => { + vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('ko-KR'); + expect(t("ui.common.save", "저장")).toBe("저장"); + }); +}); diff --git a/adminfront/src/lib/utils.test.ts b/adminfront/src/lib/utils.test.ts new file mode 100644 index 00000000..a5ad7f08 --- /dev/null +++ b/adminfront/src/lib/utils.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { cn } from "./utils"; + +describe("cn utility", () => { + it("merges class names correctly", () => { + expect(cn("a", "b")).toBe("a b"); + expect(cn("a", { b: true, c: false })).toBe("a b"); + }); + + it("handles tailwind class conflicts", () => { + expect(cn("px-2 py-2", "px-4")).toBe("py-2 px-4"); + }); +}); diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts new file mode 100644 index 00000000..1ce4b243 --- /dev/null +++ b/adminfront/tests/auth.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Authentication', () => { + test.beforeEach(async ({ page }) => { + // Mock OIDC configuration + await page.route('**/oidc/.well-known/openid-configuration', async route => { + await route.fulfill({ + json: { + issuer: "http://localhost:5000/oidc", + authorization_endpoint: "http://localhost:5000/oidc/auth", + token_endpoint: "http://localhost:5000/oidc/token", + jwks_uri: "http://localhost:5000/oidc/jwks", + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"] + } + }); + }); + }); + + test('should redirect unauthorized users to login page', async ({ page }) => { + await page.goto('/'); + // Should be redirected to /login + await expect(page).toHaveURL(/\/login/); + await expect(page.locator('h1')).toContainText('Baron SSO'); + }); + + test('should allow access to dashboard when authenticated', async ({ page }) => { + await page.addInitScript(() => { + const authority = "http://localhost:5000/oidc"; + const client_id = "adminfront"; + const key = `oidc.user:${authority}:${client_id}`; + const authData = { + access_token: 'fake-token', + token_type: 'Bearer', + profile: { + sub: 'admin-user', + name: 'Admin User', + email: 'admin@example.com' + }, + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + await page.goto('/'); + + // Wait for the auth loading to finish + await expect(page.locator('.animate-spin')).not.toBeVisible(); + + // Should be on the dashboard/overview + await expect(page.locator('aside')).toBeVisible(); + await expect(page.locator('h1')).toContainText('Admin Control'); + }); + + test('should logout and redirect to login page', async ({ page }) => { + // Start authenticated + await page.addInitScript(() => { + const authority = "http://localhost:5000/oidc"; + const client_id = "adminfront"; + const key = `oidc.user:${authority}:${client_id}`; + const authData = { + access_token: 'fake-token', + token_type: 'Bearer', + profile: { sub: 'admin-user', name: 'Admin' }, + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + await page.goto('/'); + await expect(page.locator('aside')).toBeVisible(); + + // Mock window.confirm + page.on('dialog', dialog => dialog.accept()); + + // Click logout button (label: ui.admin.nav.logout) + await page.click('button:has-text("Logout"), button:has-text("로그아웃")'); + + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts new file mode 100644 index 00000000..57ce51cb --- /dev/null +++ b/adminfront/tests/tenants.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Tenants Management', () => { + test.beforeEach(async ({ page }) => { + // Authenticate + await page.addInitScript(() => { + const authority = "http://localhost:5000/oidc"; + const client_id = "adminfront"; + const key = `oidc.user:${authority}:${client_id}`; + const authData = { + access_token: 'fake-token', + token_type: 'Bearer', + profile: { sub: 'admin-user', name: 'Admin User', email: 'admin@example.com' }, + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + // Mock OIDC config to avoid redirects + await page.route('**/oidc/.well-known/openid-configuration', async route => { + await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + }); + }); + + test('should list tenants', async ({ page }) => { + await page.route('**/api/v1/admin/tenants*', async route => { + await route.fulfill({ + json: { + items: [ + { id: '1', name: 'Tenant A', slug: 'tenant-a', status: 'active', type: 'COMPANY', updatedAt: new Date().toISOString() }, + ], + total: 1, + limit: 1000, + offset: 0 + } + }); + }); + + await page.goto('/tenants'); + await expect(page.locator('h2')).toContainText('테넌트 목록'); + await expect(page.locator('table')).toContainText('Tenant A'); + }); + + test('should create a new tenant', async ({ page }) => { + // Mock GET for list (empty) and for parents + await page.route('**/api/v1/admin/tenants*', async route => { + if (route.request().method() === 'GET') { + await route.fulfill({ json: { items: [], total: 0, limit: 100, offset: 0 } }); + } else if (route.request().method() === 'POST') { + await route.fulfill({ + json: { id: '2', name: 'New Tenant', slug: 'new-tenant', status: 'active', type: 'COMPANY' } + }); + } + }); + + await page.goto('/tenants/new'); + + await page.fill('input >> nth=0', 'New Tenant'); + await page.fill('input >> nth=1', 'new-tenant'); + await page.fill('textarea', 'Description'); + + await page.click('button:has-text("생성")'); + + await expect(page).toHaveURL(/\/tenants$/); + }); + + test('should show validation error on empty name', async ({ page }) => { + await page.goto('/tenants/new'); + const submitBtn = page.locator('button:has-text("생성")'); + await expect(submitBtn).toBeDisabled(); + + await page.fill('input >> nth=0', 'Valid Name'); + await expect(submitBtn).not.toBeDisabled(); + }); +}); diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 1e1182a2..737e0105 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -4,6 +4,7 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" + "baron-sso-backend/internal/utils" "errors" "strings" "time" @@ -166,9 +167,15 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) } - slug := normalizeTenantSlug(req.Slug) + slug := req.Slug if slug == "" { - slug = normalizeTenantSlug(name) + slug = utils.GenerateUniqueSlug(name, func(s string) bool { + var count int64 + h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", s).Count(&count) + return count > 0 + }) + } else { + slug = utils.GenerateSlug(slug) } if slug == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "slug is required"}) @@ -240,7 +247,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { tenant.Name = name } if req.Slug != nil { - slug := normalizeTenantSlug(*req.Slug) + slug := utils.GenerateSlug(*req.Slug) if slug == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "slug cannot be empty"}) } @@ -436,18 +443,6 @@ func mapTenantSummary(t domain.Tenant) tenantSummary { } } -func normalizeTenantSlug(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - value = strings.ReplaceAll(value, " ", "-") - var b strings.Builder - for _, r := range value { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - b.WriteRune(r) - } - } - return strings.Trim(b.String(), "-") -} - func normalizeTenantStatus(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { diff --git a/backend/internal/utils/slug.go b/backend/internal/utils/slug.go index b736a902..6c9b4ba3 100644 --- a/backend/internal/utils/slug.go +++ b/backend/internal/utils/slug.go @@ -1,6 +1,7 @@ package utils import ( + "fmt" "regexp" "strings" ) @@ -75,3 +76,46 @@ func ValidateSlug(slug string) (bool, string) { return true, "" } + +// GenerateSlug generates a base slug from a given string. +// It removes special characters, replaces spaces with hyphens, and converts to lowercase. +func GenerateSlug(name string) string { + // Convert to lowercase + s := strings.ToLower(strings.TrimSpace(name)) + + // Replace non-alphanumeric characters (including spaces) with a hyphen + re := regexp.MustCompile(`[^a-z0-9]+`) + s = re.ReplaceAllString(s, "-") + + // Remove leading and trailing hyphens + s = strings.Trim(s, "-") + + // Handle empty slug + if s == "" { + s = "tenant" + } + + // Truncate to maximum length of 32 (reserving space for suffixes) + if len(s) > 25 { + s = s[:25] + s = strings.TrimSuffix(s, "-") + } + + return s +} + +// GenerateUniqueSlug generates a unique slug by appending a suffix if the base slug exists. +// It takes the base name and a checker function that returns true if the slug already exists. +func GenerateUniqueSlug(name string, exists func(string) bool) string { + baseSlug := GenerateSlug(name) + + slug := baseSlug + counter := 1 + + for reservedSlugs[slug] || exists(slug) { + slug = fmt.Sprintf("%s-%d", baseSlug, counter) + counter++ + } + + return slug +} diff --git a/backend/internal/utils/slug_test.go b/backend/internal/utils/slug_test.go index 15dad8ef..fd6f400d 100644 --- a/backend/internal/utils/slug_test.go +++ b/backend/internal/utils/slug_test.go @@ -54,3 +54,62 @@ func TestValidateSlug_Format(t *testing.T) { }) } } + +func TestGenerateSlug(t *testing.T) { + tests := []struct { + name string + expected string + }{ + {"Hello World", "hello-world"}, + {"My Company!@#", "my-company"}, + {"---Test---", "test"}, + {" Spaces ", "spaces"}, + {"A VERY LONG NAME THAT EXCEEDS THIRTY TWO CHARACTERS", "a-very-long-name-that-exc"}, + {"한글 테스트", "tenant"}, // Non-ascii characters will be replaced by hyphens and trimmed to empty, then fallback to "tenant" + {"Test 한글 Mix", "test-mix"}, + {"", "tenant"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + slug := GenerateSlug(tt.name) + assert.Equal(t, tt.expected, slug) + // Ensure generated slug is valid (unless it's reserved like "slug" wasn't reserved, but let's check format) + if !reservedSlugs[slug] { + valid, _ := ValidateSlug(slug) + assert.True(t, valid, "Generated slug should be valid format") + } + }) + } +} + +func TestGenerateUniqueSlug(t *testing.T) { + existingSlugs := map[string]bool{ + "my-company": true, + "my-company-1": true, + "test": true, + } + + existsFunc := func(slug string) bool { + return existingSlugs[slug] + } + + tests := []struct { + name string + expected string + }{ + {"My Company", "my-company-2"}, + {"Test", "test-1"}, + {"New Company", "new-company"}, + {"admin", "admin-1"}, // "admin" is reserved, so it should append suffix + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + slug := GenerateUniqueSlug(tt.name, existsFunc) + assert.Equal(t, tt.expected, slug) + valid, _ := ValidateSlug(slug) + assert.True(t, valid, "Generated unique slug should be valid") + }) + } +} From ca45a14bae598e7a88ebd814c77321183200f7f4 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 27 Feb 2026 10:29:15 +0900 Subject: [PATCH 02/10] =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EC=A1=B0=EC=A7=81=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/components/ui/label.test.tsx | 2 +- adminfront/src/components/ui/tabs.tsx | 87 ++ .../tenants/routes/TenantCreatePage.tsx | 4 +- .../tenants/routes/TenantListPage.tsx | 191 +-- .../routes/TenantUserGroupsTab.tsx | 1354 ++++++++++++----- adminfront/src/lib/adminApi.ts | 11 +- adminfront/src/lib/i18n.test.ts | 6 +- adminfront/src/locales/en.toml | 22 +- adminfront/src/locales/ko.toml | 11 +- adminfront/tests/auth.spec.ts | 83 +- adminfront/tests/tenants.spec.ts | 88 +- backend/cmd/server/main.go | 2 +- backend/internal/bootstrap/tenant_seed.go | 2 +- backend/internal/domain/user.go | 32 +- .../handler/auth_handler_async_test.go | 11 +- backend/internal/handler/tenant_handler.go | 111 +- .../internal/handler/tenant_handler_test.go | 6 +- backend/internal/handler/user_handler.go | 318 ++-- .../internal/repository/user_repository.go | 107 +- backend/internal/service/tenant_service.go | 6 +- .../service/tenant_service_edge_test.go | 6 +- .../internal/service/tenant_service_test.go | 17 +- .../service/user_group_service_test.go | 14 + docs/UI_DESIGN_POLICY.md | 118 ++ docs/keto-rebac-namespaces-diagram.md | 87 ++ locales/en.toml | 9 +- locales/ko.toml | 7 + 27 files changed, 1906 insertions(+), 806 deletions(-) create mode 100644 adminfront/src/components/ui/tabs.tsx create mode 100644 docs/UI_DESIGN_POLICY.md create mode 100644 docs/keto-rebac-namespaces-diagram.md diff --git a/adminfront/src/components/ui/label.test.tsx b/adminfront/src/components/ui/label.test.tsx index cfde252a..25409101 100644 --- a/adminfront/src/components/ui/label.test.tsx +++ b/adminfront/src/components/ui/label.test.tsx @@ -19,7 +19,7 @@ describe("Label Component", () => { <> - + , ); const label = screen.getByText("Label Text"); expect(label).toHaveAttribute("for", "test-input"); diff --git a/adminfront/src/components/ui/tabs.tsx b/adminfront/src/components/ui/tabs.tsx new file mode 100644 index 00000000..71364fda --- /dev/null +++ b/adminfront/src/components/ui/tabs.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; +import { cn } from "../../lib/utils"; + +const TabsContext = React.createContext<{ + value?: string; + onValueChange?: (value: string) => void; +}>({}); + +const Tabs = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { + value?: string; + onValueChange?: (value: string) => void; + } +>(({ className, value, onValueChange, ...props }, ref) => { + return ( + +
+ + ); +}); +Tabs.displayName = "Tabs"; + +const TabsList = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TabsList.displayName = "TabsList"; + +const TabsTrigger = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes & { value: string } +>(({ className, value, ...props }, ref) => { + const { value: activeValue, onValueChange } = React.useContext(TabsContext); + const isSelected = activeValue === value; + + return ( + - -
- - - {tenant.children.map((child) => ( - - ))} - - ); -}; - function TenantListPage() { const query = useQuery({ - queryKey: ["tenants", { limit: 1000, offset: 0 }], // Fetch all to build tree + queryKey: ["tenants", { limit: 1000, offset: 0 }], queryFn: () => fetchTenants(1000, 0), }); + const navigate = useNavigate(); const deleteMutation = useMutation({ mutationFn: (tenantId: string) => deleteTenant(tenantId), onSuccess: () => { @@ -153,7 +48,7 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const tenantTree = query.data?.items ? buildTenantTree(query.data.items) : []; + const tenants = query.data?.items ?? []; const handleDelete = (tenantId: string, tenantName: string) => { if ( @@ -182,12 +77,12 @@ function TenantListPage() {

- {t("ui.admin.tenants.title", "테넌트 목록")} + {t("ui.admin.tenants.title", "테넌트 레지스트리")}

{t( "msg.admin.tenants.subtitle", - "현재 등록된 테넌트를 확인하고 상태를 관리합니다.", + "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", )}

@@ -213,7 +108,7 @@ function TenantListPage() {
- {t("ui.admin.tenants.registry.title", "Tenant registry")} + {t("ui.admin.tenants.registry.title", "Tenant Registry")} {t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { @@ -247,6 +142,9 @@ function TenantListPage() { {t("ui.admin.tenants.table.status", "STATUS")} + + {t("ui.admin.tenants.table.members", "MEMBERS")} + {t("ui.admin.tenants.table.updated", "UPDATED")} @@ -258,15 +156,15 @@ function TenantListPage() { {query.isLoading && ( - + {t("msg.common.loading", "로딩 중...")} )} - {!query.isLoading && tenantTree.length === 0 && ( + {!query.isLoading && tenants.length === 0 && ( {t( @@ -276,14 +174,63 @@ function TenantListPage() { )} - {tenantTree.map((tenant) => ( - + {tenants.map((tenant) => ( + + {tenant.name} + + + {t( + `domain.tenant_type.${tenant.type?.toLowerCase()}`, + tenant.type, + )} + + + + {tenant.slug} + + + + {t(`ui.common.status.${tenant.status}`, tenant.status)} + + + + {tenant.memberCount} + + + {tenant.updatedAt + ? new Date(tenant.updatedAt).toLocaleString("ko-KR") + : "-"} + + +
+ + +
+
+
))}
diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index 8a053d7c..13bc0537 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -1,19 +1,25 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { + ArrowRight, + Briefcase, + Building2, + Check, ChevronDown, ChevronRight, + CornerDownRight, + Network, Plus, RefreshCw, - Shield, + Search, Trash2, - UserMinus, + UserCircle, UserPlus, Users, } from "lucide-react"; import type React from "react"; -import { useState } from "react"; -import { useParams } from "react-router-dom"; +import { useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; @@ -24,6 +30,15 @@ import { CardHeader, CardTitle, } from "../../../components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { @@ -35,498 +50,1015 @@ import { TableRow, } from "../../../components/ui/table"; import { - type GroupSummary, - addGroupMember, - createGroup, - deleteGroup, - fetchGroups, - removeGroupMember, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "../../../components/ui/tabs"; +import { + type TenantSummary, + type UserSummary, + createUser, + fetchTenants, + fetchUsers, + updateTenant, + updateUser, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -type UserGroupNode = GroupSummary & { children: UserGroupNode[] }; +type TenantNode = TenantSummary & { + children: TenantNode[]; + recursiveMemberCount: number; +}; -function buildGroupTree(groups: GroupSummary[]): UserGroupNode[] { - const nodeMap = new Map(); - const rootNodes: UserGroupNode[] = []; - - for (const group of groups) { - nodeMap.set(group.id, { ...group, children: [] }); +const getTenantIcon = (type?: string) => { + switch (type?.toUpperCase()) { + case "COMPANY_GROUP": + return Briefcase; + case "PERSONAL": + return UserCircle; + case "USER_GROUP": + return Network; + default: + return Building2; } +}; - for (const group of groups) { - const node = nodeMap.get(group.id); - if (!node) continue; +const MemberListDialog: React.FC<{ + node: TenantNode; + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}> = ({ node, trigger, open, onOpenChange }) => { + const [activeTab, setActiveTab] = useState("direct"); - if (group.parentId && nodeMap.has(group.parentId)) { - const parent = nodeMap.get(group.parentId); - if (parent) { - parent.children.push(node); + const { + data: directData, + isLoading: isDirectLoading, + refetch: refetchDirect, + } = useQuery({ + queryKey: ["tenant-members", node.slug], + queryFn: () => fetchUsers(100, 0, undefined, node.slug), + enabled: open && activeTab === "direct", + }); + + const descendantSlugs = useMemo(() => { + const slugs: string[] = []; + const collect = (n: TenantNode) => { + for (const child of n.children) { + slugs.push(child.slug); + collect(child); } - } else { - rootNodes.push(node); - } - } + }; + collect(node); + return slugs; + }, [node]); - const sortNodes = (nodes: UserGroupNode[]) => { - nodes.sort((a, b) => a.name.localeCompare(b.name)); - for (const node of nodes) { - sortNodes(node.children); + const { + data: descendantData, + isLoading: isDescendantLoading, + refetch: refetchDescendant, + } = useQuery({ + queryKey: ["tenant-descendant-members", node.id], + queryFn: async () => { + if (descendantSlugs.length === 0) return []; + // Fetch users for all descendant slugs in parallel + const results = await Promise.all( + descendantSlugs + .slice(0, 10) + .map((slug) => fetchUsers(50, 0, undefined, slug)), + ); + return results.flatMap((res) => res.items); + }, + enabled: open && activeTab === "descendants" && descendantSlugs.length > 0, + }); + + const directMembers = directData?.items ?? []; + const descendantMembers = descendantData ?? []; + + return ( + + {trigger && {trigger}} + + + + + {node.name}{" "} + {t("ui.admin.tenants.members.list_title", "구성원 관리")} + + + {t( + "msg.admin.tenants.members.desc", + "조직에 소속된 사용자 목록을 확인합니다.", + )} + + + + +
+ + + {t("ui.admin.tenants.members.direct", "소속 멤버")} ( + {node.memberCount || 0}) + + + {t("ui.admin.tenants.members.descendants", "하위 조직 멤버")} ( + {node.recursiveMemberCount - (node.memberCount || 0)}) + + +
+ + + + + + + + {descendantSlugs.length > 10 && ( +

+ *{" "} + {t( + "msg.admin.tenants.members.limit_notice", + "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다.", + )} +

+ )} +
+
+ + + + +
+
+ ); +}; + +const MemberTable: React.FC<{ + members: UserSummary[]; + isLoading: boolean; + onRefresh: () => void; + showTenant?: boolean; +}> = ({ members, isLoading, onRefresh, showTenant }) => ( +
+ + + + + {t("ui.admin.users.table.name", "NAME")} + + + {t("ui.admin.users.table.email", "EMAIL")} + + {showTenant && ( + + {t("ui.admin.tenants.table.slug", "TENANT")} + + )} + + {t("ui.admin.users.table.role", "ROLE")} + + + + + {isLoading ? ( + + + {t("msg.common.loading", "로딩 중...")} + + + ) : members.length === 0 ? ( + + +
+ +

{t("msg.admin.users.list.empty", "멤버가 없습니다.")}

+ +
+
+
+ ) : ( + members.map((user) => ( + + {user.name} + + {user.email} + + {showTenant && ( + + + {user.companyCode} + + + )} + + + {user.role} + + + + )) + )} +
+
+
+); + +const UserAddDialog: React.FC<{ + tenantSlug: string; + tenantName: string; + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}> = ({ tenantSlug, tenantName, trigger, open, onOpenChange }) => { + const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState("select"); + + // Create state + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + + // Select state + const [userSearch, setUserSearch] = useState(""); + const [isSearching, setIsSearching] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(null); + + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSearch = async () => { + if (!userSearch) return; + setIsSearching(true); + try { + const res = await fetchUsers(20, 0, userSearch); + setSearchResults(res.items); + } catch (err) { + toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패")); + } finally { + setIsSearching(false); } }; - sortNodes(rootNodes); - return rootNodes; -} + const handleCreate = async () => { + if (!email || !name) { + toast.error( + t( + "msg.admin.users.create.form.email_required", + "이메일과 이름은 필수입니다.", + ), + ); + return; + } + setIsSubmitting(true); + try { + const res = await createUser({ + email, + name, + companyCode: tenantSlug, + role: "user", + }); + toast.success( + t("msg.admin.users.create.success", "사용자가 생성되었습니다."), + { + description: res.initialPassword + ? `초기 비밀번호: ${res.initialPassword}` + : undefined, + duration: 10000, + }, + ); -interface UserGroupTreeNodeProps { - node: UserGroupNode; + // Refresh tenant tree to update member counts + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); + }, 1000); // Wait 1s for backend async sync + + onOpenChange?.(false); + resetFields(); + } catch (err: unknown) { + const error = err as { response?: { data?: { error?: string } } }; + toast.error( + error.response?.data?.error || + t("msg.admin.users.create.error", "사용자 생성 실패"), + ); + } finally { + setIsSubmitting(false); + } + }; + + const handleAssign = async () => { + if (!selectedUserId) return; + setIsSubmitting(true); + try { + await updateUser(selectedUserId, { companyCode: tenantSlug }); + toast.success( + t("msg.info.saved_success", "사용자가 테넌트에 배정되었습니다."), + ); + + // Refresh tenant tree to update member counts + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); + }, 1000); // Wait 1s for backend async sync + + onOpenChange?.(false); + resetFields(); + } catch (err: unknown) { + const error = err as { response?: { data?: { error?: string } } }; + toast.error( + error.response?.data?.error || + t("msg.admin.users.detail.update_error", "배정 실패"), + ); + } finally { + setIsSubmitting(false); + } + }; + + const resetFields = () => { + setEmail(""); + setName(""); + setUserSearch(""); + setSearchResults([]); + setSelectedUserId(null); + }; + + return ( + { + onOpenChange?.(v); + if (!v) resetFields(); + }} + > + {trigger && {trigger}} + + + + {t("ui.admin.users.create.title", "사용자 추가")} + + + [{tenantName}] 테넌트에 사용자를 등록하거나 기존 사용자를 + 배정합니다. + + + + + + + {t("ui.common.select", "기존 사용자 선택")} + + + {t("ui.common.create", "신규 생성")} + + + + +
+ setUserSearch(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> + +
+ +
+ + + {searchResults.length === 0 ? ( + + + {isSearching + ? t("msg.common.loading", "검색 중...") + : t( + "msg.admin.users.list.empty", + "사용자를 검색해 주세요.", + )} + + + ) : ( + searchResults.map((user) => ( + setSelectedUserId(user.id)} + > + +
+
+ {user.name} +
+
+ {user.email} +
+ {user.companyCode && ( + + {user.companyCode} + + )} +
+ {selectedUserId === user.id && ( + + )} +
+
+ )) + )} +
+
+
+
+ + +
+ + setEmail(e.target.value)} + placeholder="user@example.com" + /> +
+
+ + setName(e.target.value)} + placeholder="홍길동" + /> +
+
+
+ + + + {activeTab === "create" ? ( + + ) : ( + + )} + +
+
+ ); +}; + +const TenantTreeRow: React.FC<{ + node: TenantNode; level: number; - onSelect: (groupId: string) => void; - selectedGroupId: string | null; - onDelete: (groupId: string, groupName: string) => void; - onAddSubGroup: (parentId: string) => void; -} - -const UserGroupTreeNode: React.FC = ({ - node, - level, - onSelect, - selectedGroupId, - onDelete, - onAddSubGroup, -}) => { + isRoot: boolean; + onRemove: (id: string, name: string) => void; + isUpdating: boolean; +}> = ({ node, level, isRoot, onRemove, isUpdating }) => { + const navigate = useNavigate(); const [isExpanded, setIsExpanded] = useState(true); + const [isUserAddOpen, setIsUserAddOpen] = useState(false); + const [isMemberListOpen, setIsMemberListOpen] = useState(false); const hasChildren = node.children && node.children.length > 0; + const TypeIcon = getTenantIcon(node.type); + return ( <> onSelect(node.id)} + className={`group hover:bg-muted/30 transition-colors ${isRoot ? "bg-primary/5 font-bold" : ""}`} >
- {hasChildren && ( + {hasChildren ? ( + ) : ( + level > 0 && ( +
+
+
+ ) )} - {!hasChildren &&
} - - {node.name} - - {node.unitType || "Team"} + {!isRoot && ( + + )} +
+ +
+ {node.name} + {isRoot && ( + + Root + + )} + + {t(`domain.tenant_type.${node.type?.toLowerCase()}`, node.type)}
- - {node.members?.length || 0} + + {node.slug} - - - + + + + - - + {t(`ui.common.status.${node.status}`, node.status)} + + + +
+ + + {!isRoot && ( + + )} +
+
{isExpanded && - hasChildren && node.children.map((child) => ( - ))} ); }; -export function TenantUserGroupsTab() { - const params = useParams<{ tenantId: string }>(); - const tenantId = params.tenantId ?? ""; +function TenantUserGroupsTab() { + const { tenantId } = useParams<{ tenantId: string }>(); + const queryClient = useQueryClient(); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isHeaderUserAddOpen, setIsHeaderUserAddOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); - const [newGroupName, setNewGroupName] = useState(""); - const [newGroupDesc, setNewGroupNameDesc] = useState(""); - const [newGroupUnitType, setNewGroupUnitType] = useState("Team"); - const [newGroupParentId, setNewGroupParentId] = useState(null); + if (!tenantId) return null; - const [selectedGroupId, setSelectedGroupId] = useState(null); - - const groupsQuery = useQuery({ - queryKey: ["groups", tenantId], - queryFn: () => fetchGroups(tenantId), - enabled: !!tenantId, + const { data, isLoading, refetch } = useQuery({ + queryKey: ["tenants-full-tree-v2"], + queryFn: () => fetchTenants(1000, 0), }); - const createMutation = useMutation({ - mutationFn: () => - createGroup(tenantId, { - name: newGroupName, - description: newGroupDesc, - unitType: newGroupUnitType, - parentId: newGroupParentId || undefined, - }), + const updateParentMutation = useMutation({ + mutationFn: ({ + id, + parentId, + }: { id: string; parentId: string | undefined }) => + updateTenant(id, { parentId: parentId || "" }), onSuccess: () => { - toast.success( - t( - "msg.admin.groups.list.create_success", - "그룹이 성공적으로 생성되었습니다.", - ), - ); - groupsQuery.refetch(); - setNewGroupName(""); - setNewGroupNameDesc(""); - setNewGroupUnitType("Team"); - setNewGroupParentId(null); + queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); + toast.success(t("msg.info.saved_success", "저장되었습니다.")); + setIsAddDialogOpen(false); }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), { - description: error.response?.data?.error || error.message, + }); + + const allTenants = data?.items ?? []; + + const { currentBase, subTree } = useMemo(() => { + if (allTenants.length === 0) return { currentBase: null, subTree: [] }; + + const tenantMap = new Map(); + for (const t of allTenants) { + tenantMap.set(t.id, { + ...t, + children: [], + recursiveMemberCount: t.memberCount || 0, }); - }, - }); + } - const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteGroup(tenantId, id), - onSuccess: () => { - toast.success( - t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."), - ); - groupsQuery.refetch(); - if (selectedGroupId && selectedGroupId === deleteMutation.variables) { - setSelectedGroupId(null); + // Build initial children relations + for (const t of allTenants) { + if (t.parentId) { + const parent = tenantMap.get(t.parentId); + const child = tenantMap.get(t.id); + if (parent && child) { + parent.children.push(child); + } } - }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.common.error", "그룹 삭제 실패"), { - description: error.response?.data?.error || error.message, - }); - }, - }); + } - const addMemberMutation = useMutation({ - mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => - addGroupMember(tenantId, groupId, userId), - onSuccess: () => { - toast.success( - t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."), - ); - groupsQuery.refetch(); - }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.common.error", "오류 발생"), { - description: error.response?.data?.error || error.message, - }); - }, - }); + // Function to calculate recursive counts + const calculateRecursive = (node: TenantNode): number => { + let total = node.memberCount || 0; + for (const child of node.children) { + total += calculateRecursive(child); + } + node.recursiveMemberCount = total; + return total; + }; - const removeMemberMutation = useMutation({ - mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => - removeGroupMember(tenantId, groupId, userId), - onSuccess: () => { - toast.success( - t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."), - ); - groupsQuery.refetch(); - }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.common.error", "오류 발생"), { - description: error.response?.data?.error || error.message, - }); - }, - }); + // Calculate for all root nodes (those without parent or top-level in current context) + for (const node of tenantMap.values()) { + // We only strictly need to calculate from the top-most nodes to cover everything + if (!node.parentId) { + calculateRecursive(node); + } + } - const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data) : []; + // Re-calculate specifically for our current tenant to be sure if it wasn't a global root + const base = tenantMap.get(tenantId); + if (base) { + calculateRecursive(base); + } - const handleAddSubGroup = (parentId: string) => { - setNewGroupParentId(parentId); - }; + return { + currentBase: base || null, + subTree: base ? base.children : [], + }; + }, [allTenants, tenantId]); - const handleDeleteGroup = (groupId: string, groupName: string) => { + const handleAdd = (id: string) => + updateParentMutation.mutate({ id, parentId: tenantId }); + const handleRemove = (id: string, name: string) => { if ( window.confirm( t( - "msg.admin.groups.list.delete_confirm", - `그룹 "{{name}}"을(를) 삭제하시겠습니까?`, - { name: groupName }, + "msg.admin.tenants.remove_sub_confirm", + `${name} 테넌트를 하위 조직에서 제외할까요?`, + { name }, ), ) ) { - deleteMutation.mutate(groupId); + updateParentMutation.mutate({ id, parentId: undefined }); } }; - const handleAddMember = (groupId: string) => { - const userId = window.prompt( - t( - "msg.admin.groups.prompt.user_id", - "추가할 사용자의 UUID를 입력하세요:", - ), + if (isLoading) + return ( +
+ {t("msg.common.loading", "로딩 중...")} +
+ ); + if (!currentBase) + return ( +
+ {t("msg.admin.tenants.not_found", "현재 테넌트를 찾을 수 없습니다.")}{" "} + (ID: {tenantId}) +
); - if (userId) { - addMemberMutation.mutate({ groupId, userId }); - } - }; - const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId); + const candidates = allTenants.filter((t) => { + if (t.id === tenantId) return false; + // Check if it's already a child + if (t.parentId === tenantId) return false; + // Basic search + if (searchTerm === "") return true; + return ( + t.name.toLowerCase().includes(searchTerm.toLowerCase()) || + t.slug.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }); + + const BaseIcon = getTenantIcon(currentBase.type); return (
-
- - - - {" "} - {t("ui.admin.groups.create.title", "새 그룹 생성")} - - - -
- - setNewGroupName(e.target.value)} - /> -
-
- - setNewGroupUnitType(e.target.value)} - /> -
-
- - -
-
- - setNewGroupNameDesc(e.target.value)} - /> -
- -
-
- - - -
- - {t("ui.admin.groups.list.title", "User Groups")} - - - {t( - "msg.admin.groups.list.subtitle", - "이 테넌트에 정의된 사용자 그룹 목록입니다.", - )} - -
- -
- - - - - - {t("ui.admin.groups.table.name", "NAME")} - - - {t("ui.admin.groups.table.members", "MEMBERS")} - - - {t("ui.admin.groups.table.actions", "ACTIONS")} - - - - - {groupsQuery.isLoading && ( - - - {t("msg.admin.groups.list.loading", "로딩 중...")} - - - )} - {!groupsQuery.isLoading && groupTree.length === 0 && ( - - - {t( - "msg.admin.groups.list.empty", - "아직 등록된 그룹이 없습니다.", - )} - - - )} - {groupTree.map((node) => ( - - ))} - -
-
-
-
- - {currentGroup && ( - - - - {" "} - {t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", { - name: currentGroup.name, + + +
+ + + {t("ui.admin.tenants.sub.title", "조직 계층 구조", { + count: subTree.length, })} - - -
- -
- - - - - {t("ui.admin.groups.members.table.name", "이름")} - - - {t("ui.admin.groups.members.table.email", "이메일")} - - - {t("ui.admin.groups.members.table.remove", "제거")} - - - - - {currentGroup.members?.length === 0 && ( - - - {t("msg.admin.groups.members.empty", "멤버가 없습니다.")} - - - )} - {currentGroup.members?.map((user) => ( - - {user.name} - - {user.email} - - - + + + + + + + + + + + + + + {t( + "ui.admin.tenants.sub.add_dialog_title", + "하위 테넌트 추가", + )} + + + {t( + "ui.admin.tenants.sub.add_dialog_desc", + "기존에 등록된 테넌트를 검색하여 하위 조직으로 추가합니다.", + )} + + +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+
+ + {candidates.length === 0 ? ( + + + {t( + "ui.admin.tenants.sub.no_candidates", + "검색 결과 없음", + )} + + + ) : ( + candidates.map((tenant) => { + const CandidateIcon = getTenantIcon(tenant.type); + return ( + + +
+
+ +
+
+
+ {tenant.name} +
+
+ {tenant.slug} +
+
+
+
+ + + {t( + `domain.tenant_type.${tenant.type?.toLowerCase()}`, + tenant.type, + )} + + + + + +
+ ); }) - } - disabled={removeMemberMutation.isPending} - > - - - - - ))} -
-
-
- - )} + )} + + +
+
+ + +
+ + + + + + + {t("ui.admin.tenants.table.name", "NAME")} + + + {t("ui.admin.tenants.table.slug", "SLUG")} + + + {t("ui.admin.tenants.table.members", "MEMBERS")} + + + {t("ui.admin.tenants.table.status", "STATUS")} + + + {t("ui.admin.tenants.table.actions", "ACTIONS")} + + + + + + +
+
+
); } diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index c18ff65e..1d68ad73 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -29,6 +29,7 @@ export type TenantSummary = { domains?: string[]; parentId?: string; config?: Record; + memberCount: number; // Added member count createdAt: string; updatedAt: string; }; @@ -55,6 +56,7 @@ export type TenantUpdateRequest = { name?: string; type?: string; slug?: string; + parentId?: string; description?: string; status?: string; domains?: string[]; @@ -380,9 +382,14 @@ export type UserUpdateRequest = { jobTitle?: string; }; -export async function fetchUsers(limit = 50, offset = 0, search?: string) { +export async function fetchUsers( + limit = 50, + offset = 0, + search?: string, + companyCode?: string, +) { const { data } = await apiClient.get("/v1/admin/users", { - params: { limit, offset, search }, + params: { limit, offset, search, companyCode }, }); return data; } diff --git a/adminfront/src/lib/i18n.test.ts b/adminfront/src/lib/i18n.test.ts index fbfd4e13..cd5b5f0a 100644 --- a/adminfront/src/lib/i18n.test.ts +++ b/adminfront/src/lib/i18n.test.ts @@ -16,7 +16,9 @@ describe("i18n utility", () => { }); it("replaces variables in template", () => { - expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe("Hello World"); + expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe( + "Hello World", + ); }); it("respects locale in localStorage", () => { @@ -27,7 +29,7 @@ describe("i18n utility", () => { }); it("defaults to ko if no locale set and browser language is ko", () => { - vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('ko-KR'); + vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR"); expect(t("ui.common.save", "저장")).toBe("저장"); }); }); diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index a12529d1..65edff3f 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -126,6 +126,8 @@ description = "Description" delete_confirm = "Delete Tenant \\\"{{name}}\\\"?" empty = "Empty" fetch_error = "Fetch Error" +not_found = "Tenant not found." +remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?' subtitle = "Subtitle" [msg.admin.tenants.create] @@ -760,10 +762,26 @@ type_boolean = "Boolean" type_number = "Number" type_text = "Text" +[ui.admin.tenants.detail] +breadcrumb_list = "Tenant List" +header_subtitle = "Update tenant information or manage integration settings." +loading = "Loading tenant information..." +tab_admins = "Admin Settings" +tab_federation = "External Integration" +tab_organization = "Sub-tenant Management" +tab_profile = "Profile" +tab_schema = "User Schema" +title = "Tenant Details" + [ui.admin.tenants.sub] -add = "Add" +add = "Add Sub-tenant" +add_existing = "Add Existing Tenant" +add_dialog_title = "Add Sub-tenant" +add_dialog_desc = "Search existing tenants to add as sub-tenants." +search_placeholder = "Search name or slug..." +no_candidates = "No available tenants found." manage = "Manage" -title = "Sub-tenants ({{count}})" +title = "Sub-tenant Management ({{count}})" [ui.admin.tenants.sub.table] action = "ACTION" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 7119000d..a6304bc6 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -149,6 +149,8 @@ delete_success = "테넌트가 삭제되었습니다." empty = "아직 등록된 테넌트가 없습니다." fetch_error = "테넌트 목록 조회에 실패했습니다." missing_id = "테넌트 ID가 없습니다." +not_found = "테넌트를 찾을 수 없습니다." +remove_sub_confirm = '테넌트 "{{name}}"을(를) 하위 조직에서 제외할까요?' subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다." [msg.admin.tenants.admins] @@ -792,7 +794,7 @@ header_subtitle = "테넌트 정보를 수정하거나 연동 설정을 관리 loading = "테넌트 정보를 불러오는 중..." tab_admins = "관리자 설정" tab_federation = "외부 연동" -tab_organization = "조직 관리" +tab_organization = "하위 테넌트 관리" tab_profile = "프로필" tab_schema = "사용자 스키마" title = "테넌트 상세" @@ -866,8 +868,13 @@ type_text = "텍스트 (Text)" [ui.admin.tenants.sub] add = "하위 테넌트 추가" +add_existing = "기존 테넌트 추가" +add_dialog_title = "하위 테넌트 추가" +add_dialog_desc = "기존에 등록된 테넌트를 검색하여 하위 조직으로 추가합니다." +search_placeholder = "테넌트 이름 또는 슬러그 검색..." +no_candidates = "추가 가능한 테넌트가 없습니다." manage = "관리" -title = "Sub-tenants ({{count}})" +title = "하위 테넌트 관리 ({{count}})" [ui.admin.tenants.sub.table] action = "ACTION" diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts index 1ce4b243..fff2875e 100644 --- a/adminfront/tests/auth.spec.ts +++ b/adminfront/tests/auth.spec.ts @@ -1,79 +1,84 @@ -import { test, expect } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -test.describe('Authentication', () => { +test.describe("Authentication", () => { test.beforeEach(async ({ page }) => { // Mock OIDC configuration - await page.route('**/oidc/.well-known/openid-configuration', async route => { - await route.fulfill({ - json: { - issuer: "http://localhost:5000/oidc", - authorization_endpoint: "http://localhost:5000/oidc/auth", - token_endpoint: "http://localhost:5000/oidc/token", - jwks_uri: "http://localhost:5000/oidc/jwks", - response_types_supported: ["code"], - subject_types_supported: ["public"], - id_token_signing_alg_values_supported: ["RS256"] - } - }); - }); + await page.route( + "**/oidc/.well-known/openid-configuration", + async (route) => { + await route.fulfill({ + json: { + issuer: "http://localhost:5000/oidc", + authorization_endpoint: "http://localhost:5000/oidc/auth", + token_endpoint: "http://localhost:5000/oidc/token", + jwks_uri: "http://localhost:5000/oidc/jwks", + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + }, + }); + }, + ); }); - test('should redirect unauthorized users to login page', async ({ page }) => { - await page.goto('/'); + test("should redirect unauthorized users to login page", async ({ page }) => { + await page.goto("/"); // Should be redirected to /login await expect(page).toHaveURL(/\/login/); - await expect(page.locator('h1')).toContainText('Baron SSO'); + await expect(page.locator("h1")).toContainText("Baron SSO"); }); - test('should allow access to dashboard when authenticated', async ({ page }) => { + test("should allow access to dashboard when authenticated", async ({ + page, + }) => { await page.addInitScript(() => { const authority = "http://localhost:5000/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { - access_token: 'fake-token', - token_type: 'Bearer', - profile: { - sub: 'admin-user', - name: 'Admin User', - email: 'admin@example.com' + access_token: "fake-token", + token_type: "Bearer", + profile: { + sub: "admin-user", + name: "Admin User", + email: "admin@example.com", }, expires_at: Math.floor(Date.now() / 1000) + 3600, }; window.localStorage.setItem(key, JSON.stringify(authData)); }); - await page.goto('/'); - + await page.goto("/"); + // Wait for the auth loading to finish - await expect(page.locator('.animate-spin')).not.toBeVisible(); - + await expect(page.locator(".animate-spin")).not.toBeVisible(); + // Should be on the dashboard/overview - await expect(page.locator('aside')).toBeVisible(); - await expect(page.locator('h1')).toContainText('Admin Control'); + await expect(page.locator("aside")).toBeVisible(); + await expect(page.locator("h1")).toContainText("Admin Control"); }); - test('should logout and redirect to login page', async ({ page }) => { + test("should logout and redirect to login page", async ({ page }) => { // Start authenticated await page.addInitScript(() => { const authority = "http://localhost:5000/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { - access_token: 'fake-token', - token_type: 'Bearer', - profile: { sub: 'admin-user', name: 'Admin' }, + access_token: "fake-token", + token_type: "Bearer", + profile: { sub: "admin-user", name: "Admin" }, expires_at: Math.floor(Date.now() / 1000) + 3600, }; window.localStorage.setItem(key, JSON.stringify(authData)); }); - await page.goto('/'); - await expect(page.locator('aside')).toBeVisible(); + await page.goto("/"); + await expect(page.locator("aside")).toBeVisible(); // Mock window.confirm - page.on('dialog', dialog => dialog.accept()); - + page.on("dialog", (dialog) => dialog.accept()); + // Click logout button (label: ui.admin.nav.logout) await page.click('button:has-text("Logout"), button:has-text("로그아웃")'); diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 57ce51cb..da5b3862 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -1,6 +1,6 @@ -import { test, expect } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -test.describe('Tenants Management', () => { +test.describe("Tenants Management", () => { test.beforeEach(async ({ page }) => { // Authenticate await page.addInitScript(() => { @@ -8,68 +8,90 @@ test.describe('Tenants Management', () => { const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { - access_token: 'fake-token', - token_type: 'Bearer', - profile: { sub: 'admin-user', name: 'Admin User', email: 'admin@example.com' }, + access_token: "fake-token", + token_type: "Bearer", + profile: { + sub: "admin-user", + name: "Admin User", + email: "admin@example.com", + }, expires_at: Math.floor(Date.now() / 1000) + 3600, }; window.localStorage.setItem(key, JSON.stringify(authData)); }); // Mock OIDC config to avoid redirects - await page.route('**/oidc/.well-known/openid-configuration', async route => { - await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); - }); + await page.route( + "**/oidc/.well-known/openid-configuration", + async (route) => { + await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + }, + ); }); - test('should list tenants', async ({ page }) => { - await page.route('**/api/v1/admin/tenants*', async route => { + test("should list tenants", async ({ page }) => { + await page.route("**/api/v1/admin/tenants*", async (route) => { await route.fulfill({ json: { items: [ - { id: '1', name: 'Tenant A', slug: 'tenant-a', status: 'active', type: 'COMPANY', updatedAt: new Date().toISOString() }, + { + id: "1", + name: "Tenant A", + slug: "tenant-a", + status: "active", + type: "COMPANY", + updatedAt: new Date().toISOString(), + }, ], total: 1, limit: 1000, - offset: 0 - } + offset: 0, + }, }); }); - await page.goto('/tenants'); - await expect(page.locator('h2')).toContainText('테넌트 목록'); - await expect(page.locator('table')).toContainText('Tenant A'); + await page.goto("/tenants"); + await expect(page.locator("h2")).toContainText("테넌트 목록"); + await expect(page.locator("table")).toContainText("Tenant A"); }); - test('should create a new tenant', async ({ page }) => { + test("should create a new tenant", async ({ page }) => { // Mock GET for list (empty) and for parents - await page.route('**/api/v1/admin/tenants*', async route => { - if (route.request().method() === 'GET') { - await route.fulfill({ json: { items: [], total: 0, limit: 100, offset: 0 } }); - } else if (route.request().method() === 'POST') { - await route.fulfill({ - json: { id: '2', name: 'New Tenant', slug: 'new-tenant', status: 'active', type: 'COMPANY' } + await page.route("**/api/v1/admin/tenants*", async (route) => { + if (route.request().method() === "GET") { + await route.fulfill({ + json: { items: [], total: 0, limit: 100, offset: 0 }, + }); + } else if (route.request().method() === "POST") { + await route.fulfill({ + json: { + id: "2", + name: "New Tenant", + slug: "new-tenant", + status: "active", + type: "COMPANY", + }, }); } }); - await page.goto('/tenants/new'); - - await page.fill('input >> nth=0', 'New Tenant'); - await page.fill('input >> nth=1', 'new-tenant'); - await page.fill('textarea', 'Description'); - + await page.goto("/tenants/new"); + + await page.fill("input >> nth=0", "New Tenant"); + await page.fill("input >> nth=1", "new-tenant"); + await page.fill("textarea", "Description"); + await page.click('button:has-text("생성")'); await expect(page).toHaveURL(/\/tenants$/); }); - test('should show validation error on empty name', async ({ page }) => { - await page.goto('/tenants/new'); + test("should show validation error on empty name", async ({ page }) => { + await page.goto("/tenants/new"); const submitBtn = page.locator('button:has-text("생성")'); await expect(submitBtn).toBeDisabled(); - - await page.fill('input >> nth=0', 'Valid Name'); + + await page.fill("input >> nth=0", "Valid Name"); await expect(submitBtn).not.toBeDisabled(); }); }); diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 06c75760..fdf8d032 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -277,7 +277,7 @@ func main() { authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) adminHandler := handler.NewAdminHandler(ketoService) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler) - tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, ketoOutboxRepo, kratosAdminService) + tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService) userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo) diff --git a/backend/internal/bootstrap/tenant_seed.go b/backend/internal/bootstrap/tenant_seed.go index b7abc258..e4bad4e8 100644 --- a/backend/internal/bootstrap/tenant_seed.go +++ b/backend/internal/bootstrap/tenant_seed.go @@ -59,7 +59,7 @@ func SeedTenants(db *gorm.DB) error { } slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug) - tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, config.Description, config.Domains, nil) + tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil) if err != nil { slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) return err diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index 0e9824d4..a5b9b794 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -19,23 +19,23 @@ const ( type User struct { ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` Email string `gorm:"uniqueIndex;not null" json:"email"` - PasswordHash string `gorm:"not null" json:"-"` - Name string `gorm:"not null" json:"name"` - Phone string `json:"phone"` - Role string `gorm:"default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user - AffiliationType string `json:"affiliationType"` - CompanyCode string `json:"companyCode"` - TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"` + PasswordHash *string `gorm:"column:password_hash" json:"-"` + Name string `gorm:"column:name;not null" json:"name"` + Phone string `gorm:"column:phone" json:"phone"` + Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user + AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"` + CompanyCode string `gorm:"column:company_code;index" json:"companyCode"` + TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"` Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` - RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 - Department string `json:"department"` - Position string `json:"position"` // 직급 (예: 수석, 책임, 선임) - JobTitle string `json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획) - Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"` - Status string `gorm:"default:'active'" json:"status"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 + Department string `gorm:"column:department" json:"department"` + Position string `gorm:"column:position" json:"position"` // 직급 (예: 수석, 책임, 선임) + JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획) + Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"` + Status string `gorm:"column:status;default:'active'" json:"status"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"` } // BeforeCreate hook to generate UUID if not present diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index 6a5d043e..3a7ed64c 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -102,6 +102,15 @@ func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search return nil, 0, nil } +func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + return 0, nil +} + +func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + return nil, nil +} + + type AsyncMockRedisRepo struct { mock.Mock } @@ -128,7 +137,7 @@ type AsyncMockTenantService struct { mock.Mock } -func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) { +func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { return nil, nil } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 737e0105..c758a021 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -6,6 +6,7 @@ import ( "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "errors" + "log/slog" "strings" "time" @@ -16,15 +17,17 @@ import ( type TenantHandler struct { DB *gorm.DB Service service.TenantService + UserRepo repository.UserRepository Keto service.KetoService KetoOutbox repository.KetoOutboxRepository KratosAdmin service.KratosAdminService } -func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler { +func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler { return &TenantHandler{ DB: db, Service: svc, + UserRepo: userRepo, Keto: keto, KetoOutbox: outbox, KratosAdmin: kratos, @@ -33,12 +36,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoS type tenantSummary struct { ID string `json:"id"` + Type string `json:"type"` + ParentID *string `json:"parentId"` Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` Status string `json:"status"` Domains []string `json:"domains,omitempty"` Config domain.JSONMap `json:"config,omitempty"` + MemberCount int64 `json:"memberCount"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } @@ -98,6 +104,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) + parentId := c.Query("parentId") + if limit <= 0 { limit = 50 } @@ -105,19 +113,45 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { offset = 0 } + // Use separate queries for count and find to avoid GORM statement contamination + countQuery := h.DB.Model(&domain.Tenant{}) + if parentId != "" { + countQuery = countQuery.Where("parent_id = ?", parentId) + } + var total int64 - if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil { + if err := countQuery.Count(&total).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + findQuery := h.DB.Model(&domain.Tenant{}) + if parentId != "" { + findQuery = findQuery.Where("parent_id = ?", parentId) + } + var tenants []domain.Tenant - if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { + if err := findQuery.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + // Fetch member counts for all tenants in one query using slugs (company codes) + slugs := make([]string, 0, len(tenants)) + for _, t := range tenants { + slugs = append(slugs, t.Slug) + } + memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), slugs) + if err != nil { + slog.Warn("failed to count members for tenants", "error", err) + memberCounts = make(map[string]int64) + } + items := make([]tenantSummary, 0, len(tenants)) for _, t := range tenants { - items = append(items, mapTenantSummary(t)) + summary := mapTenantSummary(t) + // Ensure robust matching by trimming and lowercasing the slug key + key := strings.ToLower(strings.TrimSpace(t.Slug)) + summary.MemberCount = memberCounts[key] + items = append(items, summary) } return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) @@ -141,7 +175,15 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - return c.JSON(mapTenantSummary(tenant)) + memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug}) + count := int64(0) + if err == nil { + count = memberCounts[strings.ToLower(tenant.Slug)] + } + summary := mapTenantSummary(tenant) + summary.MemberCount = count + + return c.JSON(summary) } func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { @@ -152,6 +194,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { var req struct { Name string `json:"name"` Slug string `json:"slug"` + Type string `json:"type"` Description string `json:"description"` Status string `json:"status"` Domains []string `json:"domains"` @@ -167,6 +210,11 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) } + tenantType := normalizeTenantType(req.Type) + if tenantType == "" { + tenantType = domain.TenantTypeCompany // Default to COMPANY + } + slug := req.Slug if slug == "" { slug = utils.GenerateUniqueSlug(name, func(s string) bool { @@ -193,7 +241,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { parentID = &pid } - tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains, parentID) + tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID) if err != nil { if strings.Contains(err.Error(), "already exists") { return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) @@ -201,12 +249,16 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + summary := mapTenantSummary(*tenant) + summary.MemberCount = 0 + if req.Config != nil { tenant.Config = req.Config h.DB.Save(tenant) + summary.Config = tenant.Config } - return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant)) + return c.Status(fiber.StatusCreated).JSON(summary) } func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { @@ -229,9 +281,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { var req struct { Name *string `json:"name"` + Type *string `json:"type"` Slug *string `json:"slug"` Description *string `json:"description"` Status *string `json:"status"` + ParentID *string `json:"parentId"` Domains []string `json:"domains"` Config map[string]any `json:"config"` } @@ -246,6 +300,13 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { } tenant.Name = name } + if req.Type != nil { + tenantType := normalizeTenantType(*req.Type) + if tenantType == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant type"}) + } + tenant.Type = tenantType + } if req.Slug != nil { slug := utils.GenerateSlug(*req.Slug) if slug == "" { @@ -271,6 +332,30 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { } tenant.Status = status } + if req.ParentID != nil { + pid := strings.TrimSpace(*req.ParentID) + if pid == "" { + tenant.ParentID = nil + } else { + tenant.ParentID = &pid + } + + // [Keto] Sync hierarchy via Outbox + if h.KetoOutbox != nil { + if tenant.ParentID != nil { + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "parents", + Subject: "Tenant:" + *tenant.ParentID, + Action: domain.KetoOutboxActionCreate, + }) + } else { + // We don't have enough info here to delete specific parent if we don't know the old one, + // but for now we focus on adding. + } + } + } if req.Config != nil { tenant.Config = req.Config } @@ -432,6 +517,8 @@ func mapTenantSummary(t domain.Tenant) tenantSummary { return tenantSummary{ ID: t.ID, + Type: t.Type, + ParentID: t.ParentID, Name: t.Name, Slug: t.Slug, Description: t.Description, @@ -453,3 +540,13 @@ func normalizeTenantStatus(value string) string { } return value } + +func normalizeTenantType(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + switch value { + case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeUserGroup: + return value + default: + return "" + } +} diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index c9698159..b15b4a65 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -21,8 +21,8 @@ type MockTenantService struct { mock.Mock } -func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) { - args := m.Called(ctx, name, slug, description, domains, parentID) +func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { + args := m.Called(ctx, name, slug, tenantType, description, domains, parentID) if args.Get(0) == nil { return nil, args.Error(1) } @@ -85,7 +85,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) { } body, _ := json.Marshal(input) - mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", "", []string{"test.com"}, (*string)(nil)). + mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil)). Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil) req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body)) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 0602f843..6b2d8be5 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -68,6 +68,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) search := strings.TrimSpace(c.Query("search")) + companyCode := strings.TrimSpace(c.Query("companyCode")) if limit <= 0 { limit = 50 @@ -89,14 +90,21 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { // Tenant Admin filtering if requesterRole == domain.RoleTenantAdmin { - if requesterCompany == "" || compCode != requesterCompany { + if requesterCompany == "" || !strings.EqualFold(compCode, requesterCompany) { continue } } - // Search filtering + // Dedicated companyCode filter + if companyCode != "" && !strings.EqualFold(compCode, companyCode) { + continue + } + + // Search filtering (Keyword search in email, name, or companyCode) if search != "" { - if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) { + if !strings.Contains(email, searchLower) && + !strings.Contains(name, searchLower) && + !strings.Contains(strings.ToLower(compCode), searchLower) { continue } } @@ -118,14 +126,27 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { items = append(items, summary) } + // [Lazy Sync] Asynchronously update local DB with fresh data from Kratos + // This ensures that member counts (which use local DB) eventually match reality + if h.UserRepo != nil { + go func(ids []service.KratosIdentity) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for _, identity := range ids { + localUser := h.mapToLocalUser(identity) + _ = h.UserRepo.Update(ctx, localUser) + } + }(filtered) + } + return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) } - // 2. Fallback to Local DB if Kratos is down (Development only recommended) + // 2. Fallback to Local DB if Kratos is down slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err) // Fetch from UserRepo - users, total, err := h.UserRepo.List(c.Context(), offset, limit, search) + users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"}) } @@ -289,66 +310,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - // [New] Local DB Sync - localUser := &domain.User{ - ID: identityID, - Email: email, - Name: name, - Phone: normalizePhoneNumber(req.Phone), - AffiliationType: "internal", - CompanyCode: req.CompanyCode, - Department: req.Department, - Role: role, - Status: "active", - Metadata: req.Metadata, - } - if tenantID != "" { - localUser.TenantID = &tenantID - } - - // [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다. - if h.UserRepo != nil { - go func(u *domain.User) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := h.UserRepo.Create(ctx, u); err != nil { - slog.Error("[UserHandler] Failed to sync user to local DB", "email", u.Email, "error", err) - } - }(localUser) - } - - // [Keto] Sync relations via Outbox - if h.KetoOutboxRepo != nil { - // 1. Tenant Membership - if localUser.TenantID != nil { - _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "Tenant", - Object: *localUser.TenantID, - Relation: "members", - Subject: "User:" + identityID, - Action: domain.KetoOutboxActionCreate, - }) - } - // 2. Role Specifics - if role == domain.RoleSuperAdmin { - _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "System", - Object: "global", - Relation: "super_admins", - Subject: "User:" + identityID, - Action: domain.KetoOutboxActionCreate, - }) - } else if role == domain.RoleTenantAdmin && localUser.TenantID != nil { - _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "Tenant", - Object: *localUser.TenantID, - Relation: "admins", - Subject: "User:" + identityID, - Action: domain.KetoOutboxActionCreate, - }) - } - } - + // Fetch the newly created identity to ensure we have all traits identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) @@ -357,6 +319,28 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword}) } + // [New] Local DB Sync - Ensure user exists in read-model + if h.UserRepo != nil { + localUser := h.mapToLocalUser(*identity) + + // Sync to local DB + go func(u *domain.User, role string, tID *string) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Use Update (upsert) instead of Create for robustness + if err := h.UserRepo.Update(ctx, u); err != nil { + slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err) + return + } + + // [Keto] Sync relations via Outbox + if h.KetoOutboxRepo != nil { + h.syncKetoRole(ctx, u.ID, role, "", "", tID) + } + }(localUser, role, localUser.TenantID) + } + response := h.mapIdentitySummary(c.Context(), *identity) if generatedPassword != "" { response.InitialPassword = generatedPassword @@ -382,6 +366,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) } + // Capture current local state for transition comparison + var oldRole string + var oldTenantID string + if h.UserRepo != nil { + if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil { + oldRole = local.Role + if local.TenantID != nil { + oldTenantID = *local.TenantID + } + } + } + // [New] Check access scope requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if requester != nil && requester.Role == domain.RoleTenantAdmin { @@ -420,7 +416,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { traits["name"] = strings.TrimSpace(*req.Name) } if req.Phone != nil { - traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone)) + phone := normalizePhoneNumber(strings.TrimSpace(*req.Phone)) + if phone == "" { + delete(traits, "phone_number") + } else { + traits["phone_number"] = phone + } } if req.CompanyCode != nil { code := strings.TrimSpace(*req.CompanyCode) @@ -471,92 +472,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - // [New] Local DB Sync + // [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller if h.UserRepo != nil { - if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil { - oldRole := localUser.Role - oldTenantID := "" - if localUser.TenantID != nil { - oldTenantID = *localUser.TenantID - } - - if req.Name != nil { - localUser.Name = *req.Name - } - if req.Phone != nil { - localUser.Phone = normalizePhoneNumber(*req.Phone) - } - if req.CompanyCode != nil { - localUser.CompanyCode = *req.CompanyCode - if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil { - localUser.TenantID = &tenant.ID - } - } - if req.Department != nil { - localUser.Department = *req.Department - } - if req.Role != nil { - localUser.Role = *req.Role - } - if req.Status != nil { - localUser.Status = *req.Status - } - if req.Metadata != nil { - localUser.Metadata = req.Metadata - } - - // [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다. - // [ReBAC Policy] 로컬 DB와 Keto 간의 정합성을 위해 Outbox를 함께 기록합니다. - go func(u *domain.User, rRole *string, oRole string, oTenantID string) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if err := h.UserRepo.Update(ctx, u); err == nil { - // [Keto Sync on Role Change] via Outbox - if h.KetoOutboxRepo != nil && rRole != nil && *rRole != oRole { - uID := u.ID - newR := *rRole - if oRole == domain.RoleSuperAdmin { - _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "System", - Object: "global", - Relation: "super_admins", - Subject: "User:" + uID, - Action: domain.KetoOutboxActionDelete, - }) - } else if oRole == domain.RoleTenantAdmin && oTenantID != "" { - _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: oTenantID, - Relation: "admins", - Subject: "User:" + uID, - Action: domain.KetoOutboxActionDelete, - }) - } - - if newR == domain.RoleSuperAdmin { - _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "System", - Object: "global", - Relation: "super_admins", - Subject: "User:" + uID, - Action: domain.KetoOutboxActionCreate, - }) - } else if newR == domain.RoleTenantAdmin && u.TenantID != nil { - _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: *u.TenantID, - Relation: "admins", - Subject: "User:" + uID, - Action: domain.KetoOutboxActionCreate, - }) - } - } - } else { - slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", u.ID, "error", err) - } - }(localUser, req.Role, oldRole, oldTenantID) + updatedLocalUser := h.mapToLocalUser(*updated) + + ctx := context.Background() // Use request context if appropriate, but sync must finish + if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil { + slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err) } + + // [Keto Sync] asynchronously as it's less critical for immediate UI count + go h.syncKetoRole(context.Background(), updatedLocalUser.ID, + extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID) } if req.Password != nil && *req.Password != "" { @@ -654,6 +581,97 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K return summary } +func (h *UserHandler) normalizePhoneNumber(phone string) string { + return normalizePhoneNumber(phone) +} + +func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User { + traits := identity.Traits + role := extractTraitString(traits, "grade") + if role == "" { + role = "user" + } + compCode := extractTraitString(traits, "companyCode") + + user := &domain.User{ + ID: identity.ID, + Email: extractTraitString(traits, "email"), + Name: extractTraitString(traits, "name"), + Phone: extractTraitString(traits, "phone_number"), + Role: role, + Status: normalizeStatus(identity.State), + CompanyCode: compCode, + Department: extractTraitString(traits, "department"), + AffiliationType: extractTraitString(traits, "affiliationType"), + CreatedAt: identity.CreatedAt, + UpdatedAt: identity.UpdatedAt, + } + + if compCode != "" && h.TenantService != nil { + // Use a background context or a timeout-limited context for tenant lookup + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil { + user.TenantID = &tenant.ID + } + } + + // Metadata + user.Metadata = make(domain.JSONMap) + coreTraits := map[string]bool{ + "email": true, "name": true, "phone_number": true, + "grade": true, "companyCode": true, "department": true, + "affiliationType": true, "role": true, "tenant_id": true, + } + for k, v := range traits { + if !coreTraits[k] { + user.Metadata[k] = v + } + } + + return user +} + +func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) { + // Remove old roles + if oldRole == domain.RoleSuperAdmin { + _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "System", + Object: "global", + Relation: "super_admins", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionDelete, + }) + } else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" { + _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: oldTenantID, + Relation: "admins", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionDelete, + }) + } + + // Add new roles + if newRole == domain.RoleSuperAdmin { + _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "System", + Object: "global", + Relation: "super_admins", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) + } else if newRole == domain.RoleTenantAdmin && newTenantID != nil { + _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: *newTenantID, + Relation: "admins", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) + } +} + func extractTraitString(traits map[string]interface{}, key string) string { if traits == nil { return "" diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 4da5804f..543a1697 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -3,6 +3,7 @@ package repository import ( "baron-sso-backend/internal/domain" "context" + "strings" "gorm.io/gorm" ) @@ -14,7 +15,10 @@ type UserRepository interface { FindByID(ctx context.Context, id string) (*domain.User, error) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) - List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) + List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) + CountByTenant(ctx context.Context, tenantID string) (int64, error) + CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) + CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) Delete(ctx context.Context, id string) error } @@ -69,14 +73,111 @@ func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]d return users, nil } -func (r *userRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (r *userRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&domain.User{}).Where("tenant_id = ?", tenantID).Count(&count).Error + return count, err +} + +func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + type result struct { + TenantID string + Count int64 + } + var results []result + if len(tenantIDs) == 0 { + return make(map[string]int64), nil + } + if err := r.db.WithContext(ctx).Model(&domain.User{}). + Select("tenant_id, count(*) as count"). + Where("tenant_id IN ?", tenantIDs). + Group("tenant_id"). + Find(&results).Error; err != nil { + return nil, err + } + + counts := make(map[string]int64) + for _, res := range results { + if res.TenantID != "" { + counts[res.TenantID] = res.Count + } + } + // Ensure all requested tenant IDs are in the map, even if count is 0 + for _, id := range tenantIDs { + if _, ok := counts[id]; !ok { + counts[id] = 0 + } + } + return counts, nil +} + +func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + if len(codes) == 0 { + return make(map[string]int64), nil + } + + // 1. Resolve IDs for these codes to support dual counting (slug or ID) + var tenants []domain.Tenant + _ = r.db.WithContext(ctx).Where("slug IN ?", codes).Find(&tenants).Error + + idToSlug := make(map[string]string) + slugToNormalized := make(map[string]string) + + for _, code := range codes { + slugToNormalized[strings.ToLower(strings.TrimSpace(code))] = code + } + for _, t := range tenants { + idToSlug[t.ID] = t.Slug + } + + type result struct { + CompanyCode string + TenantID string + Count int64 + } + var results []result + + // Use a more comprehensive aggregation + err := r.db.WithContext(ctx).Model(&domain.User{}). + Select("company_code, tenant_id, count(*) as count"). + Where("company_code IN ? OR tenant_id IN (SELECT id FROM tenants WHERE slug IN ?)", codes, codes). + Group("company_code, tenant_id"). + Scan(&results).Error + + if err != nil { + return nil, err + } + + counts := make(map[string]int64) + for _, res := range results { + var slug string + if res.CompanyCode != "" { + slug = res.CompanyCode + } else if res.TenantID != "" { + slug = idToSlug[res.TenantID] + } + + if slug != "" { + normalizedSlug := strings.ToLower(strings.TrimSpace(slug)) + counts[normalizedSlug] += res.Count + } + } + + return counts, nil +} + +func (r *userRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { var users []domain.User var total int64 db := r.db.WithContext(ctx).Model(&domain.User{}) + if companyCode != "" { + db = db.Where("company_code = ?", companyCode) + } + if search != "" { searchTerm := "%" + search + "%" - db = db.Where("email LIKE ? OR name LIKE ?", searchTerm, searchTerm) + db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ?)", searchTerm, searchTerm, searchTerm) } if err := db.Count(&total).Error; err != nil { diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index c52e4287..2f358ec5 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -13,7 +13,7 @@ import ( ) type TenantService interface { - RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) + RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) @@ -89,7 +89,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string return s.repo.FindByIDs(ctx, allIDs) } -func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) { +func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { // Validate Slug if ok, msg := utils.ValidateSlug(slug); !ok { return nil, errors.New(msg) @@ -106,7 +106,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript // 2. Create Tenant tenant := &domain.Tenant{ - Type: domain.TenantTypeCompany, // Default to COMPANY for manual registration + Type: tenantType, Name: name, Slug: slug, Description: description, diff --git a/backend/internal/service/tenant_service_edge_test.go b/backend/internal/service/tenant_service_edge_test.go index e48980d1..f446b11a 100644 --- a/backend/internal/service/tenant_service_edge_test.go +++ b/backend/internal/service/tenant_service_edge_test.go @@ -21,7 +21,7 @@ func TestTenantService_RegisterTenant_DuplicateSlug(t *testing.T) { // Mock: slug already exists mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil) - tenant, err := svc.RegisterTenant(ctx, "New Name", slug, "", nil, nil) + tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") assert.Nil(t, tenant) @@ -32,11 +32,11 @@ func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) { ctx := context.Background() // Case 1: Too short - _, err := svc.RegisterTenant(ctx, "Name", "a", "", nil, nil) + _, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil) assert.Error(t, err) // Case 2: Invalid characters - _, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", "", nil, nil) + _, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil) assert.Error(t, err) } diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 8698cb87..2216ffdb 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -120,6 +120,21 @@ func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, sea return nil, 0, nil } +func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + args := m.Called(tenantID) + return int64(args.Int(0)), args.Error(1) +} + +func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + args := m.Called(tenantIDs) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]int64), args.Error(1) +} + + + func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { mockRepo := new(MockTenantRepoForSvc) mockOutbox := new(MockKetoOutboxRepositoryShared) @@ -136,7 +151,7 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil) mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once() - tenant, err := svc.RegisterTenant(ctx, name, slug, "", domains, nil) + tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil) assert.NoError(t, err) assert.NotNil(t, tenant) assert.Equal(t, "t1", tenant.ID) diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 85385ee5..b740909e 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -81,6 +81,20 @@ func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search return nil, 0, nil } +func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + args := m.Called(tenantID) + return int64(args.Int(0)), args.Error(1) +} + +func (m *MockUserRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + args := m.Called(tenantIDs) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]int64), args.Error(1) +} + + type MockTenantRepository struct { mock.Mock } diff --git a/docs/UI_DESIGN_POLICY.md b/docs/UI_DESIGN_POLICY.md new file mode 100644 index 00000000..3b0d3f31 --- /dev/null +++ b/docs/UI_DESIGN_POLICY.md @@ -0,0 +1,118 @@ +# UI 버튼 위치 및 정렬 정책 (UI Button Placement Policy) + +본 문서는 Baron SSO 프로젝트 내 모든 프론트엔드 애플리케이션(`userfront`, `devfront`, `adminfront`)에서 일관된 사용자 경험(UX)을 제공하기 위한 UI 버튼 배치 및 정렬 가이드라인을 정의합니다. (관련 이슈: [#308](https://gitea.hmac.kr/baron/baron-sso/issues/308)) + +## 1. 버튼 종류별 위치 (Button Placement by Type) + +버튼의 성격에 따라 다음과 같이 배치합니다. + +* **Primary Action (주요 동작)** + * **예시**: 저장, 확인, 제출, 생성 등 + * **위치**: 우측 하단 (Bottom Right) 또는 모달/다이얼로그의 우측 끝에 배치합니다. 사용자의 시선 흐름(좌에서 우, 위에서 아래)에 따라 최종 액션을 우측 하단에서 마무리하도록 유도합니다. +* **Secondary Action (보조 동작)** + * **예시**: 취소, 닫기, 이전으로 등 + * **위치**: Primary 버튼의 바로 **좌측**에 배치합니다. +* **Destructive Action (파괴적 동작)** + * **예시**: 삭제, 초기화, 권한 해제 등 + * **위치 및 스타일**: 붉은색(Red/Destructive) 스타일을 적용하여 시각적으로 명확히 구분합니다. Primary/Secondary 그룹과 물리적으로 분리하거나 (예: 좌측 끝 배치), Secondary 액션 위치에 두되 색상으로 강력한 경고를 줍니다. + +## 2. 정렬 기준 (Alignment Rules) + +* **폼(Form) 하단 버튼 그룹** + * **기본 정렬**: 우측 정렬 (Right-aligned). "취소"는 왼쪽, "저장"은 오른쪽에 위치합니다. `[ 취소 ] [ 저장 ]` +* **리스트 아이템 내부 액션 버튼** + * **기본 정렬**: 리스트/테이블의 각 행(Row) 우측 끝에 배치합니다. + * 버튼 개수가 많을 경우 (3개 이상), 툴팁이나 Dropdown 메뉴(예: 햄버거 버튼 또는 "더보기" 아이콘)로 숨겨 UI 복잡도를 낮춥니다. + +## 3. 반응형 고려 (Responsive Design) + +* **모바일 환경 (Mobile / Small Screens)** + * 화면 너비가 좁은 모바일 기기(예: `userfront` 앱 환경, `devfront`/`adminfront`의 모바일 뷰)에서는 버튼 그룹을 **Full Width (화면 가득 채움)**로 변경하여 터치 영역을 확보합니다. + * 여러 개의 버튼이 있는 경우 세로로 스택(Stack)하며, **Primary Action을 맨 위**에, Secondary Action을 그 아래에 배치합니다. + * *데스크탑*: `[ 취소 ] [ 확인 ]` + * *모바일*: + ``` + [ 확인 ] + [ 취소 ] + ``` + +## 4. 로딩 및 피드백 (Loading & Feedback) + +* **중복 제출 방지**: 폼 전송이나 API 호출을 발생시키는 버튼을 클릭하면 즉각적으로 버튼을 비활성화(Disabled) 상태로 변경하여 다중 클릭을 방지합니다. +* **로딩 스피너**: 버튼 내부에 로딩 스피너(Spinner)를 표시하여 사용자에게 진행 상황을 시각적으로 알립니다. +* **스켈레톤 로딩(Skeleton Loading)**: 화면 진입 시 전체 데이터를 로딩해야 하는 경우, 무의미한 빈 화면(빈 공간) 대신 스켈레톤 UI를 사용하여 로딩 중임을 직관적으로 알리고 체감 대기 시간을 줄입니다. +* **작업 결과 안내**: 성공, 실패 등의 결과는 Toast 메시지 (혹은 스낵바)를 통해 화면 하단/상단에 일시적으로 노출하여 사용자가 흐름을 끊지 않고도 인지할 수 있게 돕습니다. + +## 5. 빈 상태 처리 (Empty State) + +* **빈 목록 안내**: 테이블이나 리스트에 표시할 항목이 없는 경우 단순히 빈 화면으로 두지 않고 중앙 정렬된 아이콘이나 일러스트와 함께 "데이터가 없습니다." 등의 명확한 문구를 표시합니다. +* **콜 투 액션(Call to Action)**: 데이터가 비어 있는 경우 생성 버튼(Primary Action)을 빈 상태 안내 영역 아래에 배치하여 사용자가 즉시 데이터를 추가할 수 있도록 유도합니다. + +## 6. 오류 표시 (Error Handling) + +* **인라인(Inline) 오류**: 폼(Form)의 유효성 검사에서 실패한 경우, 각 입력 필드 바로 아래에 붉은색 텍스트로 실패 원인을 명확하게 표시합니다. +* **포커스 이동**: 제출 버튼 클릭 시 오류가 있는 첫 번째 입력 필드로 자동 스크롤 하거나 포커스(Focus)를 이동시켜 수정이 용이하게 합니다. + +## 7. 접근성 (Accessibility - a11y) + +* **포커스 링(Focus Ring)**: 키보드를 통해 탐색(Tab)하는 사용자를 위해 버튼, 텍스트 입력창 등에 포커스가 갈 경우 외곽선을 명확히 렌더링(예: 파란색 테두리 등)해야 합니다. `outline: none`을 무분별하게 사용하지 않습니다. +* **대체 텍스트**: 텍스트 없이 아이콘만 존재하는 버튼(예: X 형태의 닫기 버튼)의 경우 반드시 `aria-label` 속성(또는 Flutter의 `Semantics`)을 사용하여 스크린 리더 사용자가 해당 버튼의 역할을 알 수 있게 해야 합니다. + +## 8. 프론트엔드 환경별 구현 가이드 (Implementation Guide) + +현재 운영 중인 프론트엔드 환경에 맞춘 구현 가이드라인입니다. + +### 8.1. React 환경 (`devfront`, `adminfront`) +Tailwind CSS 기반의 컴포넌트를 사용하여 아래와 같이 구현합니다. + +* **버튼 그룹 우측 정렬 (데스크탑)**: `flex justify-end gap-2` +* **반응형 (모바일 세로 배치, 데스크탑 가로 배치)**: `flex flex-col-reverse sm:flex-row sm:justify-end gap-2` + *(참고: `flex-col-reverse`를 사용하면 코드 상 먼저 작성된 취소 버튼이 모바일에서는 아래로, 나중에 작성된 확인 버튼이 위로 올라가게 배치할 수 있습니다.)* +* **코드 예시**: + ```tsx +
+ + +
+ ``` + +### 8.2. Flutter 환경 (`userfront`) +Flutter 프레임워크를 사용하는 환경에서는 화면 너비에 따라 위젯 구성을 동적으로 처리해야 합니다. + +* **폼 하단 정렬**: `Row` 위젯과 `MainAxisAlignment.end` 사용. +* **반응형 대응**: 화면 너비(MediaQuery)에 따라 `Row`를 전체 너비를 채우는 `Column`으로 스위칭하거나, `OverflowBar` 위젯 등을 활용할 수 있습니다. +* **코드 예시**: + ```dart + // 데스크탑/태블릿용 (우측 정렬) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')), + const SizedBox(width: 8), + ElevatedButton( + onPressed: isLoading ? null : onSave, + child: isLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('확인') + ), + ], + ) + + // 모바일용 (전체 너비 세로 배치) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + onPressed: isLoading ? null : onSave, + child: isLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('확인') + ), + const SizedBox(height: 8), + TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')), + ], + ) + ``` \ No newline at end of file diff --git a/docs/keto-rebac-namespaces-diagram.md b/docs/keto-rebac-namespaces-diagram.md new file mode 100644 index 00000000..1c6247dc --- /dev/null +++ b/docs/keto-rebac-namespaces-diagram.md @@ -0,0 +1,87 @@ +# Ory Keto (ReBAC) 네임스페이스 및 권한 상속 다이어그램 + +이 문서는 `docker/ory/keto/namespaces.ts`에 정의된 Baron SSO 프로젝트의 Ory Keto(ReBAC) 네임스페이스와 각 네임스페이스 간의 권한 상속(Permits) 및 관계(Relations)를 나타내는 Mermaid 다이어그램입니다. + +## 네임스페이스 설계 구조 + +Ory Keto는 다음과 같은 4개의 주요 네임스페이스로 구성되어 있습니다: + +1. **`User`**: 권한의 주체가 되는 기본 사용자. +2. **`System`**: 시스템 전역 권한 (최고 관리자 및 인증된 사용자). +3. **`Tenant`**: 조직/회사/부서 등 모든 형태의 격리 공간. 상위-하위(`parents`) 계층 구조를 가짐. +4. **`RelyingParty`**: OIDC 클라이언트(앱/리소스). 특정 `Tenant`에 종속될 수 있음. + +--- + +## Mermaid 다이어그램 + +```mermaid +classDiagram + class User { + <> + } + + class System { + <> + -- Relations -- + super_admins: User[] + authenticated_users: User[] + -- Permits -- + manage_all: super_admins + } + + class Tenant { + <> + -- Relations -- + owners: User[] + admins: User[] | SubjectSet~Tenant, owners~ + members: User[] + parents: Tenant[] + -- Permits -- + view: members OR admins OR parents.view + manage: admins OR parents.manage + create_subtenant: manage + } + + class RelyingParty { + <> + -- Relations -- + admins: User[] + parents: Tenant[] + access: User[] | SubjectSet~Tenant, members~ | SubjectSet~System, authenticated_users~ + -- Permits -- + view: admins OR parents.view + manage: admins OR parents.manage + access: access OR manage + } + + %% Relationship lines indicating references (SubjectSets or Direct inclusion) + User ..> System : super_admins, authenticated_users + User ..> Tenant : owners, admins, members + User ..> RelyingParty : admins, access + + Tenant "1" --> "*" Tenant : parents (상위 조직 상속) + Tenant ..> RelyingParty : parents (소유권 상속) + Tenant ..> RelyingParty : access (members 접근 권한) + + System ..> RelyingParty : access (authenticated_users) + + %% Styling + style User fill:#e1f5fe,stroke:#333,stroke-width:2px + style System fill:#ffe0b2,stroke:#333,stroke-width:2px + style Tenant fill:#fff9c4,stroke:#333,stroke-width:2px + style RelyingParty fill:#e1bee7,stroke:#333,stroke-width:2px +``` + +### 권한 평가(Permit) 상세 로직 설명 + +- **Tenant (테넌트/조직):** + - `view` (조회): 테넌트의 일반 멤버(`members`), 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다. + - `manage` (관리): 테넌트의 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다. + - _참고:_ 조직장(`owners`)은 자동으로 `admins` 집합(SubjectSet)에 포함됩니다. + +- **RelyingParty (OIDC 앱):** + - `view` (조회): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다. + - `manage` (관리): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다. + - `access` (접근/로그인 가능 여부): 이 앱에 직접 접근 권한을 부여받은 유저/그룹(`access`), 또는 앱을 관리할 수 있는 권한(`manage`)을 가진 사람이 접근할 수 있습니다. + - _접근 대상(access)은 특정 유저, 특정 테넌트의 전 멤버, 또는 전역 인증된 유저(System:authenticated_users)가 될 수 있습니다._ diff --git a/locales/en.toml b/locales/en.toml index 68f376b5..6c980370 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -178,7 +178,9 @@ subtitle = "Subtitle" subtitle = "Subtitle" [msg.admin.tenants.members] -empty = "Empty" +empty = "No members found." +desc = "View the list of users belonging to this organization." +limit_notice = "Showing members from the first 10 descendant organizations due to size limits." [msg.admin.tenants.registry] count = "Count" @@ -836,6 +838,11 @@ select_placeholder = "Select Placeholder" [ui.admin.tenants.members] title = "Tenant Members ({{count}})" +direct_label = "Direct" +total_label = "Total" +list_title = "Member Management" +direct = "Direct Members" +descendants = "Descendant Members" [ui.admin.tenants.members.table] email = "EMAIL" diff --git a/locales/ko.toml b/locales/ko.toml index fb3b4804..e42e83ef 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -179,6 +179,8 @@ subtitle = "필수 정보만 입력해도 생성 가능합니다. Slug는 없으 [msg.admin.tenants.members] empty = "소속된 사용자가 없습니다." +desc = "조직에 소속된 사용자 목록을 확인합니다." +limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다." [msg.admin.tenants.registry] count = "총 {{count}}개 테넌트" @@ -836,6 +838,11 @@ select_placeholder = "테넌트를 선택하세요" [ui.admin.tenants.members] title = "Tenant Members ({{count}})" +direct_label = "소속" +total_label = "전체" +list_title = "구성원 관리" +direct = "소속 멤버" +descendants = "하위 조직 멤버" [ui.admin.tenants.members.table] email = "EMAIL" From f02ba3cbbd5a08e6c230844377a6e4ceaea5fe40 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 27 Feb 2026 10:52:11 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/playwright-report/index.html | 2 +- .../routes/TenantUserGroupsTab.tsx | 62 +---------- adminfront/src/lib/tenantTree.test.ts | 93 ++++++++++++++++ adminfront/src/lib/tenantTree.ts | 69 ++++++++++++ adminfront/tests/tenants.spec.ts | 103 ++++++++++++++++++ image.png | Bin 0 -> 58287 bytes 6 files changed, 271 insertions(+), 58 deletions(-) create mode 100644 adminfront/src/lib/tenantTree.test.ts create mode 100644 adminfront/src/lib/tenantTree.ts create mode 100644 image.png diff --git a/adminfront/playwright-report/index.html b/adminfront/playwright-report/index.html index c67d9584..84d1d61f 100644 --- a/adminfront/playwright-report/index.html +++ b/adminfront/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index 13bc0537..03533254 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -65,11 +65,7 @@ import { updateUser, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; - -type TenantNode = TenantSummary & { - children: TenantNode[]; - recursiveMemberCount: number; -}; +import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { @@ -779,58 +775,10 @@ function TenantUserGroupsTab() { const allTenants = data?.items ?? []; - const { currentBase, subTree } = useMemo(() => { - if (allTenants.length === 0) return { currentBase: null, subTree: [] }; - - const tenantMap = new Map(); - for (const t of allTenants) { - tenantMap.set(t.id, { - ...t, - children: [], - recursiveMemberCount: t.memberCount || 0, - }); - } - - // Build initial children relations - for (const t of allTenants) { - if (t.parentId) { - const parent = tenantMap.get(t.parentId); - const child = tenantMap.get(t.id); - if (parent && child) { - parent.children.push(child); - } - } - } - - // Function to calculate recursive counts - const calculateRecursive = (node: TenantNode): number => { - let total = node.memberCount || 0; - for (const child of node.children) { - total += calculateRecursive(child); - } - node.recursiveMemberCount = total; - return total; - }; - - // Calculate for all root nodes (those without parent or top-level in current context) - for (const node of tenantMap.values()) { - // We only strictly need to calculate from the top-most nodes to cover everything - if (!node.parentId) { - calculateRecursive(node); - } - } - - // Re-calculate specifically for our current tenant to be sure if it wasn't a global root - const base = tenantMap.get(tenantId); - if (base) { - calculateRecursive(base); - } - - return { - currentBase: base || null, - subTree: base ? base.children : [], - }; - }, [allTenants, tenantId]); + const { currentBase, subTree } = useMemo( + () => buildTenantFullTree(allTenants, tenantId), + [allTenants, tenantId], + ); const handleAdd = (id: string) => updateParentMutation.mutate({ id, parentId: tenantId }); diff --git a/adminfront/src/lib/tenantTree.test.ts b/adminfront/src/lib/tenantTree.test.ts new file mode 100644 index 00000000..eb277ce7 --- /dev/null +++ b/adminfront/src/lib/tenantTree.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import type { TenantSummary } from "./adminApi"; +import { buildTenantFullTree } from "./tenantTree"; + +describe("tenantTree utility", () => { + const mockTenants: TenantSummary[] = [ + { + id: "root-1", + name: "Root", + slug: "root", + type: "COMPANY", + memberCount: 10, + parentId: undefined, + description: "", + status: "active", + createdAt: "", + updatedAt: "", + }, + { + id: "child-1", + name: "Child 1", + slug: "child-1", + type: "USER_GROUP", + memberCount: 5, + parentId: "root-1", + description: "", + status: "active", + createdAt: "", + updatedAt: "", + }, + { + id: "grandchild-1", + name: "Grandchild 1", + slug: "grandchild-1", + type: "USER_GROUP", + memberCount: 2, + parentId: "child-1", + description: "", + status: "active", + createdAt: "", + updatedAt: "", + }, + ]; + + it("calculates recursive member counts correctly", () => { + const { currentBase } = buildTenantFullTree(mockTenants, "root-1"); + + expect(currentBase).not.toBeNull(); + if (currentBase) { + // Direct: 10, Child: 5, Grandchild: 2 -> Total: 17 + expect(currentBase.recursiveMemberCount).toBe(17); + expect(currentBase.children).toHaveLength(1); + + const child = currentBase.children[0]; + // Direct: 5, Grandchild: 2 -> Total: 7 + expect(child.recursiveMemberCount).toBe(7); + expect(child.children).toHaveLength(1); + + const grandchild = child.children[0]; + // Direct: 2 -> Total: 2 + expect(grandchild.recursiveMemberCount).toBe(2); + expect(grandchild.children).toHaveLength(0); + } + }); + + it("returns null currentBase if rootId is not found", () => { + const { currentBase } = buildTenantFullTree(mockTenants, "non-existent"); + expect(currentBase).toBeNull(); + }); + + it("builds correct structure with multiple roots", () => { + const multiRootTenants: TenantSummary[] = [ + ...mockTenants, + { + id: "root-2", + name: "Root 2", + slug: "root-2", + type: "COMPANY", + memberCount: 3, + parentId: undefined, + description: "", + status: "active", + createdAt: "", + updatedAt: "", + }, + ]; + + const { subTree } = buildTenantFullTree(multiRootTenants); + expect(subTree).toHaveLength(2); + expect(subTree.map(n => n.id)).toContain("root-1"); + expect(subTree.map(n => n.id)).toContain("root-2"); + }); +}); diff --git a/adminfront/src/lib/tenantTree.ts b/adminfront/src/lib/tenantTree.ts new file mode 100644 index 00000000..36b5a4a2 --- /dev/null +++ b/adminfront/src/lib/tenantTree.ts @@ -0,0 +1,69 @@ +import type { TenantSummary } from "./adminApi"; + +export type TenantNode = TenantSummary & { + children: TenantNode[]; + recursiveMemberCount: number; +}; + +/** + * Builds a hierarchical tree from a flat list of tenants and calculates + * direct and recursive member counts for each node. + */ +export function buildTenantFullTree( + allTenants: TenantSummary[], + rootId?: string, +): { currentBase: TenantNode | null; subTree: TenantNode[] } { + if (allTenants.length === 0) return { currentBase: null, subTree: [] }; + + const tenantMap = new Map(); + for (const t of allTenants) { + tenantMap.set(t.id, { + ...t, + children: [], + recursiveMemberCount: t.memberCount || 0, + }); + } + + // Build initial children relations + for (const t of allTenants) { + if (t.parentId) { + const parent = tenantMap.get(t.parentId); + const child = tenantMap.get(t.id); + if (parent && child) { + parent.children.push(child); + } + } + } + + // Function to calculate recursive counts + const calculateRecursive = (node: TenantNode): number => { + let total = node.memberCount || 0; + for (const child of node.children) { + total += calculateRecursive(child); + } + node.recursiveMemberCount = total; + return total; + }; + + // Calculate for all top-level nodes (those without parent) + for (const node of tenantMap.values()) { + if (!node.parentId) { + calculateRecursive(node); + } + } + + // If a specific rootId is provided, find and return its subtree + if (rootId) { + const base = tenantMap.get(rootId); + if (base) { + // Re-calculate specifically for our current tenant to be sure if it wasn't a global root + calculateRecursive(base); + return { currentBase: base, subTree: base.children }; + } + return { currentBase: null, subTree: [] }; + } + + // If no rootId, return all top-level roots as subTree + const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId); + return { currentBase: null, subTree: roots }; +} diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index da5b3862..253e1b76 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -94,4 +94,107 @@ test.describe("Tenants Management", () => { await page.fill("input >> nth=0", "Valid Name"); await expect(submitBtn).not.toBeDisabled(); }); + + test("should show organization hierarchy and member list distinction", async ({ + page, + }) => { + // Mock parent tenant and its children + const mockTenants = [ + { + id: "parent-1", + name: "Parent Org", + slug: "parent-slug", + status: "active", + type: "COMPANY", + memberCount: 5, + parentId: null, + }, + { + id: "child-1", + name: "Child Team", + slug: "child-slug", + status: "active", + type: "USER_GROUP", + memberCount: 3, + parentId: "parent-1", + }, + ]; + + await page.route("**/api/v1/admin/tenants*", async (route) => { + await route.fulfill({ + json: { + items: mockTenants, + total: 2, + limit: 1000, + offset: 0, + }, + }); + }); + + // Mock members for parent and child + await page.route( + "**/api/v1/admin/users?*companyCode=parent-slug*", + async (route) => { + await route.fulfill({ + json: { + items: [{ id: "u1", name: "User One", email: "u1@parent.com" }], + total: 1, + }, + }); + }, + ); + + await page.route( + "**/api/v1/admin/users?*companyCode=child-slug*", + async (route) => { + await route.fulfill({ + json: { + items: [{ id: "u2", name: "User Two", email: "u2@child.com" }], + total: 1, + }, + }); + }, + ); + + await page.goto("/tenants/parent-1/organization"); + + // Wait for the table to appear + await expect(page.locator("table")).toBeVisible(); + + // Check if hierarchy shows correctly + await expect(page.locator("table")).toContainText("Parent Org"); + await expect(page.locator("table")).toContainText("Child Team"); + + // Check if member counts (Direct/Total) are displayed + // Parent should have Direct 5, Total 8 + const parentRow = page.locator("tr", { hasText: "Parent Org" }); + await expect(parentRow).toContainText("5"); // Direct + await expect(parentRow).toContainText("8"); // Total (5 + 3) + + // Check for either English or Korean labels + const hasDirectLabel = await parentRow.evaluate(el => + el.textContent?.includes("Direct") || el.textContent?.includes("소속") + ); + const hasTotalLabel = await parentRow.evaluate(el => + el.textContent?.includes("Total") || el.textContent?.includes("전체") + ); + expect(hasDirectLabel).toBe(true); + expect(hasTotalLabel).toBe(true); + + // Open Member List Dialog - Click the members count button + const memberButton = parentRow.getByRole("button").filter({ hasText: /Direct|소속/ }); + await memberButton.click(); + + // Check Tabs in Member List Dialog + // Use regex to match either language, ignoring the count suffix + await expect(page.locator('button[role="tab"]').filter({ hasText: /소속 멤버|Direct Members/ })).toBeVisible(); + await expect(page.locator('button[role="tab"]').filter({ hasText: /하위 조직 멤버|Descendant Members/ })).toBeVisible(); + + // Direct Members Tab should show parent's user + await expect(page.locator("role=dialog")).toContainText("u1@parent.com"); + + // Switch to Descendant Members Tab + await page.click('button[role="tab"]:has-text("하위 조직 멤버"), button[role="tab"]:has-text("Descendant Members")'); + await expect(page.locator("role=dialog")).toContainText("u2@child.com"); + }); }); diff --git a/image.png b/image.png new file mode 100644 index 0000000000000000000000000000000000000000..f6fc697bb74921d7448ba0716f99c434dbb965a0 GIT binary patch literal 58287 zcmdSAc{E%57dEc#ZF?_O9jIQ4PC8I@Fx6OXRZCGdq=rycHN;F}h)5?CMN3hXsCi6G zNk}AGik6y(Ad(bCi6rJAlKkS{+wXe+`K|T-`<}HdC)4>1XP>>F{p@GQ+%q#06_yef z5)u-=2w8?go?IF6N~dH|ypmWm zA3D0`=ij+K(HP%gEbF+Mc>XOI``_baZ#(C&-!Gp2d4Tx)TT2_`8k|;g+g%wdn_lfwjf5g{Fx^8il+PC@S`v;da zM;NN}gIb&Yp}>KU_=51x!{&`oy5nJIr?kR&S!{i5jhXHME|@;A6yF=z$iLJXC5KMK zo^I7oKmxT!Umf0GyT%}gc7Jg^1>HX~%H7wYZ{~zWW+lt^L*>jW(;T@upCc^46*|;h zMLZ@wY9SJC$o;#0*8;7q2|cBfL&Hs@C55;8AJtS}wy(}VUHU_~w0unmWIkYr^|97Q zV=&(K_W03~2B5ufd|$r<3SfypqO<{f5tT9FcIUGEVPbI-f3lm1aZQ^YIX=Ho(!`&W zmaDxXHaxOfQ5_zhe*I~R&9AH#Fb_gb!-5X%TSujMHK+Ar^_j9LbgS{|^B5~GKnFeTJ;nrJ!gyF_fSB{r@y<2brJETJ*06L%0x%} z-*DI3R7rNHF6=%dY}QBc=^O+E>Faa0q}l!KxlB2-RM{$6Nv|56Q#y(vKRGPv^*FG> zxdPkeY2!#8ipW+Su}>m+wWE7xcKtKYp0VQVew*Jz)j?(jWfz!(H8rdA)Ni<;(#o|d z?+Vk*A>>kBfz@%&OzHB_M%ghdSp~_I#P&N=1p%OaO0!`pOXwf47k7;}c%8TIyi^f3 zEG<2P`(&V`s>h$uW~KXyfBFT!^5pwk>VO7l$bpnQnP=X(l={WaCwy^G3^SM`+8{qd zrxM~s)MY~_;L5+wsg0z9N)TfI%DoFN6| ztr7B+?G>?q7z)+cABV|IG;cM1z4vcn;{=_MSM0lMOXo?IGkHF}X8exN>R|&;ly?kG zjeiZ!G-l6kgt`k-um(4vc5se;{;`rVs|W?7l%jU@fjY-@iAg&@nS1A2HZOd$&Wl!= z69%n)2wglP?#5rfm3b9pMo@0LY|)crqa=K^Z_&PSY!Ol9py6O4Dvo++G+CgtKK#J< zj*t2;nu_e>53tekk9!8p*YP%V<4fSfARr@$X2gXTWY(T&8vCLj^B97Tj(cO_c;#Z`>O6K0s2`uy7JV z?usorfRNwt99U>!?KIidw70~tT|sg6j@GsC3C&Yj3fD;+{o97BQ6E|{$GU15w})xc)YAwCQaChD!6yGtihUD|0B%sECTcy ze9fhUU2|IDcl5e;C~(HwHqDGd(I~bEsq!tw22W*+AOS&3^+z1`s$#(dQ4nwDV zGzvjSR51G`rl^O+U$om#WivE}MEnUv&N!e9sl2t`RiKat`pnr=aFW!WbnbtV@Gk|$ zvWn-=nxrG!fm4Y1>kcSEOc=-xUG(-vy0)*`Ef5XPf19c~ZT!D!J#XJVI7EcirhL8f zq^oLKnK*bRf*ps=@A^fS!?N{0{OFV*31Y_dnv;SRQ-SaG6G|vFMNH{lzlR~^9$-oT zMGRiit6wk0dmE&(U;T0<4LJPVtpB)V_4k}xU}}fkop=9*sDH!o(<7+Tu#eJ;Ic{t- zYLa_#?(+ZP%^R?)w^E9wD;lFj$3nb)@5m6CePt|kdd+>nJLE7H8?QNj4MZbmVe=Cd zG7-|81gL`}waZKkLJQ-Nmj4&M@)u1rFOjss7qCl^bj78-I>)y7y6;iUh62$Mk2lya z^TcSUN;QB}$r`SlIW5sM7D~G^ROSZIp2z`Gs$28vYb^l%{064QUr?poFxqJ-;*Ye> z_m{|yVXH6R%~V`GIk+6LP(Aaeg3?|2oOQyfntlJwEOo{}I30=KI9OKddGy#Z@GqZ5 z(pj=&X?NU@MNb)m4Qqo*(Sm}+op9aRkJ$#w)f*RLq<-U(0~g(>JMF6<$AD+8#+ANh zDQN2dt?MYGr(+?!UzSc6fpnF+7NWIADNuMjHGs~+_hUogk&}K zfMO34UlNX35PWm@JE_7s1oP1D3`PU@iEX3`&Sw-p!P|QJ)7;+s-BQMxJ?bTY>`Jj3 z=yMwo6&r?-y^_?!@7!t&W4-7eDQT1Y!9*1|rCA|lVqBG_S6HNngdl{jMWo6|cWyR;P@fr^OkfS=sscN>{v7+0nWGqgGc+=vU z%$nUzuuPInpr7sgg{dk&F{Ac^^^-$_DeqG{R1n6Jc;0^e(#5|x^>5c@%`p{Te>A6f z-AFlJk*1u}UI4bp_0luRZGHbAI=yzy)HELLA>)lsFZ*@O-!{A*{5{s|ZkAq0dk5LB z@uiJaX}R?j=p$P$KUKcJuzPKK&OQBl;!OoOF#2LH&M{&kprw&jW8MaHDMMC&05blMW=To-{V2`>`AwHP5Hrf_2!>x^z{QfqhxgjTjedPj!UlH6k0(r4F#AGRtp?jZ`t;to{i~k`nu{++`T{_Q*i~bz6 z7}{o!MQ#Lh-*>v*8TgHQpFYjVXc^LKGJd>Uh3S^_$iG6(CII;7Zbhe6(*W!CO$V<_s#PDTe=t+<>aKmc65P+V3Rr2M+37wLBW{e>mk47IWh*@$u!%4?hRF{Omc z@45Kznj0JyM7&ua`V%Xg#l~99wwCoUELnK928~;uld|D?=got^QJmusEcgOEbhe@r z&Tj6Wq{r*da0d>kn><^XOM|TIWQVaPWRDm8|Gg z*WAnf8+4p5x5=Ja)hKE@LZ!nv7Z}tVVPa=f@$L74>$8LprGs$^GZ;J{G?S=FdD540|Q7fvE@TN?t2CqwIBr=x8nc#n7pmC*6TlQVl9W4e8fUc+XUenB65y z8CEzN8>%DZEkIm$BcB`2`Y#RXndr=Na?R3uhxgyPRABraOOwY;@L7w!C+2&f z?);0y#vt0Jjb?5eo~ijYK^jw3QNJ1MBV+x_(QlTq_Ol-~V%go89n>BQ+5j8prLjJV-yl(S=h}B)5AY;a=O>AjXX;qC}`WfhSB-VRNk~Wg_ zj7vaY?pI?Dl-$_b+#~O(X#KoQx_bGRtT%^IUjZ`oBXPTjQjHA5vsBLV+~p#c3eX;u z(Jpu)4jEyV0@QIC26Ld7u2dW8BTf|EeUD%y393t_(6puNst3h0=dGzyDvlohjiJ?j2 ziVDp#|5hR2olJ4(SNk`)MW;3{cDXH9Z@TvV+(Unb4D=)X$glM1&6Oe**df^!D)WLT z(0$`R=nexYo|&rp_B^DaWWD<=-1}wnngrR^;Qz8tv8`%kB?p8YRcH<_{Ga^ zUE%EEqDtd|ow?qb>X?zIU<#-YT>nlCJ#_C?z{)tj4S%Aakn@ak7)&1>*#^Mu2P8}9 z7BPLUtS`53V0{L8?V5gbcC=lg`<68N;$p|$lr>=^pZeu*AMCdND6y~m!p@*(&f39$ zg%yM2S`odW#kek0M@m*xt_qRYOE5o7m!t?&S48F&!h4qoKB3SLi%0Mda}S@T2|N{+}ifkT7b%caf*E`tU zA1$n~3&4e3T#kO>_iCz1@N#PYk*QTFPCKV6mZ{5o`~o}nb+?L`E-wkT+G!54U{pbv zW1sD>Kx3w4#TyFs%`-j(yM0G%;!Doh zpL#{E!rHvB_7kg2#PO7(A>=bMSCHAy`jH|IpCffP!okvXtl$5ZFW1p`L8aH_# z`@>42vDC{3aMvv>thj8&YB!M>!|LYH#ZQGF)PMRmNXc=4((~GO*{dWw9WNcb8Rp>! zi(i}okumXfi~`MPB*Sz4(3;&(sLUajX;z$F#n#4G-Eu%8@?%rlmt}KtK@fj8KUxsh zea0p*Vi`97e(T2x%sha@szH$#jTl*%!$bLbDr4_2pXG(pMnTvQ*jnP_ag(Dh`<(>n zwv0RTantq9YfMpqP2BsaIg)Es#pw6xn$w=9NOILm%8GN^$DCdgE(mwDc=Vgs&l9a^b2bH`)y%f7JG4K|4O3b&MIW?@mRIa(JNzQ;(K7yc z$H3tGb|=z_*XNLFtt&mB_?w}_&$P*Z!>w0*RLv51MU(%s^1v!%sy3xAXhEwSbY%X4O&00Xs)-6 z6M3(Em9_rvv&os!sKWaf1E;;pO6`7i4q6N!;vJ=@xek|hZlf8Sr13amW%WT;pt6na zym`qVq&rwg4^B5%jI6*_uVe&|c0_PjHGDmsKR{aRF-;0w?|AT@;X41r4F$aq>ipgU zAuWv+takdSv(bL+NZ|C(5MX9Pl?q{^-LgMkgTho&GF?R(jGJa(9$cfcE(Yt5h0}qy zER8|B(T9ixd(7r&=FJE($)ql~ekDwB8#3_RZUz06yoA}PUTmmI4ZzaU#aw5j)*Cam z-^ZTJsB<&W@0jk)^ySECerK4tmR4``hEE;P-sDjMZ?iO=p=YhmG3b<^k)yePjx*nJ z_sqZ8qs}py%6?5)rH1%+jS0<46~j6@Nr}N~LnW4xy6iq}LC}q?=&|`#*=`JT`1K?v_*9;19A{(OFw=YZX5fHTTak7U69so$;FngxBc^G>`={-s#stg^y@{zLfLgf_-{ zzj9fYGFA~q=;poGp>)+htj%Pn!36oR`oss&LJ$v)6y?x=>%D6Fck^+~;})3)|jmM%`_Tdj`av#zZ4 zzI@>YEN$bHnf9`;`qs<-u|-Z{_j~6=J-t@lp$+(YZxPe~e2>3?l6oNNx52jMi()F{ zRHR)4XS$Y$JY}24qFerasK*WZK;RpAUHi-*W53Zxdi~^tiAOUFKu8&H_|?_&kRQ3- z)o1yf*H8y9tv!*SZm_k6Tj7rE=zZIMA-@ml4YIGRY|Xska&LpK_3~iL>fB&W*>+Yk zRAU72jTzDBu~Nfm?SNP4Wk}}KAR&FHIQrUBQ5*`V?P17%yI0I#U(Fpi#QD{a>cjXM z8>XzVh-%AQjtskpt^!c#{*9H1^F$B7`0;evx#*cKM8>96ywb|*UE2-$NHZt{ zV;YP6GG|#IX{*wzxU#7)_4W62UO3&Hs8bgNHLCuBM--70cKS#-gR&FB} z8x}T$QAt5GxEc3vZKSBrWk}DzHA2+RUU?E`;*vTy*qslbDnf&dut>RDN%?A`ZwEe9lZRy9JaDpw8-)6%^jd!I~OEWGxB8D80>iT zv4+OgU%eFF{fabFp5~K*i2XXN-$ke&Y)MF<*K9%9AkTla)&9PNFbqlHJ>e{sp}fCM z914H2Oi{x2wU!vbje$p?1q0)>`yi=QJAooO2PlUWMo2HBCJSKR*59J3fvL>M`%+&}Y#xl!Wv9go(EX0&XhZey+5y z*4z6AkR*?}@a=Ra$fB5Sih0tNAaWcU9zu1&R`@$5M3delUFKo%SXh?G8{?7hwl(uT&541|U!tVT-83BG1xht5_tiJ2wdeafP~Yk= zoDS*i)&sqifsa`*{ax+@+<8#OnmrQDz)H`{7WED6nFOQ(dAeJ_UIfnsQKVkrT1R2s zyS)4IX%x4yVnvmuY!96|h!x9>;9+*3nRl3A)j{ZV3?L=|aUGhtivC>(jM; zpA8ONz#xH5Bo&E?FkmQ!i!tCwAg6Ahzpe2Q{_!zX$*!Gq#_=O{VTY7;xmiDI^!qla z6F<6SXz<0H79VHc{W|okbwZ2+_j&jE5ZlU!&nV#?S(x#s-VTx$iWL~5jQ_x2>i!d0 zrkWmn-{E+*1u_dh%y*3F+~Y&cvM)rq@Hk$C0Xvt&4XbNV$oku6il(BSZzp)~sI42q z*Rt^L@=&bxInT^6M^4XTaV{0!N!)wPgBHQQK>%}&Lwa2*uzUj}S1;?gp2vt* znojI!uP*AcCTC2(VeMO(=@xywM}b)lasIXTrNF*#_@F;qE*$lj9-Xs zvRtuTwgX7;w9Ku)L{Cxb`tpJgz4`Jo2eU#{kk+TW> zkIVY}=O$h))}+7=)dcg(F?8dPqi2#MIFz-qnFAX9r83_Ea(1>@SD8eT!pw_-0I!rm zji7D>CsBk^)2)YMZ(g**;Pkg#WGhE7kTxLSf9gnn;1Ge(3BlK|st-Q~L1*EpQ4y6P zaN>K-J+WP^d`q6EA}O^#+bNi90O>6NOpSSGb@AumiI@mbOP2-Z%Lb)n;Hg zU`U1XPX)8)aInP|0JHAQ08eM}=M~CKCMIvGpL2YWkYDBSob5RQdl%c7{an%_rpHdkDWi5UY2L(iz({fqd{1&#fg@+B}EjO%8~Iw zgllyvnXF7_vg$}&UfW|06yj8uCdvBGO&|QEKIF#!Ro6%RHeMOiO`pxa=NgDS)NL)nI=eOXdX(Kk@R3_;FY_IiJ>S~fvzWb%g}1bA z*uKjr6Rd4)zYRH|8lhXfy>cV@v^iRuz`j40;Pud7EI?Ua_CL?v9f_DBIJ}>ctI^wK zcj$vV{otF`MPckxgB?qS;%9X0MpxnUmN3 z_*YD(in@2~{-DqO0Hf7xo7BBO8Kao0p$fZ^t#$?9zhVuhD*iF8dLSwbOZ%=&*rbL4 zY*`Kb`YipWU*&kw4kKk!ZhfQgg!LIwxtuRn)9bc}3T}xwS@phuP@k#E>R&3m1!O5m z;vut-f@%JFF#W~D(3wt2Z-EmAMiyn8Z=G3hs9$E_q z0xwkqFf(c1HjL}%seBy&*@%J6YYfOnf3iD?gH;hhR2Yzvks zZ2i$lNvDIT5VI%#49Jned|BzA3EHd547+1sCo-T)zfiA8s_p2)$EiR`?oB;yz{CCYwKk*q!v zvWu-*{zDft({o~N#K?D8j27;h<`nP@G?IUuzN$fk&=chNk@1^sQKtVJJw@@C^7q&M zHq+t3Ge|mn2-J-~62=}2O~w;(UElQN`a(Grv3cTN*+l`BDF<)_XF(fSdeAzAU7^iY zJs=29D`qpI`|+7K13jB?j%|aiQH=c|cntXJ~kxTQ+mp zDeW$<3KirarM)d|5HdC_N|;iI`qc7?w>%ZxQn2m1lAW_*9^X^9nNYpyIN7~z{^x7M zk%oaV+L!LutTg?PwO^1ONiA`cY+n~@lxgl%hf3q!*l2kpQSC3Hbrs6F1J(m4_OfPD z;Y%YSSjRUCf2bw2wXqsKX)taKcl9++`O}$*W$!I}lGGoYl{eO~B&X0;>sN5yaTuo~ zrVTgqg+CqfhA0D{MHTR04Yld_{JbC&q4f%18lH`c@*qex&UY?DQ>y$m$UKM z2I%UFpKY~RYpDyS`0KM*PWfEey}^r?Vx+}tdfTLI9=a)TE?V6QA4wsp4ii)Lm&YD(HfEeC4R%5VzH>Rb z$aJrd*VNPqw`mdn?J-mgTLM%5zU`MDlmfcHGMibA{9fhyUk#&^8v~|kj^RX+OE*7$ z`<}jPFLORebE{|9d|5XwnC?3%`VQf~+_|@v)d6sVD>^>Gbs-{-fJ`q5z;AhAkr~tf zfw?ZX0pFI&AiRdMf?3qgl#MQJUpG3kCY9VRxJ}F<>$0P^0*$2bFKGR z(ujS&^hmi^9iv0I1b}a3 zl-1>Lg{|8qonSGHcYjEQW4@rqtf0efAFPa6Un~m2I_Dwld*cD)=0&fM&Zd@CD_6zI zTi0?K^H<8j1WSp+HnBue9@1uaBTwu6+L_y$8lCJ?r5e0X<7WufUmFXIJfsU< zt6e48lPZ$S8=CL`nhFNzg95w0_B%|Wi=S`JzdhwuKa~nz*56tWeBpn`Y7O_Kq}{QN z=GPb6GSs`4pd0a${-jh;giwB%vEH&5!Wc}$3A@N-;%@ZU9T(Hvs)KPDY~-PEyf-$+ z`}p1wYI8dCTfJie^gZ;XLg{1uUr%9U7`2juPIY_V@$5`>SlElja2p`lReScOKHjBg zQxH~GvrBvnBsinDq$^mmWpOHvA5>doy6j!`ACt1isw=HhOTS3Qb5z~wS!0@V8JoV9 zksx{bFkcX`vE?1JEwEcpSGMrn`*m`?Tiwj_tp$@EqBZ?ss*dEj^XJmMIz+cUqi}^u zQS2z3KiI>4@Jt~05|~-IA7M$d1?`?n`WPYMX;6o=Gj#+$X+6K5ajfWW65)*AAP=E0 z@APr4@rQUw={*HWD?Ahx*tWT9AOG=AZMa2Cqe4$jLF|@mm|I>$A%Pq`5DB*h21F71>Zp;`x>^|0eT{h}s~}>G|p= ziqRTy0A|ALIh9a@-t?W`vu+bvpkV$2xLJI?pRZnzqIv%HB0zp5u~_>gtcGW(di>wHrd3UU4wF1KbPRY9F))*nUg3#WAJsq;+Lgu$D?VD65gS>$@3v=aI9L z_5hA7r%w?%mDqlZ7C{SbV^jd~0n%DQ0O)MwB_!x$jMm=1+xKE|5M<>@pn|u-6}j6DRmBe0M1`4ieuMzYDYUx{=|qO- z+x#u9TY%LGbLZVo$&=z^@app;pb1ONPEd#~8k7s_+%+d>^ zR=igfl{sk?KZXR-@MZCRD<#bY&AAYbKk+z&rFdzL&)nCvuV}u;VkT?s@?S{$atCnI z!QBsx&bR3Kc-*K$Hr0uk2-Q26Nmoz+G$z>sZA%7k_RL=moS$)e^uh;3IJfJM#kOXP z0(c^m;wd+~?h-lhF_!=gAE!;eSd7CZX)%YL?qL_u=ZQMxURqBz2&y#U@+*~YQkj37C=y#dAhf0Xgw<`FPMQ$DtbPwZg=hl{d;w8fP3sz6rv-E=l;7E5Y2`cK5CicMI=kNwMa-0JczmzI2)) zFho8Z#dHR1yq~@DprR{5+xaC$a}z?kZ!|t;rtL$=N!{Jj@gyNDR_8|gb+h&1M5TSN zJ5y$lZYv{>dkW-dRRW2?==YK3lAgr(?UqxeOV(G7p3ze>i&2Jw_4~oAFlSF*Q_P@3 zX+(GDo-c^=pWdM!lfSzf|4=ep^l}#|m26?raGO=bHp>(UGUH}PS_;6f7c3@US_|Dw zmz+p>CzU)Y$8XS_{6sBr7xD)y7KkkUfMLmGRO1hYYIB|O_vLNuuQzwu((A0hWq3dQ zxVk!8U2TIr$wQ|hFlpKFCiOKOsPFjvcH)rggfwp`XV$X~f+OZ)%L<>oB?$Ok16M~OOy0Bqb-Y?GvsHCZ$M^Ia zhvRt<&)5s=4%uB%|JA@O5K(UUj^1}Di=&7ldbni;61BxBi4oXfoSaJIgk7*)saH@$ zngaw4Rd#prU4Ww});Sw|x5H;6=tF>!aJ_MpV6hDfKeFKX)JG%P;I^VWiG0pLAhUid z%}JRmfw+DItXo()!C}EG@yvSK?t@$skc>VgoafXy%rWO!(3SbRIm_#98~ErhH~oLdrMB#Su)0^gLB8Gyk@2Qa4OQP5!#RBJrq$6CpfwPuNn znNI7)AcCGWzaCnF)ZKPOkMjf~Oga_)LLeATRIWHFxHF-p0%v3~5$0NSkCa>D6phQM zSpd7I*D1~NCo)&uCPai!*C8AVi}Qz(8kv=<;$SuP4m>-jGODZzG1%e)}Q9c zeQk&!Tt)Tf9-oHS@q6}T@Ju2t;BU7U)ZH|J3}LNJE}3%KnhAt*JCOl;#@I0kwgT50 zr7ocM0!r7Rj8zTBd?0_8Sc24A)`M*d3KBOZs-@08YLNg0J&P$WPte)CiS4$wq4n&S zgEZKm!834q6}5e_O#hSM6V~YCYtEXm)_tTVgDK8WIHF3jmDjCgZup{{rSsS+80H9D z74`MF!C~qSL`p4vd0#{U53E|T?J^|eSF89D&>_7(yz;8Oa;A{M9x-ZrN?L=apjaEQ zpo|2mq$DQ*J8n*$&yZ`N_+a`x=_J`1^|cns@ja_z!Z5L-`m++^^3>$g1w8@nHBlic zUMx>NjNIB8B{;xfgAqaF$9s#{a|*&K%V*34n4 zV=fOO{zh=v@KBR&(>tzXeVQ%Mv`zzT$O7qI4Kn%K>vq$rB7p_ol4)4~R~V_fK|e4) zjx%k+7VR}HzaV__jbqFjdze51`+AJJpnX;(@26LVL z*NI74OiHlUK<1e#c9MK`XNrvUmY}}!ZkdkLLV8`y0JbJdWwq71ymdy@zNHFdGes?c7_3e$-~m zxtH9LDON^W)XAYFem9d7P=hwYq|7Baa` zZLWeYrQT!b*ZX|EPdSXG2AMXf8PkZc`3wt5Qll8iDQ#A7Un(o0W3|fO$jJqS?CtI> zJGABNhF~%YU0GW7t{Xk;a`yX12Qq8QalOX3NE_u>%&MeO_-bk>y)IFA`qk)C-RTIK zAQ*}`_efh%C}D>7g6p|M^a`UKZL~6)1*ADg&$C*=e^-8sT7=#vc{ak>wRqMsZ$em0 ztKwfKt|1!*lEa^NR2}bcWU{H?8gQ`7;X#ty2vs2NoevQ>zKrz)_r*5VVNAvhAmSmYNg^vrXx{t>e7fSFWlD zQ+H}W6s7Lh7l&J!{bQ)8_jQh!;$cb|?cfFaLF*#P%%#J$cl~})p5vHaVAf7C5iNP( zQJ;XRxz57WOZurxRB7A+nQMsoHlk5$M3M4FW5Nts50Q{ z7~~PY1B{^ZJmLAMAs-!Zkz}<_1Hz-bsQg0n#*m&|?zKnJDI0Ye95rFZ2RlbTm<%uY zj@7An(RPUiySTdNtWG9sw=0QpHVdzhf55FT;WL&?Boc6L&>0GwvZWiG08xTTWhSr@ zeB6M5fp^q^Q`FK%WN!q66aP`~wg3oz7L3NMGD_X9%{4F?Qvqaac-vu1x!5Q3BG66- zt?%2qQXvU02-!U&)yV;NMoD({e$vKUKHfg`#)K4$t?`);IkF))Ys8$+n5TORDhC)D$gdq3#*d0}ARe;ToTxt%DG78|X;`neH&SYs zh5cf#Yo2Z?RG#+KM*$VH0FKc91Z|#GFmT|})Z4wx@3?}0a`v2yrKOT~=(3+ke}1av z6qa|835e2gLlSe=bgcc6Dw?ed{q~ZNkG(BcCD8{5kcK7^w49G$IDi}8Hv{>tbE@c# zN9Sf2rp_->25C9e+QmD|&q|Iyl9TdSOP$@Q8wGo6 zqs{^vxn%xXE;k(+nG0)MlhZ-2FUqH{mBx-CyYGWqcYp2!VWYaf?3xo zR|HtBb51rtlbpr1bXhLxl7dJl+4O-ioXkJ^A|au0i^0x%z}XR-rA|`jo%dxZKefPg z%m-$1_1nk#)ZD1<0o}Jht?L)()K8Heg$)H7!e5r|!VQ;u5=ZZlhM>cLO1Ys-Z(JW) zm;9C)3#R05{`ud9pY|o;An2Y|WQ$F{Pg}q_%oEGLs>2>*pX8%jg=a$|CxQ7tR+ox$ zV_6pwx+Egz*WQ1se*#1XT~%C)wXY~5NVp^_l~q?i-aD2Ej5w;) zCpvT!zRnzBs4WfpnDbjA#5wPzUa=a-Uz8U>U6bon{uBK_Ipc-?Fl3@`c$Ta*9VqVM zn7mZtux6=ctRC*6$_$%^T7aBTeBl6J_dIGV=xjXUpftQ0(HAFU*}QuHLRSuiM$HSm z?ro$fpLy8?yuZGLVm9BHbVxrtO6+>OYPLy9)^9!Thsct|4&wfcjpyY?yw1{KYNnBL zevLf7s}g_55J<&qVKi#Y(T5YyI0q{TMmBkC&p*6Z(uMNz-z3pPlH^PZjp2(neFKmWV_2IOb8uDN0uB{L z_&#g!Z2gIj2bW8&pj6qG;CIx76988~lpaWXsgwQPSJhW+H~t|t z^l|;-uz!($TM#{8k2M@qsP*A{%2L(Lm5S8*F_*C#FkkX|9e1Xi{4C<7PMX8I{r9z? zB~kO=O70$q$coH;&E6+E*XHzTO@EZ$OD?lNkumyp*e2E*ZM(2w_(j@it4D{Me+urSxGgVNPAXDcuN1s{H<29moKjH^&D{^mx^ zX0y&Y+^76HIkSJa<(i8^icsN($^F>}_;YimUmwA%8U#F?YKqXU05RuBl9dCBC4u^l zmBLOIaxGWiJu*pH|65HHeQ{Rvf$_eLs=L6n?>s^;bzwp`ri5 zhJQ!QkypG0guKu_O?I^`E;P|$inlooSbF4#%?nDlFNOA;^Zw(%ZVLC=xzehVZlAFB zEq*LAgdG8OUfg@iJY54cZU1_RUuD$pCdRqlEI`QM_15=+}1z_wdE-dOzg zQl&}sf9<>((YM5$)}Q}FV+byiW7SJ7I3EkFYW*(P9A=mXOf@e=l(HNR*1kYhvdnvcV_3PbYRV@tdWWK-8F+Mvw*B zo;hWvUu1zAX$W2RZrl~!!fq5WWB=$V-F{Ew&*)T@_;T^9Uy#3a?zS2*eD)km;!=h=#?h}2g60M_G4%&;g8nI?Ztquyh8^+v*%&3r z^ZqlLyl-brH!1f0V=hW27~~nW)$j3g>W_zeJYVvL^^Y|tM8+KnP2B$N_HjgcLReYW|Umgy)GgD@-Po;BO9oU47O=Wga{%TG%lTx3$?A=It=v%g7VXMEsFu5jJ3)E!J zA#@PD)}fNzaP{{X!bJjAv!Kxf;{@k+W<$s2wONR?;V1UYhjj1S0$|XrwYnSA!B?pU z<2^cDd1}A^vlr&wAcBCpg1q>Ys566DTe+fA$MC?{ANAk7y4q^h`*4LAx;#TxX&kcN zWDEFRL0eF0ps3*96^D^WgW?zrfq^u*xm0_=mIV~FEz5Pz4p%Oqs010zijr>xhY^gQ zP1W`ma5m&UnO;EFF;$NdgJ@Y7F`f1IBJINCBQtHIQvgSL=ODYkyK(q)w2X_O+?I^} z86?L6euPkm9RLi4VnH5ugjU|D--*U zTzK$9H&YHB66!y%2$}P6!!NC+q@;u6vg_&k{$- zO63hg{eV{R+|Qk9Ro(hNbQBh8j~VqW8xAzr^#V9-s?bMjqU}?S$rx`SC>`TX7IZ>g z0k_wNDR~YE8%`3%@vUr*x%4v4jUYcBPu$p#|IyUp)tkG8 zdu;hMuvZ>1Vgc9trl3e~I+jIumE`xYQ3Ex6@4U9t6V;wRO%6odV7w61ugb{in?3X& zP7>V0pwHW}Vy zY419sMp`3}OTwV^;55B5U8EQlHln;msu#6;EFO#73EfD+J)4%ezD-6@9e5F2i)W$B z6~O|zaL8p12WX@=2|<*WWNqF})Q{j>se8Knp?}uXpbbs80&7{m1 zJ4f3Ibcx;@Q=DNUXMX(oyKa3bIXi4=hlU@t@Q7lVBi#c=@L5>}jAY-4et^&;ROIHu z+HF)OTTLqmNz{DDoJsSTU0Y`p$khTdIS^nk<Kb!>{dKKn^Y2ZvhuopOm z2Dh5!^<;CB^yFjq32oa%SqhK3~88IEo}X0ye_0WtafJF0Q^5^Xh*l4ecHxq3tC}1-4d4 zlpWkt4#tNjy}q7C7U$L(Z82@9o zyrvsPQX3Xje?YKQ;83OrDmS;uOgFR+Byz}z6WP(cDa1M zdB>${i-XIg%kV2tiB%C{Y0J~OA8jtcwP)^hpIK$Zhj!U>cJJGK_hZv$B~1z~5#OF!Jg#aQbNL54#MM^*dAt0disB}UAL2L*pNEZ;K z_udJD(t9rfLXi>z2|Yj{yn_nA-+eQ8=FPl$Gxxr^{=t~!l)ca1XRo!_`o3?uVMo>% zuWGnJo=lEkaQGoJYFHrYkdnjLCR;AgX7|28wHKlu5%8Y^kfQ~Y8h1zh^RIA>8Oh&EBleh$R%|)}=-VMF z5{5LrA`LKWSe#IM#(8w(4#)2F>v1KNeFRU85=M-!<2%!+8qL1>ZD0T)mJ@zl(l0hI zRo^r8*q2T{6H`?h(t2Q*&o?zFekU*Fe3l54rM&=<69nNe?_ zRqqqLxTKRlJ^MHQq>_IO(X%`mg~a5iXJJO)5FMX431#gW(@FqCI}A+g-?nl1&O5Sq zrq6R0^s4V!HW!HL-onS}SgQ!wr)Nmz0$n3lMUtjisGx{JMS{@nCg_CM=HP*qFTS#=M^5EdRkH0YvHZSdiOhcA8;pWaAd)m)4a)(~)nchZfjJ-t4=89QT0s^(dI*jszCGKUcK|D12ZpP-d>$b|2z2NN(eT zUkZLG-#mP0WgdVz7DORSyJcb*6UiU-_ZEh$Ov9GaZVu$o*XP#4!CLb)H$E)#Fzme< zf5x$Y!xZz-G-O}b=NsNC2yh($6qwV9D+(#}K)#9Z>Vu&_4px?I!=Bgdqujh!(Z@&! zjtAVR{r=KyS^yj+x)42quxhLg9@H3E^nF3(@xe0Cpa*mz0W#C()I6KMF#I`LpR&GC znK;-lq&-jVj5NDq=54 zT@W|BL8u~1_7&N3k_JQ5OFwSzD*?&zS+E|=2^zUni@cG5{YOdj<;wn^4MAoR~9CgQ%ewc`_C+gV@mmDwZ&Z6wiESL5-$^8~mr4>b|y=Q3iIUnGj0t;k)l=Z_u z+AXwG_8RYulKZWt22~tYH=A@=Ym-bH*JN6g-5D?AtV2@;yP}aYQ@o|4Y?~ORf8!|D zbFGFA%3udrtv=@|$H{IW-E+3AMjUtP9;K7&P0{#TqdTxwA7sNBS#!ZOiGiRRk~*Z6 zho(<<=YPdxqnOA4F?a!?EyU7jl30GqB+LA=K*qV|YiTHm_JA!$u78rDp;ojt%Che~qcJ6a^swy%VcFPJ{>5qaq3uv-oO_ zR8>T_8PBZf`mkPI4z4H1-j?3}!PS>*N{?LVDG2E6wZ>f7jj=7l)09v45hk#MK;mpP z()T-hg8~s>C?`KcwhpJ%kTab}^OIv;s||vNfGtP>i9DbG0Kl`Ye9x+AFi4O4_042J zxjg#JZIbm!g?yXb;vsNjBHj83BJn#S-h}q8w1b}@)=%xlVQ)h&|&Wb z^y<0Xqj#yT*Vr%G=HX*)YS<8iS@+0+;O6NIFF$V#;x+jHMy+UQd^?}iyDM*BFXvbE z+}eCb8k`81LdIZc`u7$FW~HQ1aNu3>I)Is)CM=8pUA1TO%Ye|j?xAzm&EsJRPsU!+ zt5BD-Utgvg>KgZC-)JmkH&;g{f3vHOjMr# z+U5K4-%v&-v2tRrc?82*unZ@(G^cV8M;bpo^YtaZW~Q^gZXiLqs{Dg3^b-A%%Y_b) zAOSi6NeN2Au2cg{jk{zu>vce<3O2@T(fMN6axqkheG|uMI-g`fe95wwmZPCGLtV`W z4+lLVO1C5(Qbuy*T_IH1=mUtxjz?a*=Hn+4mVPg;91c)4UM)`GCzzTSOS)6bl zv9Dr0zD6xcSeHC%P-xNAqcyiKMFL>mv`e9nsuwj#e3ivP`fd@4!{`k3^{zt6ryGrc zU7g7=Se`Yq{wSjE4KfzvAski*m8=?Ok%j;E)~l<-tR~EO;B=CxtW^XMU@i*r<@d^_ zlKq$LvZ(8Uj09SRh%zyi-jGk%LH#TEltP7^E4N=TU|E8)d~gg3A%vkS1gi((3*aFk z0csxaloRw2x_TFGch70C)2?KlK`%8+SH3zF)Cfb(Ix1a6#|y}MN>@px8S-bdym7>L z)#~b-^B|R%c9xs3ITPh0W2I49j8{cF-BK0qs0~&)ZyQ6#{j$cRi{T{Lb@Tj_aC%=W zrV-D#s7!?><6`GBBR3?^EZ4P?&u!S@n?RCr=z@ zCR6!xA`xdyGwCe<_#=`2-zv+j4lLre*`J_1&&653B`UCVq+QbM_@>gw2;PWF4_9AT z+fZl_oAyh}2^p%^J)LS442UXNA#WR7vwfSr!-YNbmdniGq>zeY=B+5289I%lrYmhoVQg{4@*3(uUa)JI!70VP%9} zYQGE>`1UJnfZ66~xj=UU;hSjFeae~-X!jO%-4V^4WX@pBeEaKS*vDr4-UJ5Yn_O?3ltF0$lFN^f$}KV|CdZL9 zyo{YqHTl?)rGIv8(;6^2n=D%<^p&htkl0^*s^+FicJ(znS$=(`D?|$X({H_@t&kxP z#&F@6cqi4F2%8-T?aKmBpw-!|;N%#0_sT~PSjK^$b3lS!$8{rbr1O%G1W3Oj&K*9z zfXjZY(Jy-SMAa49`AaMO^o8#p8Gm2y2MB=m-}!P`J~cHU(ac{Rs_5=TjeSX7`{1cT zm+hx9b?(HA0`(`Q{J*={MY5}wZMG{4F=g>920s$w$q%0h1gmN%jfuLD2s?4Y*K!RR z9J2`(ENQ8f!f#p@mQ#!vC2x7=fAf2|;GGEKtijQ1>Jj{$J|nLZBWrZCtfR@J98nH% z=#v}i^Xk9pHK-Vzea6YkjZ5$9y*Usd^%bxTJklh;Ax_svW0X>x-7rds;f%LAxiEPtStS;j1c zcV=Q;#Vpy+C()|rmd*JoOrUr~gu7PoL<2{Ou#6L8JZ}*4cvvG#TH(Kyy`u}*Y4G$) zQ1UZ{^%PC{XvHOC(5G$>`@jF}VmD`{Vj7onSkfv2cIb7gYG7oW_Ds}3ZRmACE{;!M zF&7tfrNkqAOpzirLt-l<;$9v%5-n`wH52%tv%RPi#1%{z+Rl^F(6c=CxxUWD`g$_? zE9@$*!PCv)gt@~Y?ehY|&AKwxx@9?_mYEMtA|q~YLeO_X?8Dd8Q;D0ZneiNxGXGpe zm^W+Z!ejQd1RWlaoTvn0T*aPKU}w$cD3y?_7KA1sLgM$Qk@Gf@q7FGq@KKEf2RU3&ug%yS=w_oadQq-O|oN8)8yW^pFiSU>ck5E8RGef z?WQQ&PkQi+l?_v&opKRImW9H(1TYwpd3_*$?vUUn0oB1G9gZVPM@rHKvhSzK?HSmz z`e;cf2U$OPatvLREMOp%4WcY6Vq!h0rR$YN0mTWJF}cOYq#RPQ)@LHRpB>cpZ)P$- z@MA=Dp;rvGMap@!e~de)hPYg?np#?-WV2+LKS+AXyDl;67$b>@cAm)HfakMFowicN zmz8vV$xsYdIOcn1XDf5ac;{9>*KAF?;ZKIbj4tuB#RLufsDu?u{0o<$=)eSqV?5e- zQp{EJ7Db~Co5cCmQm6bzk!qmff4;JcqQ0Z!+Sz?k9H3>(fF9z%2GY=+`Tw~F=YA#* z=N}~U#7kgr~YAqil?B^@|kcIDYI};$wK|oM%ej=S%v?qlJvNTL|x~n#_r=cn_ zx*iTN=qvRcK&v+m+&uB<&}QrI&eGp*$i>mXzRtjwJkLt8{-|c$kyzcKi_U7r5g~q+ zJcFIN_g(L*>6>~4u+CM+GI4dMa9(^q6$C<0&)WaBjLe9CZ0y@63#X?yx(hijU!O!1R$s>|>ZPQ^}V$={#rMj)nTIu07zHzzz zXgj$+V#pKPyhX@*TF+fsjLK-@4FQ7HHcyU77-2yxc-Syy%swlld`|&3^ZSQNK+*LQ zzVKl!4i_l9O9Y8vT`8?L?UxP8>+u0M2CdH}mPtf@cOV0N$TYF-1#;>VVWE3@rVcaN z+KV4b4EDe9BD{Pw?JgIZGAX{DQaK0rgp;u(bW>D%J9ro25f!vPs(hnY#IMbD@?>9z zGjjGe_`G9aYvf4Ig!IOp?`yCo)NUau-S(Gue&$P)ErbS(ZL>{}9o&Wxf^~&-Rf(5i zYQjRwii@g{{f#K`TfSGwo-llCZ}BF>!h8|Byliobs=>dIwNtKN{Gp6=Yg_nx-6`~Q zK(Y`g{N;|t&W8=^&KOV2k{DYm5$_QD9iu~v8ha)euTVusZ;h<<*dqrPO;G0Bf*Nag z*tg-ItWc>&oPtL(^KD2*OE}FxYuY!LV~7ueQ7RE;FfG~)#G(F7>tr{U!8FxKK%6W+ zJV+AE=vF|zZEuJ4_i7JD(|9jgl?m9??dh@qQ#^aJgbLksOCayCWqCfuRKN(?QY3A+`pg+3w~qKDfa(1pc0SE>j?iK&MlWxjY2 zxRvf-Hp@)*H~*ciK{6Wso}@^^f-zgw9vgUPa!hXmIoq>6(F#8qC5a9#fA*{;d;#=| zcWlFI?n;zo_VskTN{>i=Q$|+To3!yXw6$xSfcRMCj_yN_7K@0OZb^T|>i4kUs@v;8 ze9~#vjrRKAv9Y}Bu6$X4Dw}^BDiRrhiF0deNGCv->67=QT37`ij_^dRr=u+pGKJd~ z2%}vC>@g*CXFwyb-1yoHEy5QYp7hguTDRp!9>#Cxd5urd1*i?K?4tTBJsokj zsF^V8%Igj3|7pT#=4G}&dH zB}?JSsaaPfRErU<3SR-}J4{;P@jn#d9(!WR;%4>oS&TV4)p-F+kr4GvGWk_qxmI(uYW-hd zSOq}oe{&e6L1sez#WWDoZ5)=My3 zSDD?e@}+*dAVTyxs4yWBUP{w4T`X z3s54itX{GJ!VwtELeUDV-O!$h-Wt?=#{*?Y2whlAKEy}LoK`UhsIbAqj*q+&MVFh2 z&F^Jl^`{mVVxMuF9ubE%gM!CiR^2_iFhhY zb8tX*{I{vH;u$Q6;$T1Gy^!~yl+ngYw$FBC)OJMD^tw)Izg@lxz%rC=%gEKM$}Wnt zE_Tiy`M#dKyS3ygn5Ud?J)e3NHISnwSz;Oz!5BJl69LL;c3yZA)~kNQZd62umYk`3 zE2woz%+WLkJyJ>X*mTUZ&9ST6n912Kr8aSLmy&VqRa8yV4%kihYHywn z$rYhP+PPHuR(5MuKxz_9Z!puwH7r`01?W{T#HSOTNQ0`_PExQ)dxagPFGgcgI%bZ< zfR1q>_QZ`Y-S8kY4HiMo$d*ARE9lF!%#tN1N33~+y3cU&l+O_x0{ScI8`cDW^Ty!VFtsw_ z2XyiT-dzkg42bXeeZG1z(z2UBY@m|^^ZZB3= zji`kpXNt}^{XpB2s3$U?o`O}?Y^_;EJN!U%Jxi0{^*P&~UD}N%F}Y;-`B%%2YooIW z(v;x==ZezjJwexA~=wyM;E4RLrquPmTGT4$plSX2aS4ivD{gynOjrE^AP&kXnv6=SW7AOcF( zYx^2Z8=BU2O%GIfSUgvuF)SlU-TY$Gnr6obG^)I*NVuODUh5gI@>6FVmWQ+`cJMuh z*#6KqCegA(m@XFO)1l4C+NtBrh$vFH1NHh|z11C!v=6hr9Ty6UsDo~{Nt8$H5~HhcYrCwbM~ z+t4yDeGN|K*Kr5MO+mt0kG|}{NDUZ}0vOj$;tjJDMS-4E?KwEF4O%;n-0E-}IGu8* z!*RX*qKZ=+z9!6Jgelcgxe7`VKZZCf0Q1oiNuOc1Q86uW@04ErsZn(!^R7DC5OUh_ z_L(BANNk;1=~&_&I~va_fr4)i<9=mIQ8KdPG@kJu(gx-IhLk>c3Ec2cwvqav`J0bO zu}rd?U4Z8K!=`r4&S#~E*Z+DULvOezj(RGwZdJzmnVCEi-P|T>xt{x5tD)oeF~ng} z=>9SJZG!W*@(p8u*b_Nf&m+k~Uwf3KAL z{numkK8C2VRvGv6%xHvRHrkahd%x3C+|Y_0r4+dH0YlUkg0~-GlFqZ-Jgb{U+EMto zXZ>b*#hp(+O;EXiAzWDIp{l7bI0E)Cku&ycFTr5q7F5wP$*%0>W>Sfm?nV&aUDSUh z$cht$ZV5}7I*a+}VpRkcQr!{AS9+`KL^@RJd-FBevBo`%uoJxy4mSB$6vHd!zpG=U z*y$MmStIuT{5}v)27qe+L#>Se<@eMfP(3~H(#0b*9Y2Qj)Rr#B-HCiYq&0f=Z{P0w zhJp`zd4_*86kMeigO}bxFQGz^WHa}#Z(7EO0T4V(8rxG)V3ED6BHibX8? zj_qfky+Sinj|%T2w1HW6I_~-zOvl{*?=%m!Ep`-|53b;KEW~|7A^qUKgqVp}W2*8zL}zlLA*qu1wG0MXE4^fKNBwc}r0 zc_;tHWzob_vq=u;qbh~ zEs^s@ji z=j~&t$yVnI9ua};0`C#@jD>+^ElUNDBlD*dBZNx;!ooQ{#R3^9<5K)L#qObHOW6Y- zRjy05>|vJBTdM$(5l1RJ!Pt^@U+~W^b0&?io;C za#dPUu?Nfyp_ZD94}oO7!DcjCz)_IZ6yE$>p;op&x)!GQ`YfJFOBUtk<>hVY*d%_r zdK9RIAPlG?icfsBU+8YHLqS^F?hwSJ*4vc^T8oFx+2C#=#XUAVYkh^y;L0C;hTjph zoNDrQ%E{ZkM(71KiEOzzK)+|H@A{sEfZcg?A=c@U>1*(wv+Z+j+}L*lXqZQbT^ViE7ZOyQ)i_p49PfvaEI6!K!7qM-c)hxNfa(qe9jU1% zi9^P24TzaLCFEq`Osi_KR4aK7e?)rLZ`DWAo52%C}nARu`s zhjpr#o~ZT{`n|b(yO|bjH7>ecVj@1*I+!}6&*YnwHX^iuPxo%M350DG#_E7Wi}Gk@jA@Z@?UefA(B`)+Wz z7v=9<=H*JWiIqeLfYS7BAmll#kuryzWhdKXMzBk8dpK(3NEw@hMhWUC=dL8D2ZRm! z!Be_)=#;y0(a8GcYKZ-JLMkDRkWR=TEFdVM0gF7xoR7|CSJV5#PJk>dauX4k65hL za~k;k6aHug0EU;FTbaCUR_h-Cn9s;&P;tfx3eYt*+I1YZf7$MNG4GX6Q89{yYB11> zbhvnc9l7W+7LZYP;qTie_aa3LJ=9X#7Z)F${`|)CR~lq*;_(sAGNqR%jdd=|Za2EF z*M8};j_{hD-I|jmxSgx`bsBSRwl_UFdO2)-X<9R$SSWOR*M^g_J$bT>y1YgxPLp09 zi*~9_+pO7ttfmZaAnBkt*uDDkXlE={2P#}hy;X(==ughidFv;(Aa zK+9z{G@Q$Q4CC~}1BJ5hbXnibwKLusD`-se{NQ{!iCF2xRp#mAxUGC-MI+{JZDth6 zk@FAs#V6A2-1r|7BY^U6`6`Wb1og(n4;virNk-ixzaxg^Y_o#>SV4LAh_BDIr?t=H z&dvgzA(T--#xT*Euxmv_FYTo$*~tJfJ9DCpnKHYhXESi-?PJP%F7@QC=>`D)GvQ6Y zvs(YObA^7sD$D?p-A&MtnmUR#-k}mVza@qd%_WSS6Zf#M5*{r8wtK!$qbJiquXyxi zztMP|%$?1I$qZZUW433SmWVd6-k)ja75c!m!om=61-aG8d6mwzbvkV?e(xgQ z%f_LLqv4FF9hIiLFRkox?+Z_Dy4BF%VJgbjy=@g2oIaR8Gw_8^yYe5G;7eJpUsy@? znv`?eVkiwBZ`3LM9d+Xzw4^;8Vd{)FTmZ>K;t5#DB#~w0W|_0rl7VDO?@!OF$3Mol zKwq-TvpgZl9fb{VIy-i>!QGzofQ=pPs_7^ZQTi<3?abpT}UV|+}F}qYH(wV=@AnD+lp7HUdP-5%>J4NRuev9Daj3@%+Zczpn zhMNAe!EJ!dRAyRQQ`En=CQHnY9v*PnpzN8b|K>!odxVklMpqDit9zN2H_xy>yp*uc znNd+OjQz)P@zSbw0fRXEWA{lra`#W&U1dkjrOs5`xyAwbADNi->$Kd@N^=W;_njJ) zbWCp(AOC2+F!()eJ=E-3yCeU6@R{Z^<-irk{#Vkr6Z+`+czBsBgBE3f}6Wf_dEkfVppSGIUa^}&p$MJ z6Qq=bQ9nzV-*(HqTt08H2NnaZ_+|z}piH;-@Oc1c%+^0Pgo=YMaZne{!4c>Cm~@WFco-eoAJ4byPYjD%Ee8%7Qro z%N@Q*wZTNM%&U?Hhbx?Idq3H^3H7ifnya68D~!@@6XIuzI4b;r%zHNa4pq9}AEUtP&lYV{ z{(e<)`j_2Cuv$c&L0?#WaHV>1osC@+;t3V^0#YuV5YC*Gfv!dNk-C)aBv(?;i)XvN zk6Il-WI1|Q`|-AP*uRw(ZuJFK_uIJs zWIo*PxF^fvopOUE7MA?gVHuIBT_51|%Y?qvTtjxnn{r;vZg|EHPRoZ^2<*qXV{La! zWVubar0QU7AMaYfwzhrQ4RYS2gpK5>GGF1LlV%9g^1TOARxB5_?&e1`eo^2oQE?VvkCC2<(|k zMgAFaa}5cWv}Tb`*R3P#5^Dt)|Gd!O__Svy=)rn$k2nJWI&=}Ut4;>UkC#)SO(H)v zoZ{-AMxAPPrE~x%Zald zNuq#*WXIVo7>ym^($t*&T})K|8cBJ4(>F;7;{?yIDP#o(+@wx_5WT=)kYsP&?>P9T zxfb?Tv6}QiHPgx?Mt?GZCn{8aZaD*piexFap=8Z*4D|Se8fFcR(@`f2&Lqd2W-@Yb z2IJ~!g_EOSdKV`MUqst{v<`_Mn`*;m1s2~vgTCWJS)fDNivSpgIq47RT>Z(c*<0xu zc|n;~MxMo>LWd>3cbb>J_X+G!vL``m>R8=ltM$L<>lcnOTr_<&V0;5!Z2Eq(< zhDZ!g%AOp}Gg=I4Ko-X9UIthM8S-q#Zka`wtF!92h3e^S#f42t0v?12V>Z*U9->Az zlanIrgD3C4>)x`s%OGWk&3@aO3e!Snu=?ZU&7HXix(gMQ6Z$usvt1mE{Mm2*q~(Hz zsxh*}a+mQMYj(sFYncyku0C$YL}MDbwDVpd+$G=ix)r|2G`h&$p98r|E5#x8;K<@t z37{xmVWL4itkBBbj}5&3k9$vZf4sQyrtM%Nr!MP^b@UqjARyX-yt?a_R-Evh`S}Ic z)%WXw%r};+*r@+o2;_=?RQzIX*Z}N-X~w+$kMX`rlmy_e?FiXuO29cF*IU*VLFND% z;S;0beUlb|d%?vm^aJ;{E>PLVH^{|3^_pFG@{;l1Za^Z;Fs=XT)sf2YKryUHj`Xy& zyjK!4qe1a-f~Z6Zrq1Q~oZ)Cwnsw;o-uNx(kcL`H!HQA%RI8#ANT&^#8^xJEHSIi8 zCZe-#{*uag6Yh70_CmegQ0n>shftJ^XKwW7YKxl>fimK2`7yN7-g?+gwk_p_iMq%J z1@YJ*l9bDk-nYJGK>m#LdC}!8{ zm4x2o#+tAvOFo}%QVjBp3GA{ipUn<2jzb(~o&N2C&a2W+g015jeGSS zMh(T7A8Z_%rZ3-Wx^G%-k%RW1T*g0}oG&MNEXJe~VpQRQ=G{||Kh;E=`Haxtpu@DQ zW{UJc^`65X<^Q4uzi;+qsrWqy7)vL(OJY=k`gM=uC)N0tZ@IZ9%PwlydfKyH=#XWhm1QuDjDK>nB(_D?d47)W6L?L;DDTCbZuA1+BMbtj z-;iuJIAkBqAmv$ax3|gxlU}vIhKKt^2{+e1li{0h;|=#E`gXnWvwh3W$h5i>h>n8On1eVqNJVDc4;)p!!h-qcddXe>@oVt@laTWR_%+_4d?S_lK=6(Y zBltMyhj>+Dmz7G<^m*UDvX=P;Sv_EDumIoC($y{hCI@&F#y@s0=;-PW*6NMelF+vS zG=2Gv7>`!FiiO;YqC2M37s#+3!B7B^^JE6@o{mj!lh@WMP}`e8x|sgDs*8zN9=B;W zkd59ikAi6>nW6*R1PnI6D9TX&#C zbff@ygmUrur--zN-2ZSd8K~m6(Jppr=f)J2P2YU!XwaK7C%oBJr3-yyh4GUpyLl<% z5|ClS0(BFOSy-lom`ljjGsD~INRRoL{>_A7;$wh8Jg8hxwBFIxAI(g*ms$0S9&vv( zQTTZeN@66AZxV13dcSSE(SOyYQd`+cFmu{kcQM0 z?~P88>=NT7v}?KB)F;Ru6sAN=I(aJX9nO8MYzfUH1kp1x@eZ-62{ZFH59Jd)u2P5t z$~^=8MnXcFUe^fWJ?U)@8IvHOZHrvx3dGt;AtTmy`1nacu1XYNbS_&lZw12Q{hF^? z$NbE6Wa7}@EJUp+(jx?*ikSkT3Sb`>(F*K>hl(t1xumXTw?Ep5-x{8qvnr?jbaiS) zy#|AN`HnQtFZxTt%AFPrrDaC~s{i0*hApvbBO1_;Y4KYCUILzI1(ewND!OdNDKjA| z5xvvSX*-?K13L0DQDp)B=9Cjl1OHo~#93d64}a3iEypcE=axTZn9Y(tJxgS9>t}S` z6Q2?3k)h5L9tH>?`1AMBH`{q}&*O7D@#1Ltkf%a+)IPEPr?0%P)w!_Pc*R@dRX-fhSZp~2&H_<} z-~j%ioyA0eU+J{%C0^vl1k)?LRyJBjETejCbZH%72UHz8zfF0ReETM!siw2})$Y== z)^GYdk*l{2BuTq>x4k)y(Up$6_9JqYJEI#3naH!L%e&j3m9T7xozvMBdW@$WK`c)z z&Sy>{j=-5tVu>a!*ozij2*~iZfF?+*M z(Lm*C;tSymol887vo#E%<<~>X>0-z;=k9&G9|5!Dv9A7Zuc>d%Es2Q$ufj zcVvm7ZC1bhL%Y4w$Bhi6+yBN(y+YMN9Acl`qQo;Z?UNa- zqvH(t3QllLD#HEo)JKc)l&gZCV>^m|tfkfDs%`Wu_(t86sM|h8G_BAoq91%Q?UpIb9qQAuBn;v(oKOj%G#Q-Qn z-S7`29W8j2u)G&P^+vCeU(Ps0bnwlQ+R!;8wDTmmn_oiI-3V*+N zRRhaKk{W7G{B;`SAYy<59w4lGB+XjOTuNTG831RQ_|f(CCx{q{I^GIZc|xNNg_?ht zP06LNvM|53dcV)c;`z>zKA6o<2}|3lsVPM!qt}Z(^!}O_9=>TfhM%$hx6ga{x(kg% zp458w3q>0?)6udea$r)t2avDgmTr1aoy{H?V}e!u=I`E2Vvt`ga{$#jsHtDX#EzhM^Ob*w zIrQty{HxRLrB&dvbXE^<1g-2~6-s-tVsS?-+ye`Y3?Plm_QRaRP?pOVOicg+113qW zaEo@&@`2Ia%oa?v=F^+$O~*_lPy2#Kn!*OsM#2WX%)b(@|B(Lw?Q>GbI}x}?9sPJ<9-%Z)Dp4kmkko`nwJt^^#%JfG{!JqRFaH< zJxvf$YFu@{4rtCq9BsZcU)X!+A>V6ogxQc$IO`oe&@&Fq>(3hczw`f^YOf@PYQK~K zJ#95LwY%>+6U?cS7Lv?qDa27G12M~wY@qJn+1i4y!FPCj6$Ald90j6Gbk}YbgIXVWdYnK$hJ8w z=K~9d8So9#qqu+bqUXlNQqW|;1Z?gR$%<_LvOYi&dz%%&& zim(Ah7J4BS^*qVYbloDCr6=%9ixxR=p7Fu5-DQ`O&-04P9so@g(&xX5W?2nHy^ z?8T5Joo$fOR>^e=8$mvG`~`}BRyNb~n}BYodz3_Wmz9-O#k2jhy$XO$y`)n{4j^&y zxLAlQLoKRr^k%Jg-Z%v6*c&8jJ^WNYRqR0=pG$hRrhdkm_ERAT905bM*cpFTZU$ z0tFT^m5J8o%5+%d`$MY0V=c;YIzZKwYD$;SVGSg+rx*`?w0ko*=ldVsrH0VC!Q+TI zN2$KNBkXJP#V#W71HStwf!_R^(-0pK1k8yh|21Ww2k-S&v;AVX@eg#_rTu&}O#?h= zQSq4Zexqn!c3LSniOU`RM{5PXAAAWy{MP~TXGl&TY3TM(xa7%Kw;z!Oo~#gzmb0j1 z>mY#-6hjRK#~o(hKb*!)tAF9~&pOa?i=Bghep2odLHzgUs65(U=vr{-4?}(ZylLT+ zJ`s5>txsR>CBY3UTylZX3jjZ|+^cIht@1M;j4=;RZYcnK^dj|WNk9z)L!G|C2~_a~ z#3`{%7md@ii@X70EV-9^^|@m_#z6+i1*mOZ%ivx$?uA@}cwZJg%B`Jg^vOK~OJAAw z-#4>i$pI`yz0;fn-%X8jZ*66c8HLS}S-iT$or&8UDA)W$1UCo)DDA2vfi-_CKiX9` zwKA>e26+OMnQTQny_30dZA1TXff%)_f7>&R)-%?FvIDmCL}FH`E)<)Z;yozzUb${p zh&GsQ2fIY!_FS265r=vDZ=fVc2zv`1WSi5#Q+8vQ8VaD62aQlGlV8><-+MZ5Xo0|? ziJ^0r%|ausQ34rHrjHF;3@r0ZZh(D{!(jY7m)(D62A1KoC^0XPMZ&9Z?u>_{IaE<6 z28V_UrnpOKytY=A!geVSx?7#4@%-L2m)Oz<)^@3euC?a2O+67#gb@uO^>Tbk-=2!f0rI9leKwMm~k$LYD-fYpp}&+%84c61YQR z()rUZ`Dwn4a7OW^D)ui6C_@k;;Ep?!1YV@0OD*;cO2a(8%-LuEw)?}4W#$@C7GFz(|MSUMbAx%}(eS>Loo0TU9UQxmL|_8aW+r5Q0Vwy=9<-ewbdPi7@k_Yj8x z7%ZOG;1LS|pw{)~*Va_57h-smcC>o#r&H=QrtC zn1&FXqBNX-0?;?%4sm6H;_-^fcejtL(i};MeD$BRri`6f>!;K#euE z`P7+FhvuCs5pPeNL|vFD`*XxN#1XplK;ZqR@>uB;dg0bP?q@3Hy+aJC+|xeAfV9iy zAYbr%&x+*lA8t{F>1p0kXS=?k-&e71;{>+)P-Tm?*)%F^{!We>%-d+%Z|2Lg0wo3& z=mWluoY3+~MAtZ(rNd#?#B@r$F6iw+&$MyT)N5VRch6m zHw#bds69rtD%lbM-^q1{@ZDF2zE$1m+GyfWXBB);&wu%F_>&?J%j4q^$%q=Zlks0R z3@1te>DPzC^JG5ZK_EmV^yN4w=4d_tk&aa!ip?>V-en&4;p@~^W$W9Q(B?{I?-YVD&7etG&h}#WHKe7WVP_E#z2pQ;SYmsj`wOPC22}}`V_F$m^*|sJ zGsU5t}fZZ^tk zYzGEd%+zrtfTzeq*_Te};AVV)qNSw(YZ-tE{`ao|)^&)Ccl(#!03gm1ON+I%d?xgx z$*&IlA~`K(ga2SDFG=tL;Xe3>#0U8YP3d@9XeBHJdD3qx^9H%^o6t0*7&V6_WWq(o z%o;0w5AGqi5=;pPuL>PF9365)i@v&`{Wj4IS_1fV!IO1g4z6TTXV}bk`0V}>UKZC# zP8ou@KH)WlM#({EU?1qlAww^W8XoN*NYlk1rB`VDUMnO|&N@W2(0xgPo&0!vjAtOF z)_@g}9P>t5?K!tefmLr4E7)khm8B0E{LC$I|8zN-PZLR<75-oCUKoII{gTIYT*kWRej{jF;XZ%hxi-ol3VgQ@FuIpA z4?9Zf%2ven9t@NPpW%kW?SnaR+F$qciP<2!>U}jk8VltvC#mT=&w~dmXHgossWm|N zM_d2T9o4idfk^@hJ8_vY)AXPXb7D4|dDj$&6zUGH{z)@k=g7p&XuL1AVgER}<`7_L z_dn5CT)K!cyEV$_Z*cHcP#SKTr`H9{Cw9VSu1W?P_VnPVR%n-9J;!T)Gv0%*;z|~I z(uL|3;{U6U@8@6`v%#sy_f8x=7>W-5++IkXP<_MWgHyFW6uK+htew4LtULrgzyGxy zV;I1f+5bfII|-W83mFiq=O}WE1OJWF?)_*vuw2`&ISW0QvzM%w7I`Mwh4jVj_^y^7 zJU-ADM#MxlpR@l9@mye*X$T2*sD{YZWi=k$A6D|_2=Rsr zb}o?`T;TSf%fi!hM*KVfgO|RPUlt11e3~>JRG+W{}e>$wBffbb2 zDByCY(g@r6N5Ayn&cwnJmT0{O%$3MkUq36u+j$n6CRHr)3)Zqj+<$r?2MNlXV%*b8 zw(v+xXB+fR^92NqEIj|E10H5h=eJLE2gyU3KXKnEI)WXPeHfFPNk9;j{eS?PPWg;tY?I|Pgp|Ik@B0#~?{_l-h zD>veyaK{h{z2r4Fg8rsuvi_iGvkN5hRNY<-uW?|6J=2K69LO0tbwZu7aI&;%cGXU* z^y0u{Dbj?&0s-mi)GoWTZQwMuM#ci+Nc=S86u_8X_(6F#^nbPY-a$?F-@a&k?TR86 zP!M<(5fLGv6a@hl1tk;#>5xzaq>BWQmRJxAO{D~>3IwDJgiw>9Qj``+AV2~Ega9FQ zLP;pO59+)3zH|2MduN|>@0m06%lHRl+LQ0|E$dU(TCL4L2R`zGT#J%PfrDgSHSQZr zpp#2TjP>ZVHT3)R2bHS$}@-3e4NxoloO* z^MzccWno@btXm=~LuzV3=~$1#F7wiXjYQ{wzSTHco62wPB7qqFWQ`U&!n<~TnWHj= zImC}qGPC|1?DTMt5Pc(;nw(EAc5$z9=Xp^IGvfCucs+E%N72h7k`0^qbvN*~Qp*?O z#hp8QHuKGx3nI{_r13UShc82(-Z)NapJ zQt7TM3+T@?Wun6j`l?tNOJ@Mc0>9+5^2UGsb1;n{S54i)F9y{A5gu2MwFIcuf{fbK z-^Gltw3OZD!t;-5(i2)!T%G!>Ef=Fytn!bTeNxs6f9PI+$2O#-oc;jf-cKMTdv|3% zPxb}`!i-sTUJrSr~sm*q~!nem^pP?4d@)@FFYcvv^u%qJsEB-UiL;}e_6bl z1!cSZ*c$6JEi^~g1IYNEr8LJ_z3t=Oa-s}HP(z|45EP@TGA4N^dzu?- zH5K)fAGHE}`*nA!&#~Q?fQ-Ao;UZhrD&(@?45hd2d4J<(MT56S%av(|PxY!YPvP;k zwW+D_Nj$|np3xt2)IM6tN0U`tlO&3S&l7-i@Se0FJut^qbLG7XDs_K(vFD!t*8;!e zqkO;vU|IR8alPQ}0C@P0@A6f4K%a|3dYGD7Lqi`3yF zrhof_?_`Ho4b8e~89vn3*lpmq<)#I-z4jTzBiZyz=z`^m4j@A)SH+2}-?M%TI`do| z^yA$ zjPhI!tX9e4b3~AG4{f(hYso~!vpp#G+Q$fkl6PgJ)xd`QUeY~ysbEpi+M=mu+U@Ny0duE_qD~upRszi+<3rV~ozRTQ)a52?#*=EzI9M?25FnXS z%%CMP$=%{((Nk|&Z@6iN{mhMNq zZ;>+=?tF{Yzr77~1@h_Pmu>m;R$n_qW*?hI2{XhpnQrl(=`2jp%d{ldbYHFj)@w?f zC`@yD=M>blyJ^ysH$YFVV;aSCern>f)OJ5ExOQdtRpo|9ub!0VKLY;PzBB5{&%fk& zZ{#Ju*=ToEeY+11Q#(8O5Vxn`&%w-QbcObZZp(xb9Wof2|8xeSz8eucKC`s&;-dRUcWuxx%SL8|9dQj4GWTtDkyHTLT?uh1IKq; z^l`l#XX|5;hCtej8D)bL=iB{mB46d?(N_W&hnu|3s3hO`Wh?2W0nM>D@WGm3Gw^;; z_|wsxbb~0BffTC*S%`j&W}2`IQEtlqQ=V1Vk00$Kua{u2*nYF;kMEgYetsGLkq@jY zV*|@o**>57aXnHd!P#Dyu`6lX2JA*?MPTfoPS>c<_3oM8s@1Cr?f)b!EAllv*`ue^ z2_*l6(J<~9rvtUym?m6~^Qvah+3|-os@uQCe0+Rx2;y{t8(k#eXG;I{HIAKo?70(d zUiCcV*=q2~Yj0}9^*D+S>K$}k_!`ydK6298F(@@#WiVGvP6j@}nJ=v0}w+_T1} zvAl`dg83doFn`;ny>Pi`*_mbSZD1pB#-vv8Qi~U3SK?89<4YN*zPyPZ`P{-5ouH*w z7pTWSTO#xZ@dvbfTew&YWSXCx1^O>&0Tp9rc>B{;X6D#*Eln(a996r{jIM#`_z#5l z@!yU-WT>j-iJQV;{ps6<5|%(WUKpLpNkMD`UA?YD6`@ zdHI7u74afcX!(qT_K+8$3JeQG2s`EyH`MiK*!0dF3SB)41dF88p2lSRAD>Q0+&sDM z(!J-^{!im8r(Y66CD&PqWcblqw(}|d07g2&!LNz9l4dfoO}_4RxkJ}PXHQQJ)lvYz zHu4shRm`KkWF4XMD96UK190xem>H;gvg)accTsubMiX1=ejK!Ki~+BEa@mnzDbn}E zPi9rJU$M&GfEk&ZCnI|v%x7U|Gt1mZBw%NCUf+RcsyWoRwjry*p-f-#_E(H$ZI@+g z+R@#IBY(;&X>+W5mRk77cOJ^b8q|MgDkf%L^LY?2&fDiWxayF_)g68v&q zZE_G-HWh(7ryp~fvhJSc#a4t+KT-W565FE(eK z`(T@1@YK2x`1@bk(UbDI%KbiH>e)Y9y9>Ibrx~GJ_ym>I3udq-`!K6p9%WpwJox$B zE!c)d&DHv>-Hznu_H(EpPl#W&7yWo(et2cAR4|Y<1cSRopnN-UO!}?bHMqS)! zhVed339GCP5qlq?QY%^#6!v77<>G$URz9yp&39a2INWKiAaY!3E|{GB;3zxhe7@JY zMM~vbUN38{*0x^;#3xif6ATmfqX*`?wI(Y5{Fq7lxIhGN?kQ%&XrzAPCrj7DP)?@< zI3YCO)q!PrI-po5hXV4V=#X;OD8#(ntutiH&0I8EQr4P2r((X^oY(=#MF8};cck3rq(yG8UpFaXNvS=p z-%P~$)9nTH(^;qYkwM2Mwl8ma4=(ib(3LSSlw0J|zi|azuNO*h;>y1j+up?rd)F#h z`)4oJZ8hnSJcgzZ{vDFL3$}xL#}WAWGiF*^j#lN>C7mp_MF}?(TU*cEoxnT!oW7ta ztFL(AYpBP9wIEPK1n89Zdp3II@-Nv-KIP6{Taewum$`(DrRMO%Lo?Bph>+;qM&PZF zhMb4-ep^60vd#o)=Hcb`>Nx0rqEeIY*y8MA-(D;XtNP$7y=-7Un{XN!TDN=mEQ$v= zu6+2zhVaR`;g9~w;k|uk=r>7mme}Rw{M>;dT~omb+xLJiV7-7r2EMj zDtq%b0H@yU4-)CES+SR{eqqasT18dh@P^fGTif{cb*PDHymsWrTc(AO%>{Fh*|Izp z`c8r!_67`+=esdp>R_s6g$*$UR+AK z&w4t2GG0L6{kt=yY9X!4btprKyi$^!txm`K3aba*#fH)y7s#7OT0Lb42zMkCvcqB4 z*z4~43&jj<_GU+eM<0rPmL@S1DZaY6ayS2er7mL1yF6@92F7Xj_QH)7Qnuz-6bfdU zfM3qcC$G5(IzMiiXULGV9kp|aVyhdNVSno!UwlBIRqb6@s{67n$$saIt?FJ-HFzSg zo(+Ba8Q3lNY?j}Z_b9l`8R@LOXC#OjaTtMYJ6}?`^!}-?^-lgFh^|sjsy|glm$lWP z_8~L>WNi!7_MLM7-L{*tc>nJ}lL*7hi#A-+m_~~8aV2%!RH64xWbg=+UH3F5J7B8J zn(DJ!XkyCZ)hvb6$`+4q-wIMMDYf@An?%Up^IESN7~kBl!}Ag(A;x6&<*e+TV&ASM zG$woLb`^(u=B1rSFN)8~bYrO0PB_g|x~nrQ0Sz%(s120H&3;L8-0>J6GC)UNx@LHk zV@!fcz>kHA=R?3E9i-X=}X8 zn-=b{)ZGixVN20WJqBlS(XUtNuYiZ^tWrb(d<-h1Io3wT8cH2s z{_G(N3eWEnvW*iis?6wQz3V6(+aK=wEiyy^k6{+iIx(8V+404x$v|=CN9?q9x*70; zf!^q`?Hkih%q*~Is(+6c%Y&9XQ2qB5W<3tx+VTAEdRpb$<0H&ul|qhF#d(XD@~6L` z%J};gqeM(o6N>H`G_5(f2CcFtNUl30zeiUseh!2*Ymn?GS0l85fD<~0MC_+Iuv7=sa?1l6@Ns=ESsmsdcuB2M4s1Ax%dI&R|r8O z{HV?oED}C=%7{t|{rB_p>DPW_;2HIb`##*d535e=I_VFU*T;XllU$-NWyp5E0P>(} z#<1JvI*ev?>c^md zZQ|X#pH!1lgK>`-O`r^5VxrYG&6Zu^$&O&TG!N{`m;8ndI zZ(22WP%^}!!id?`CF|P$g^p|8`hG@n@xl01Mf*h+QU8$=*N%kfaAJObE=J@6QJf8;YtrutN`C!O09@_iZXYksu6SF2x?Gfq+UeDkb^(54 z(BZKbB&CwDbqN`~2qJ%Pa_bq2AT_B;6$J`yafM%W*3EWcl$E9da~N16M{*5H8_GDmU3>%XoXSZ zWOE`fOdYMe(w*#$y#8o@V*UuTmbXkGCn_YzVT%MZ?We0(@$inAc71D)k_|9|P&q+` zQvnnu`d~I#*E-1SZ-nD^kaB0)Ug%93b0tih*MXCXFX~QezlQ5X23fAGy=7;s;PDuz z`!CZkTuV#ab^|1TTv3yK+X_fV?hPX2ma|REoSj&F&@ZShe}~(o2ayz9tyo~95zQ$= z_H-6FWb~DHG?7h zGh2BpR>GnK1-S=IA_3z&?`G_2tY*jx#)Lr1&uwg898KF|_x|PkkzCH+GA6Ec15%vz zX3@_wAM+lNpyq`mta)um(;m*xFjo!{o2i=V54@Ba-Qh3n&S(!tiW4kdAw*NUJGuwS zOVJ@co}qslI0Ujg5~ z9nXU^f$EDYd6E-fE-Nl5Sw_53ShbmTFe?y$^tiCPIuE1#kgqW~{PReCtbx9@w_0Ig zYQX1bX!R=s4u_kibJElGi~YX%h@bPEvIx$ER#!Lf+)pE55=iXj+RUa!s|QAkJOJ;T zSZZZUsn&ESnoVgM@~#OUyrICm;9~o>oiIB=f&wQnWQ7zn{|o1XJuSOM1Bh*IxO#=5 zj;1tMRulW~jaLU4pCVw**}@TAd0Txc!Kl&J6P z+<=Pg^!0fHc@qQ|KSibWxo=YtsJq^lv@iAaK9Jq}1?Noh8{-GbFGGa#oi8~HfMe~1cZWgZ=V~$&Po2^RL$H9z>(V12|hvp{nVGZPrqUIY3zmrqKAzuTa<)Azb6P#^pfP| z`U4Gp$T7COZ+YyWuMz;hPCFi3FcUXGC~ZINh|>IYGJh|*_Y#PYPeL26k>vBYaO5%{ zNQE8}V78EzDKDXo0KylQ{s-QIiDA}W1WkQ<$Tixf{wnR`NJcU5hkumF#_X6!RZBa~ zsU?w;|8LN`5*qwJgRK9-1`3RuzP`S>6+W^L5w6q+3qELiqtFmUPh3#KvifW`k8_Bq z``mCqO@wnWLr^aT+2{)3*t8cJoDU&kkZsZfwXT-A6_k$xrmhtGWb`_=u;mHrnE5$< zNJ24(&=l6uHAQ*0#m>&EB=3Zh@>1JCu1Fphs~VM8EVBr*n|*O$=y6p{e2}|m+2TmS zj}Se6!R8p22dpX$DgjsAXR+<z&{qSJ8!25yPADrkek!@qgmxS#%cR3o6m- zoOUG~2p0YU>Q)MyKppTQfx>eV5)v>TflUK8qBk2Oi&W}?0uPT)cr(6B<&55lD78ah zh$?dlkjXlB6X<1%QVtcxgRa1=`+MSkN6>l_ES)TYa&1#U$CPISQS^U?!GVwmaP$Hj zx1wUUX}$;q_ZW@1?g?~{mt6#c$VZ=be;?97h=09O0EnoQDy4oy?>j)TDZ9j%uDFaM z-NV=!Zy$aiaJKNKq#b?`0K$A)Mn4dDLTv>@>fx~Ra;4nrpkbk^7~%`!D`F(T``otK z6jF}@bc3|f!f$h?a!L|d`yW)T`!7`e-z66EgoTBzHo=9TpP!qTm)xRq(RLzOw(z$; zFXGVB))Y@sF2c781YKJ(21sm}`foK?{>~y}b?LD%KJib!@V`(s=zo3)ZkuXpX+c~= z>vD^V4BFb-fUT{3kb`#JybVDh`O6p#CaWty+1P}2exu#fi#`k63oO4-t|)!WGo+p#m4Q7ki7$x}#Le|~bU#34$wzLk`}lk_R8XjlhKk1K znzVqCQ!aTHI(1}_%y!5q!E|?AYeT8cyh|383sQ&@uW_Xgd#dGhWa>E6#(=0pKS^F$ z-@_C1!&+fJi|_>X;PI76}o z{RlPkmCW7{J_Brv<}Ws{+N$vX9o~*(=KqEJUxc@JA>RXdd$s>J-p>3#3U4E(BFg67 zBZZCPokg*hQ~)#;@L%B_yO#-lpiQ{KQ=Q8)A;+7;Vhg>?yGGz`#c~j9kBy`Paj}ru z59|*yi1URO9bA5b8BC2+&we%>(?WT?RA^ChHZ^$T2$Sg|n1IbxRNUy5X}-kuLQM^%$RL};k((z7~ID6hI;K(xQA zyO|icfSDriZVHvR-uYI$Wmc%wmu;oK^MIUiiEOEDY?)l?kz|Dm@X(a39Og zjK2Ig&V^7BkIiO}4->P>2*H-BOo&d7D3+#5^Pi}YyQ$`>DJkm9K4D3al0(|v;Ew9M zBYF$@%uYtW92}X{QqdpQHqAlHB#}48Ci+HoHJrM|vDDEkwDg3mb=TYdfm)%InBkdF z>UL6s(+zTB^dDi-U#$ciU!sQEQX0*q`~2r!5Mp^RB7>{FX`PZH)4CcZ$sxd1!vZ z*YM_V2YC(sWIxtgZsEpg%z1D-8oINH$9=C`e-!xKk{Uqt+-sA;o2|_unH#L!6IRN@ zTX%ok8l}FRNb&F6r?P%&s-oMv)E%x9GHn(sRlZ$vuFBkYp~>&b`yzl;@vd6mz_60B z#G-f4mAw00-DsCK2|M)_J1b6Cad!1^G^BiW(u7LI$GfDx%R8;FE1|hu6{kEpxxP~U+a5_Dh*9ZR`K-3GM2kBRjKQANkR*S{60UCg~v_9n> zlMPOv&Gi`YM*10X+seChH2Zx5u*FhAzOX9_{=H6Pn=HflQ=KexXrQF}T)hcewvaFr zx|ZpARWI_zrO`t=IRm!K1D4|VVB3x{K3v;&z$8b{02#XSvOs)DYn!aN>Q7tGElnH2 z1SQ$fK+B-n4@yX)zhBgSj%iu& zXF0sFzlS%~n|^i865w3SZ1))DHAd^3#!3ayCs-z3CzK6T=({KyxU!Lp#h#X_M0PlJJ7q#M7Vm76su0yI`5|353ifPH zFlj8;%yVJ_6)97C-7aK8L0oHZ{JO$=-8!lM<)P9=X%lENc3@+%H|QbDI^dSt6vh@S z)-+iSS9g$U@kgkZ)z)UVe*L(so8X&XPHRY zuG1$!y%0FnFOUfp)F7O~F{1$6mzdtqvB=jsQdb2)d^_9t)#J=k{mig*_zcdqtH82X z^(kuB3H0JbcTO5yFNwl!@$K~Qb%@x_+?&p^GxN}#1ndo*R`7-ih1)!-BSOm3ccW^5 zy;HpcmLYA>C;=DIJpi)2NsG)kUD$31>iKbPbzzLBZmqq`L^@l_n!WZDvtU3&Vc`Se zbfMcR;xM>t5KrKFfyn~eM$csF$Jd2Yf%8|EZ~U@1^s=FrJhVCK8@IZ^)au987309D zhC-e*pW$WBGJNf&i_=uP;U$&id3UYr@`okvH^vMUV9;Y{XUdHy3CMN{W?ew>yXM0n zi+m0<$7HxoB`3kD#h5${3YDSwi;#+Yx><)mhzI@Gr8nSn$M`6a=*uYjnPDP+N6@vR{1!$2vlesdH*d9Y~wv zjq)}$nG-N5$^PQj0&sR$^7Yy6mtU4bAHqm!FKU?%Q5*mH;e7bFkw@O%d4`NNPIz8h&E^0Uv zXJVkKnG9ux4Qp6XsU=I=IJjRF|8XqRRTj;h3Dk*Um5*fH+vv8^%qb1mGlcsWD-=>U zO%TBEb=dfAg0Lqd0g20BcvOw)bhIA!8AKAox6O~WO|g!9vi0Js znx>&R?^|#;|H(%0gsza6X1O73h)ef9!%oAOgPApcjzHR4e<9O_ur>oHp)@J1L9`U$ z@X*^PZ&c>2m2+z==;(Ou;<%1Lbv@kSfABac|CF@S(-fo$fi0870t?0l?oHf`e8?Gd zhZ>LrKB|}=tvhhXq^nHev_fG|7l9L+Tj9K?bigJ!6yUg%q^~)^jvsOeYycPCq8Uta z*oo>mFz|IJZ&ywvX*%0$2b)A~2BgX73qrzl^;%4!R|xs;*Go3uNU*4Ggs{YUnTc}v z9e1?4KvI3{o`H*uN~qyh3IN(cI1|P|i`J7_bo%wggG+s(R#*>WJq6avpNtQ{;_rT! z={^4?>DHk}wAzfc7q^5ZZW#}?>3FMALiedWaA(GTpRGkm$mOo5b@?ZRu31|(DqE|;7|~R{|&4dG}JH9 zY#Cjav37-pgs2Rpc3ozkX?^iVTmjduU(uJm{q#@*ZN@NGCH;rLK|ql}6O6;s#Mlmp zYpz|go-SSR?>(QrIN{+|d=b2<#uas^9X+xz<%^&$9h+iKKqV}y*YW^fKuM0^wxp`* zZN9t*^!3KnDjlVr8UW1`zHmC~;>1VAiM55zE$ZK}r=AX}ug_+}(`IiPED75eVv}1j zcVp!s`FX?5grZkWBw&aj4FP5mZG70r?T5zFeW-IlIF@k_aYlUT;MHaoZ!5EjLM6BB zrb&K1&aMPcR_UNq;}Hn?EyI0dM!_DwsOo3$z;4})(8NN0^sks8vix$1v3H>-HZA2yQVjF+HI7f}luS3-JuEtVOT-mM-$LF{DD9|n3~{O?{2;(zdB9Hl3X zw%&?Tp(o0!nFJ3$&h=`HH1@uK0KW@H1sI*9`vLNRoYxhFh7RP58zlo(Yar?Xys|rU z@K?098^pI&Af*0V?8bp~nTMN@?O_Q3GoD9K5xqn`OK-_-~Y+ZQICe2zNmA^?T9Kgo` zDuOxxCVdB>c2j_A2z?(8&KCXsAOEshfc}3Og7@kF(d+P^J7*Lz-oGIu^S7&f{~9VjczB+fDz<_YMT_3;Ts-n(a(gQX#yW(+g2gIU&u*PN>Cr!Z4y zY|{jRLwkuDih}oi4o0>A%B8aVWF0vFJvXo;b(nnXjua)Fon1DE^YQ~N4%-oe&# zFyti@T^IiWA9F0!5ut_Eu!s9qvonWDopVZ-ntj~6TB>su#aT!4yD#t8Aq3u0oqD9u z8&s6PIlK8>WXuY5|DCg!4h?Fw?`_?|K&;nTlNVL!@r5z{9(t;bdc&-_VdXY%Kduo1 z;k{{3@w>I;g#STTNX@-Wz~kjMy_^PWwHtnx1hs14;`sE1QCC{U25tFiuPTVoTo#g$ zm0jg)(n3P#y7g6d-rwNl1~tds2_FA|mY`THPI>1fg)ZGvs+oMdm@lRGjr$bHtOQIZ z0PUZhoy~4QUQ!c9%xW-yFoOZMLUF7(2EBlQ`z=9MI;E|hx3uOCa1p8@T)Mj0`oYi5 zrKYmX*D9SZ>)!pCbN#CxTARi_*S?QRPRPw@13pR9c3RJYy6IjhvPi;X#>Ux(-eU1n zGwn*ikc5~0KTrXXkSaO;|J6*u@k0D~M(6#8BWNfw(n~!+UQg?Eh%2s`^cGPXT#!5FT(*F|cD-e=`^S zH692cqmEmmpqh*2%%b4Y>?1PW8}orIQC{o>OFPT6qW3JTNhK3%Gp`%8Dd%m3ClcWL zgBP4^noPnQ#!>CrhgrYwm<6jBz)$h{!leB)Ph0BSQoECXMI60K5>=RR8d`vmlIbg4 zQo?6RMEkD5po53E)ZM;=WW**)9!nCgX@SK!Kx|f(8JfolQ+}2DvRtsR=E3E;+6StbOT)jg=&Hmjpz_eYROqd$* zsVOYGw%QRS^NKMO(!SSH4KRW)zGCVO&SPiJwJ)WIvP$8Mrlw+{0=VC1bsowK*K)a) zx#ChISq3?{+@)pzmsd+g0$bVv|1?UzTfa*pq$y@A( z?RX(wOgp*BFf!dQXugZ)b-uB@`o-pO&%h4>un^**N6@`d_fo-fo29D31`oFqn@%{V z1tsOQYDo3gQytWSlxA=l)zv#dTf1LMr6yqk5N@qD^JFh1_|ece-s!r=Bbm1iF0HS5 z1Qi~NUbS;|Z~7lB`07P~0Y8HSb6-zs7Cx`u(Ti)uxbXtHLZz?IyyED9d^et%qn7>x z@!5VsA(VkbgIUo%kftwd)+>jftOnrcuXF;lurq4QM%7iOpB# zGyipYEd^!7o5ahPDhfk~R)%%|xx7R6jlBb$K<k3c=_yGGVGU+QAtJ z-EHFig(9)PXkpD&1~MlWzg7a>30AM_-sN!e(aR(y5mkL*P~8=&5T;Jx_ygcbKw%45 z(KTbv<8RgcD4CU(&8IL4liY|G}85&v+I{2*TF2e8(S z3Xp)g2ulT@$XWZHf&>g|O3K>M`%AU4J73TH=YV{4eH3siC!PWuy_W2%x6 zN>AAm4JQ7vK?YR`H)yv525uCD*H+5Ug6miJ;urPB4f zXkInW>i`k9s7$-dk}=e{Wa?RR!++AQ18@be{&5Q;BsrSM1ttg(42VrV1B<@gl;2HPNRNN1n+53&DTIE7lF7=K!!-fF)62ISM&5mS z1wG`V3CVB2ltUK0s^2D86W4zCll(k7t61l^FfAYQpX%s#BN4Oce_E|6r_45B9DjW| z=wJ8+^dIqyu`%E!PD--Y-NckYc4W>^_261>@#!$Iq#t1WcXTxQW%~z#*mr7OVwR`A zrp}^IiG?4L5%~}AsM!pnoIAf&Pkxa9Q%-_yQwxh(o}9ynRvyB~)gOl^d!0YO?%4Z^ zB>4M_YJRQ<)w-Ai9^cYvg+l%ttvVGU4SD}OD>DaGrx!N``-g#9$0~Zq*)itkd+dI{ zQ{-o8P_22)GONG{NG+4Mi%7X+Zk{tbI%-@zb6vH99naXL&plP0@8l_5|J`tcBA^6y z$SZl6Bla0mYck`$9xnk0F8QTXaZxL&`po{rvSP=t{+8Uonr@R5z+Sgszh^Xz*31z; zglB)bvZCeg75e(;7B`Fj#Pac9aeUqV!wqbfcBixK4q0}9Pv{T?%-5-@`0EmPgeM5X z62;q<6?H!5Q7;a?dGfoB4xg$(^)6`7Z4uV7KPXy$kYOM3<5`Ahq)W@68Omp&9(WAe z8R5GlCJn#>@0>lfvQPbKwkZF%-;r!M2Da-$2psqcN8ZDw?3ntyB!1&`ySnjn7jyf% z=_OB4Lujc>*Md*zGO+DW64Jut|CsT2ghfAzou3jd918<~g}fPp=&%^oB6WY-l#K@f z%?>z0!L)1UV;23ENMR%iU&WhS%K zCYaXzCl_E`CH2|ql5;R3YRZ#;^F=JL=-Xlm7@?vO*_i-rtLak9zVTT}uRi3dUxTG( z!dM+>p3MsBg~|+|ic#QW-0EbBxSeNG3o9yQVPM$k+108o;`nX655Hc|CmTN#+VV8E zd9lcfQ^E;CFJTob{o+nwgkFr$94xEosR`mRJn%J7e`g{*(lF=HRk(rqlR6Q(=;qZj z7ovem-|PT$4u}wn@zv+lc2>K`>NjRhVQF&u=^J$TkE=VK0%USr>zU-@f^Wt-1Z_KBZFpX-@!naY-nA;(m>lL2MoRfhGMKo{z!$ zP>nhMdY<>`tT$jv+B6DD(>Z3%1KZ89m*Fq{iR#ODKes#)O4toObLX<*oK3^R5u)W~ zZ_a`_M{l&yn1S6QiXZutOY;=Q*gMgT>Se6gHtd}RkP*Bckak|6x}sQwmGFH>h^Cie zb14lA6zHsRF3xlF?u=;O;bZ+`i|p=w4Dm@_c02~gF9?Fac-PJ~Vmx~HNI-i&{^YzG zJC{~*`O$alG}E{WT?TE3X@>o_Ukk79htWo9^%~kAGmoN|xeO#8>3$w1n^yLaKJW5r z?Om&NRWritb{Lq$|0mVtsMY*3LA3ngmW>rakO}XaB8-HeZ15E%T8nKkWRYT8oQ2*u zS5;6b=4Qqd5SgUU3w6bV3l|U@*J{?#0eU=42q!-`w`y3Z|90HN_Md?*TT%0alHUCa z_ufX%i|c}Zt#kQGQ+%u3%$O{4ZeOS@Kksb%_u6=wnK9PWvkhl>A5K0}*!Qa#NZlZc zuv+L5dITRiury^UqJ(H635M6qj}_N4R?k+L3d6N-HX{>v*+oA2HB3i)RD-UHHZL<~ zkS*4=paF}M_j{LNNR&TslD|}tbwG75wEAEZU-QUG-qi)(I8fIx>~|K<(>ojO6(P7a z#&Y9<&ya0$VD&(6E}3^p(OUcI54x{KZ?oyrPEQA;jjajKb*Aui?Duz}VeLUK%2khgED}ms{{Jug&`Qb~<*bZ10_=(94b%)@%~T)krodVW({T zW`YR_5HvUtR*1T|(Q9yWQc~$+V`HO-dP1Pu1dy5cq#?g_;nji9x_Sy`V%Yj|)N4a^ zfV1z;t}+KY&CYy#^ISX;i|8NmSbygtlnt{Dc1%)r%7=eruancKS0F*N#W8nR{R7F- zt^+uFLQ&070$}4tX2pce4l26c4aI_6->HGu=M0xIsn!58^7QubXl{R3-R4d%BKtRE zpB<4gN5i0@q5g&KBg-h5ra5AuD%l%XGldu^pPB9*lSTNrR&$ejU7c8C&(=1saftKd zj)qR%M4}9@*2huBb}aF;UCXUS=6nm-P(nNM+m7H)3dLDge9h?6u2bpn@-L%4UYf#? z@kUnS#y>SFwZCrS4`lNIIe}2qW*vCIgnlyYr9UIyJlm-yS=YShev}LfJ+*%&ByIP?MEo;#^0phvBw;)&#E>crO@%@~WDmPa;$Rs|~H<>ypW4 zx=Yp=?4bh7yM$AZX`@)9b;OaXg*IKU>FmZS@>gW=Wm})FMc2-cT+D(9))0IS`c_nA ztZ}gjH$4hC(tXPwNqj-IDbo~J3wDa7pi&79?x(W}>hstA4T4Xe>79~QLw$Kdoh*}R z-R<4ST-@Y-;LIc4*|eEz&6?){(;K6hkcQ^TwBZG8Pl$0|zQ`NFb0K)yk-(NB@0->J z2z?fjTG~!5y$i(Rooi#%_T-E9AxjJOfk47)v9F#HaKp`*Y^3HToa|4zwX2nBzX;J~z^z5L zR6ry3@5YL%&`_0;WC2EU+SbYjU4;{BtR*L&O6Q!wLi1X z!cGkVhev|!*P3OT#5z){^=A5dr%tJBO*7#7b-+%c(O>{?f%$a>!3b#)`$Ob7Paanq zjblZ)Hl$g9I^$XT{i9+jUXDij@kw=kz3PT5DQm8G?8d`Gt3n!ti?o3^yP!aB!Q;u4 zfN8wDtGf@d&DHv*Gnqo>8$p@dq&leo3w*EXBpdegB#^J1^t@Bsnqq+?V8CzST0>=OdUM zOLX4~Qo=r!1sT}4guvOiGW>QY-%DQ${tP)+vs$p{cRJ)z`Jjc28X3{;V1d%>n%UwJ zu8s4q->Myn0k!CU(5d3@2ko_?3#Y!k-lYT>E3!en@E5lwin@&S<12H{yh?wr)`6If zg*hwPHqYett?s9)h#Wa&l56^^PExz0o_P4iQVno1HN!=#r7Z1PlQ7?Qb#L6Dq|laD zoeic5^PNkC%mm@YpR=6L;>I@l)a_uLA8#wa%a)sw=U(R40n7MA@}6_oo+oObo5&2A zHWB~6)b#O9kKk&xgm}q8S?$y~8Ga>=hxJ!k&!hwQy{mnVeBmp$#*$zirpGMKuSQ6B zQbisgGI`PPG5OG7 zK`SjODe*k!xj50~+8BAv_4Cg(+zOFl--1l9wzmJgSBsVqJ3%HQmdX?z>R$jXSTb<* zQ4<350k0-dCD^m2mhtD8dE5cKieZ=U_qtVleST*aAjL$_hi>v_Ce+gG1U;9A6@WJU zifSIb@!Pq4C7pR|#aOI%WNKKPx3c3Ic(@`oIB1HtpZIEGIjd;zR}+Du2q3#<^TBs9 z9$cn!J<|VOFr#}rn0@Tug5Qs?x4$yp&>Ll`@80(8&x_AY;a0-&*c1OgJ%zv(3kfZ& znc7hXN?q5ED&sffi~NxN!GB!Tb&uXYm*YTYa!qows|CIY{quqyEq)kW%0Gdt>Bi6M zwHy8n$W$$10I{0gt=Ct8Iij5QD_BX#tp%Qg6?fA8|6E&W(Y%vCBZv-oRmq6eDHJfe z@!FK`xadbeH-o+2jnx@K`-P*j=2cKSSn5yANrbaYRY$;OEikvH^M550j|bQUwXGPr zUiWMo)ADZcJwi0U>=I9e1sBTvfe>8%U@$iG#-K5NBcY;JGITyR{bsC`r^7bBXRpSj z)AAqUe_o0hD_rBqcvsCYeu5G{eE5LI{D$-*Hy|xk^Ntee=4>c)0;U62tmR%*RFtJ= zLLd*hF@_|?Am?L&c<~kcOGI&2cvs<=QgE9-vGb0(Q^R6rBScSWiD?*m7PnaFtq+uJ z0+61vwHbET-Fc_Lzhf(GC_=wkflnnjDuJ~Ly|(QfLzW*lZ+5J%e%=ttHK_)oeDVe2 z$Fx`@aJ7tizkhB{ z#^seW{5tbyVi|Ee&&9JC>uW3b7~N-ikq_cS!=vX9oQm5i!3h#e89AXFSZRC=8&^Em zq|;JtN`Jtq{%1ZIiG|$RaVqeku~_zG%^6oevQDyn6ynr5mgNXa2+Pf%#rK1X|@gok?9-r3kyJnVl9rUmPz1&gY&Pm zO3Q`vMhY*(zjrVAo-IC9XhZ)qo3{?@a+}t|6D!Z_}G&X`&&*j{HABEIZJE8oTYml z?7ZYx6Q|p6D|Q)QbE-7%ou6WijdZ^-E2{km6d3}a%{a07HmiB3V4A}*S%N`Pre8SL z@7ilp5CePpwlplpiGDUF*MsBtfWi7x?EOl*JNW2Jr{F@|dvpk{l+o>Gs6#upc6RG) p8z(#C-e#=?`ZlnAKSGzMd6#vt)w}o3uat*dHw>;9U9)@ge*uy?!H)m{ literal 0 HcmV?d00001 From f97b989455b295df19a7bc68b8892486b1fd2be7 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 27 Feb 2026 11:28:39 +0900 Subject: [PATCH 04/10] =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20crud=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/lib/tenantTree.test.ts | 12 +-- adminfront/tests/auth.spec.ts | 2 +- adminfront/tests/tenants.spec.ts | 34 +++++--- backend/internal/domain/user.go | 2 +- .../handler/auth_handler_async_test.go | 9 +- backend/internal/handler/tenant_handler.go | 24 +----- .../internal/handler/tenant_handler_test.go | 82 +++++++++++++++++++ backend/internal/handler/user_handler.go | 8 +- .../internal/repository/tenant_repository.go | 21 +++++ .../internal/repository/user_repository.go | 8 +- .../repository/user_repository_test.go | 23 +++++- backend/internal/service/tenant_service.go | 6 ++ .../internal/service/tenant_service_test.go | 30 ++++++- .../service/user_group_service_test.go | 13 ++- 14 files changed, 221 insertions(+), 53 deletions(-) diff --git a/adminfront/src/lib/tenantTree.test.ts b/adminfront/src/lib/tenantTree.test.ts index eb277ce7..7cdf148c 100644 --- a/adminfront/src/lib/tenantTree.test.ts +++ b/adminfront/src/lib/tenantTree.test.ts @@ -44,18 +44,18 @@ describe("tenantTree utility", () => { it("calculates recursive member counts correctly", () => { const { currentBase } = buildTenantFullTree(mockTenants, "root-1"); - + expect(currentBase).not.toBeNull(); if (currentBase) { // Direct: 10, Child: 5, Grandchild: 2 -> Total: 17 expect(currentBase.recursiveMemberCount).toBe(17); expect(currentBase.children).toHaveLength(1); - + const child = currentBase.children[0]; // Direct: 5, Grandchild: 2 -> Total: 7 expect(child.recursiveMemberCount).toBe(7); expect(child.children).toHaveLength(1); - + const grandchild = child.children[0]; // Direct: 2 -> Total: 2 expect(grandchild.recursiveMemberCount).toBe(2); @@ -84,10 +84,10 @@ describe("tenantTree utility", () => { updatedAt: "", }, ]; - + const { subTree } = buildTenantFullTree(multiRootTenants); expect(subTree).toHaveLength(2); - expect(subTree.map(n => n.id)).toContain("root-1"); - expect(subTree.map(n => n.id)).toContain("root-2"); + expect(subTree.map((n) => n.id)).toContain("root-1"); + expect(subTree.map((n) => n.id)).toContain("root-2"); }); }); diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts index fff2875e..46daaaa8 100644 --- a/adminfront/tests/auth.spec.ts +++ b/adminfront/tests/auth.spec.ts @@ -55,7 +55,7 @@ test.describe("Authentication", () => { // Should be on the dashboard/overview await expect(page.locator("aside")).toBeVisible(); - await expect(page.locator("h1")).toContainText("Admin Control"); + await expect(page.locator("h1")).toContainText(/Admin Control|운영 도구/); }); test("should logout and redirect to login page", async ({ page }) => { diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 253e1b76..e2fbb624 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -170,31 +170,45 @@ test.describe("Tenants Management", () => { const parentRow = page.locator("tr", { hasText: "Parent Org" }); await expect(parentRow).toContainText("5"); // Direct await expect(parentRow).toContainText("8"); // Total (5 + 3) - + // Check for either English or Korean labels - const hasDirectLabel = await parentRow.evaluate(el => - el.textContent?.includes("Direct") || el.textContent?.includes("소속") + const hasDirectLabel = await parentRow.evaluate( + (el) => + el.textContent?.includes("Direct") || el.textContent?.includes("소속"), ); - const hasTotalLabel = await parentRow.evaluate(el => - el.textContent?.includes("Total") || el.textContent?.includes("전체") + const hasTotalLabel = await parentRow.evaluate( + (el) => + el.textContent?.includes("Total") || el.textContent?.includes("전체"), ); expect(hasDirectLabel).toBe(true); expect(hasTotalLabel).toBe(true); // Open Member List Dialog - Click the members count button - const memberButton = parentRow.getByRole("button").filter({ hasText: /Direct|소속/ }); + const memberButton = parentRow + .getByRole("button") + .filter({ hasText: /Direct|소속/ }); await memberButton.click(); - + // Check Tabs in Member List Dialog // Use regex to match either language, ignoring the count suffix - await expect(page.locator('button[role="tab"]').filter({ hasText: /소속 멤버|Direct Members/ })).toBeVisible(); - await expect(page.locator('button[role="tab"]').filter({ hasText: /하위 조직 멤버|Descendant Members/ })).toBeVisible(); + await expect( + page + .locator('button[role="tab"]') + .filter({ hasText: /소속 멤버|Direct Members/ }), + ).toBeVisible(); + await expect( + page + .locator('button[role="tab"]') + .filter({ hasText: /하위 조직 멤버|Descendant Members/ }), + ).toBeVisible(); // Direct Members Tab should show parent's user await expect(page.locator("role=dialog")).toContainText("u1@parent.com"); // Switch to Descendant Members Tab - await page.click('button[role="tab"]:has-text("하위 조직 멤버"), button[role="tab"]:has-text("Descendant Members")'); + await page.click( + 'button[role="tab"]:has-text("하위 조직 멤버"), button[role="tab"]:has-text("Descendant Members")', + ); await expect(page.locator("role=dialog")).toContainText("u2@child.com"); }); }); diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index a5b9b794..6a27ed2d 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -29,7 +29,7 @@ type User struct { Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 Department string `gorm:"column:department" json:"department"` - Position string `gorm:"column:position" json:"position"` // 직급 (예: 수석, 책임, 선임) + Position string `gorm:"column:position" json:"position"` // 직급 (예: 수석, 책임, 선임) JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획) Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"` Status string `gorm:"column:status;default:'active'" json:"status"` diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index 3a7ed64c..35c13cf3 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -98,7 +98,7 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) ( return nil, nil } -func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { return nil, 0, nil } @@ -110,6 +110,9 @@ func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []st return nil, nil } +func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + return nil, nil +} type AsyncMockRedisRepo struct { mock.Mock @@ -161,6 +164,10 @@ func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*dom return nil, nil } +func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + return nil, 0, nil +} + func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { return nil, nil } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index c758a021..ac6ec510 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -98,10 +98,6 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error { } func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { - if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) - } - limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) parentId := c.Query("parentId") @@ -113,24 +109,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { offset = 0 } - // Use separate queries for count and find to avoid GORM statement contamination - countQuery := h.DB.Model(&domain.Tenant{}) - if parentId != "" { - countQuery = countQuery.Where("parent_id = ?", parentId) - } - - var total int64 - if err := countQuery.Count(&total).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - findQuery := h.DB.Model(&domain.Tenant{}) - if parentId != "" { - findQuery = findQuery.Where("parent_id = ?", parentId) - } - - var tenants []domain.Tenant - if err := findQuery.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { + tenants, total, err := h.Service.ListTenants(c.Context(), limit, offset, parentId) + if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index b15b4a65..54285177 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -66,10 +66,51 @@ func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.T return args.Get(0).(*domain.Tenant), args.Error(1) } +func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + args := m.Called(ctx, limit, offset, parentID) + return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2) +} + func (m *MockTenantService) SetKetoService(keto service.KetoService) { m.Called(keto) } +type MockUserRepoForHandler struct { + mock.Mock +} + +func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil } +func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil } +func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error { return nil } +func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForHandler) FindByID(ctx context.Context, id string) (*domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForHandler) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { + return nil, 0, nil +} +func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + return 0, nil +} +func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + return nil, nil +} +func (m *MockUserRepoForHandler) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + args := m.Called(ctx, codes) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]int64), args.Error(1) +} + func TestTenantHandler_CreateTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) @@ -98,6 +139,47 @@ func TestTenantHandler_CreateTenant(t *testing.T) { assert.Equal(t, "t1", got["id"]) } +func TestTenantHandler_ListTenants(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + mockUserRepo := new(MockUserRepoForHandler) + + h := &TenantHandler{ + Service: mockSvc, + UserRepo: mockUserRepo, + } + + app.Get("/tenants", h.ListTenants) + + tenants := []domain.Tenant{ + {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, + {ID: "t2", Name: "Tenant B", Slug: "slug-b"}, + } + mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(2), nil) + mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"slug-a", "slug-b"}). + Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil) + + req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res tenantListResponse + json.NewDecoder(resp.Body).Decode(&res) + + assert.Equal(t, int64(2), res.Total) + assert.Len(t, res.Items, 2) + + // Check if counts are mapped correctly + for _, item := range res.Items { + if item.Slug == "slug-a" { + assert.Equal(t, int64(5), item.MemberCount) + } else if item.Slug == "slug-b" { + assert.Equal(t, int64(10), item.MemberCount) + } + } +} + func TestTenantHandler_ApproveTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 6b2d8be5..5864852d 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -322,12 +322,12 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { // [New] Local DB Sync - Ensure user exists in read-model if h.UserRepo != nil { localUser := h.mapToLocalUser(*identity) - + // Sync to local DB go func(u *domain.User, role string, tID *string) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - + // Use Update (upsert) instead of Create for robustness if err := h.UserRepo.Update(ctx, u); err != nil { slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err) @@ -475,14 +475,14 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { // [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller if h.UserRepo != nil { updatedLocalUser := h.mapToLocalUser(*updated) - + ctx := context.Background() // Use request context if appropriate, but sync must finish if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil { slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err) } // [Keto Sync] asynchronously as it's less critical for immediate UI count - go h.syncKetoRole(context.Background(), updatedLocalUser.ID, + go h.syncKetoRole(context.Background(), updatedLocalUser.ID, extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID) } diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index cc20a6b5..9a18c4fe 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -16,6 +16,7 @@ type TenantRepository interface { FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error + List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) } type tenantRepository struct { @@ -90,3 +91,23 @@ func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domai } return r.db.WithContext(ctx).Create(&td).Error } + +func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + var tenants []domain.Tenant + var total int64 + db := r.db.WithContext(ctx).Model(&domain.Tenant{}) + + if parentID != "" { + db = db.Where("parent_id = ?", parentID) + } + + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := db.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { + return nil, 0, err + } + + return tenants, total, nil +} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 543a1697..b793aa6f 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -119,10 +119,10 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string // 1. Resolve IDs for these codes to support dual counting (slug or ID) var tenants []domain.Tenant _ = r.db.WithContext(ctx).Where("slug IN ?", codes).Find(&tenants).Error - + idToSlug := make(map[string]string) slugToNormalized := make(map[string]string) - + for _, code := range codes { slugToNormalized[strings.ToLower(strings.TrimSpace(code))] = code } @@ -156,13 +156,13 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string } else if res.TenantID != "" { slug = idToSlug[res.TenantID] } - + if slug != "" { normalizedSlug := strings.ToLower(strings.TrimSpace(slug)) counts[normalizedSlug] += res.Count } } - + return counts, nil } diff --git a/backend/internal/repository/user_repository_test.go b/backend/internal/repository/user_repository_test.go index 886f297d..7d8d421e 100644 --- a/backend/internal/repository/user_repository_test.go +++ b/backend/internal/repository/user_repository_test.go @@ -56,7 +56,7 @@ func TestUserRepository(t *testing.T) { _ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"}) _ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", Role: "user"}) - users, total, err := repo.List(ctx, 0, 10, "Alice") + users, total, err := repo.List(ctx, 0, 10, "Alice", "") assert.NoError(t, err) assert.True(t, total >= 1) assert.Equal(t, "Alice", users[0].Name) @@ -73,4 +73,25 @@ func TestUserRepository(t *testing.T) { assert.Error(t, err) // Should not be found assert.Nil(t, found) }) + + t.Run("CountByCompanyCodes", func(t *testing.T) { + // Clean start for this subtest + testDB.Exec("DELETE FROM users") + + users := []domain.User{ + {Email: "u1@a.com", Name: "U1", CompanyCode: "tenant-a"}, + {Email: "u2@a.com", Name: "U2", CompanyCode: "tenant-a"}, + {Email: "u3@b.com", Name: "U3", CompanyCode: "tenant-b"}, + {Email: "u4@none.com", Name: "U4", CompanyCode: ""}, + } + for _, u := range users { + _ = repo.Create(ctx, &u) + } + + counts, err := repo.CountByCompanyCodes(ctx, []string{"tenant-a", "tenant-b", "tenant-c"}) + assert.NoError(t, err) + assert.Equal(t, int64(2), counts["tenant-a"]) + assert.Equal(t, int64(1), counts["tenant-b"]) + assert.Equal(t, int64(0), counts["tenant-c"]) + }) } diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 2f358ec5..c1c161a5 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -18,6 +18,7 @@ type TenantService interface { GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) + ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) ApproveTenant(ctx context.Context, id string) error SetKetoService(keto KetoService) // 추가 } @@ -226,3 +227,8 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { return s.repo.FindBySlug(ctx, slug) } + +func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + // Let the repository handle the query and pagination + return s.repo.List(ctx, limit, offset, parentID) +} diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 2216ffdb..2952bfe8 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -59,6 +59,11 @@ func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, d return m.Called(ctx, tenantID, domainName, verified).Error(0) } +func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + args := m.Called(ctx, limit, offset, parentID) + return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2) +} + type MockKetoSvcForTenant struct { mock.Mock } @@ -116,7 +121,7 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin return nil, nil } -func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { return nil, 0, nil } @@ -133,7 +138,13 @@ func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs return args.Get(0).(map[string]int64), args.Error(1) } - +func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + args := m.Called(ctx, codes) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]int64), args.Error(1) +} func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { mockRepo := new(MockTenantRepoForSvc) @@ -214,3 +225,18 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) { mockUserRepo.AssertExpectations(t) mockOutbox.AssertExpectations(t) } + +func TestTenantService_ListTenants(t *testing.T) { + mockRepo := new(MockTenantRepoForSvc) + svc := NewTenantService(mockRepo, nil, nil) + ctx := context.Background() + + tenants := []domain.Tenant{{ID: "t1", Name: "Tenant 1"}} + mockRepo.On("List", ctx, 10, 0, "").Return(tenants, 1, nil) + + result, total, err := svc.ListTenants(ctx, 10, 0, "") + assert.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Equal(t, tenants, result) + mockRepo.AssertExpectations(t) +} diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index b740909e..e5772077 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -77,7 +77,7 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string) return nil, nil } -func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { return nil, 0, nil } @@ -94,6 +94,13 @@ func (m *MockUserRepository) CountByTenantIDs(ctx context.Context, tenantIDs []s return args.Get(0).(map[string]int64), args.Error(1) } +func (m *MockUserRepository) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + args := m.Called(ctx, codes) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]int64), args.Error(1) +} type MockTenantRepository struct { mock.Mock @@ -135,6 +142,10 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d return nil } +func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + return nil, 0, nil +} + func TestUserGroupService_Create(t *testing.T) { mockRepo := new(MockUserGroupRepository) mockTenantRepo := new(MockTenantRepository) From 89f07f3bcdc58c8dddb65e5c3fa14482068ac4ba Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 27 Feb 2026 11:33:09 +0900 Subject: [PATCH 05/10] =?UTF-8?q?i18n=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en.toml | 32 ++++++++++++++++++++++++++++++++ locales/ko.toml | 32 ++++++++++++++++++++++++++++++++ locales/template.toml | 29 +++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/locales/en.toml b/locales/en.toml index 6c980370..d09b83fb 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -1467,3 +1467,35 @@ verify = "Verify" [ui.userfront.signup.success] action = "Action" + +[msg.admin.tenants] +not_found = "Tenant not found." +remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?' + +[msg.admin.users.create] +success = "User created successfully." + +[ui.admin.tenants.sub] +add_dialog_desc = "Select a tenant to add as a sub-tenant." +add_dialog_title = "Add Sub-tenant" +add_existing = "Add Existing Tenant" +no_candidates = "No available tenants to add." +search_placeholder = "Search by name or slug..." + +[ui.admin.tenants.table] +members = "Members" + +[ui.admin.users.table] +email = "Email" +name = "Name" +role = "Role" + +[ui.common] +manage = "Manage" +remove = "Remove" + +[test] +key = "Test" + +[non.existent] +key = "Non-existent key" diff --git a/locales/ko.toml b/locales/ko.toml index e42e83ef..ac7d764e 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -1467,3 +1467,35 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" + +[msg.admin.tenants] +not_found = "테넌트를 찾을 수 없습니다." +remove_sub_confirm = '테넌트 "{{name}}"을(를) 하위 조직에서 제외할까요?' + +[msg.admin.users.create] +success = "사용자가 생성되었습니다." + +[ui.admin.tenants.sub] +add_dialog_desc = "하위 조직으로 추가할 테넌트를 선택하세요." +add_dialog_title = "하위 조직 추가" +add_existing = "기존 테넌트 추가" +no_candidates = "추가 가능한 테넌트가 없습니다." +search_placeholder = "테넌트 이름 또는 슬러그로 검색..." + +[ui.admin.tenants.table] +members = "멤버수" + +[ui.admin.users.table] +email = "이메일" +name = "이름" +role = "역할" + +[ui.common] +manage = "관리" +remove = "제외" + +[test] +key = "테스트" + +[non.existent] +key = "존재하지 않는 키" diff --git a/locales/template.toml b/locales/template.toml index b92a8848..4d819478 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -126,6 +126,8 @@ description = "" delete_confirm = "" empty = "" fetch_error = "" +not_found = "" +remove_sub_confirm = "" subtitle = "" [msg.admin.tenants.create] @@ -142,7 +144,9 @@ subtitle = "" subtitle = "" [msg.admin.tenants.members] +desc = "" empty = "" +limit_notice = "" [msg.admin.tenants.registry] count = "" @@ -163,6 +167,7 @@ subtitle = "" [msg.admin.users.create] error = "" password_required = "" +success = "" [msg.admin.users.create.account] subtitle = "" @@ -749,7 +754,12 @@ title = "" title = "" [ui.admin.tenants.members] +descendants = "" +direct = "" +direct_label = "" +list_title = "" title = "" +total_label = "" [ui.admin.tenants.members.table] email = "" @@ -777,7 +787,12 @@ type_text = "" [ui.admin.tenants.sub] add = "" +add_dialog_desc = "" +add_dialog_title = "" +add_existing = "" manage = "" +no_candidates = "" +search_placeholder = "" title = "" [ui.admin.tenants.sub.table] @@ -788,6 +803,7 @@ status = "" [ui.admin.tenants.table] actions = "" +members = "" name = "" slug = "" status = "" @@ -881,6 +897,11 @@ role = "" status = "" tenant_dept = "" +[ui.admin.users.table] +email = "" +name = "" +role = "" + [ui.common] add = "" @@ -895,6 +916,7 @@ delete = "" details = "" edit = "" hyphen = "" +manage = "" na = "" never = "" next = "" @@ -904,6 +926,7 @@ previous = "" qr = "" read_only = "" refresh = "" +remove = "" requesting = "" resend = "" retry = "" @@ -936,6 +959,12 @@ ok = "" pending = "" success = "" +[test] +key = "" + +[non.existent] +key = "" + [ui.dev] brand = "" console_title = "" From cd3124f91d22e2020b59301730bca991aebf6756 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 27 Feb 2026 12:33:23 +0900 Subject: [PATCH 06/10] =?UTF-8?q?=EB=A6=B0=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/tenant_handler_test.go | 7 +++++++ backend/internal/repository/user_repository.go | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 54285177..80e89d73 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -85,24 +85,31 @@ func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, nil } + func (m *MockUserRepoForHandler) FindByID(ctx context.Context, id string) (*domain.User, error) { return nil, nil } + func (m *MockUserRepoForHandler) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { return nil, nil } + func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { return nil, nil } + func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { return nil, 0, nil } + func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) { return 0, nil } + func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { return nil, nil } + func (m *MockUserRepoForHandler) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { args := m.Called(ctx, codes) if args.Get(0) == nil { diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index b793aa6f..eb17527c 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -143,7 +143,6 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string Where("company_code IN ? OR tenant_id IN (SELECT id FROM tenants WHERE slug IN ?)", codes, codes). Group("company_code, tenant_id"). Scan(&results).Error - if err != nil { return nil, err } From 497ffff216e45e49d4be2915b7c0c2f67ad69427 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 27 Feb 2026 13:53:26 +0900 Subject: [PATCH 07/10] =?UTF-8?q?userfront-e2e-tests=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/tests/tenants.spec.ts | 2 +- locales/en.toml | 22 ++++++++++----------- locales/ko.toml | 20 +++++++++---------- locales/template.toml | 12 +++++------ userfront/assets/translations/en.toml | 3 +++ userfront/assets/translations/ko.toml | 3 +++ userfront/assets/translations/template.toml | 9 +++------ 7 files changed, 34 insertions(+), 37 deletions(-) diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index e2fbb624..d9c567f3 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -51,7 +51,7 @@ test.describe("Tenants Management", () => { }); await page.goto("/tenants"); - await expect(page.locator("h2")).toContainText("테넌트 목록"); + await expect(page.locator("h2")).toContainText("테넌트 레지스트리"); await expect(page.locator("table")).toContainText("Tenant A"); }); diff --git a/locales/en.toml b/locales/en.toml index f8680473..3ee69959 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -806,8 +806,8 @@ tenant_admin = "TENANT ADMIN" tenant_member = "TENANT MEMBER" [ui.admin.tenants] -add = "Tenant Add" -title = "Tenant List" +add = "Add Tenant" +title = "Tenant Registry" [ui.admin.tenants.admins] add_button = "Add Button" @@ -1067,6 +1067,8 @@ theme_light = "Light" theme_toggle = "Theme Toggle" unknown = "Unknown" view = "View" +manage = "Manage" +remove = "Remove" [ui.common.badge] admin_only = "Admin only" @@ -1086,6 +1088,12 @@ ok = "Ok" pending = "Pending" success = "Success" +[test] +key = "Test" + +[non.existent] +key = "Non-existent key" + [ui.dev] brand = "Brand" console_title = "Developer Console" @@ -1547,13 +1555,3 @@ members = "Members" email = "Email" name = "Name" role = "Role" - -[ui.common] -manage = "Manage" -remove = "Remove" - -[test] -key = "Test" - -[non.existent] -key = "Non-existent key" diff --git a/locales/ko.toml b/locales/ko.toml index 95c5c10d..3fdab991 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -807,7 +807,7 @@ tenant_member = "TENANT MEMBER" [ui.admin.tenants] add = "테넌트 추가" -title = "테넌트 목록" +title = "테넌트 레지스트리" [ui.admin.tenants.admins] add_button = "관리자 추가" @@ -1067,6 +1067,8 @@ theme_light = "Light" theme_toggle = "테마 전환" unknown = "Unknown" view = "보기" +manage = "관리" +remove = "제외" [ui.common.badge] admin_only = "Admin only" @@ -1086,6 +1088,12 @@ ok = "정상" pending = "준비 중" success = "성공" +[test] +key = "테스트" + +[non.existent] +key = "존재하지 않는 키" + [ui.dev] brand = "Baron 로그인" console_title = "Developer Console" @@ -1547,13 +1555,3 @@ members = "멤버수" email = "이메일" name = "이름" role = "역할" - -[ui.common] -manage = "관리" -remove = "제외" - -[test] -key = "테스트" - -[non.existent] -key = "존재하지 않는 키" diff --git a/locales/template.toml b/locales/template.toml index 5f4fa1b3..0481ac57 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -910,6 +910,8 @@ role = "" [ui.common] add = "" +admin_only = "" +assign = "" back = "" cancel = "" close = "" @@ -926,6 +928,7 @@ manage = "" na = "" never = "" next = "" +none = "" page_of = "" prev = "" previous = "" @@ -939,6 +942,8 @@ resend = "" retry = "" save = "" search = "" +select = "" +select_placeholder = "" show_more = "" language = "" language_ko = "" @@ -1531,10 +1536,3 @@ position_placeholder = "" [ui.admin.users.list.table] position_job = "" - -[ui.common] -admin_only = "" -assign = "" -none = "" -select = "" -select_placeholder = "" diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index db6738c6..76911a46 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -336,6 +336,8 @@ theme_light = "Light" theme_toggle = "Theme Toggle" unknown = "Unknown" view = "View" +manage = "Manage" +remove = "Remove" [ui.common.badge] admin_only = "Admin only" @@ -567,3 +569,4 @@ verify = "Verify" [ui.userfront.signup.success] action = "Action" + diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index ec26fe14..c655ad51 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -336,6 +336,8 @@ theme_light = "Light" theme_toggle = "테마 전환" unknown = "Unknown" view = "보기" +manage = "관리" +remove = "제외" [ui.common.badge] admin_only = "Admin only" @@ -567,3 +569,4 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" + diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 0a335640..6ae3319d 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -305,6 +305,7 @@ details = "" edit = "" view = "" hyphen = "" +manage = "" na = "" never = "" next = "" @@ -315,6 +316,8 @@ qr = "" reset = "" read_only = "" refresh = "" +remove = "" +requesting = "" resend = "" retry = "" save = "" @@ -557,9 +560,3 @@ action = "" # Auto-added missing keys -[ui.common] -admin_only = "" -assign = "" -none = "" -select = "" -select_placeholder = "" From 6d9899acbb898e218297112df4188cc6534a2561 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 27 Feb 2026 14:05:45 +0900 Subject: [PATCH 08/10] =?UTF-8?q?af=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/features/tenants/routes/TenantListPage.tsx | 2 +- adminfront/tests/tenants.spec.ts | 2 +- locales/ko.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index f951e674..c1671049 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -77,7 +77,7 @@ function TenantListPage() {

- {t("ui.admin.tenants.title", "테넌트 레지스트리")} + {t("ui.admin.tenants.title", "테넌트 목록")}

{t( diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index d9c567f3..e2fbb624 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -51,7 +51,7 @@ test.describe("Tenants Management", () => { }); await page.goto("/tenants"); - await expect(page.locator("h2")).toContainText("테넌트 레지스트리"); + await expect(page.locator("h2")).toContainText("테넌트 목록"); await expect(page.locator("table")).toContainText("Tenant A"); }); diff --git a/locales/ko.toml b/locales/ko.toml index 3fdab991..76c20154 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -807,7 +807,7 @@ tenant_member = "TENANT MEMBER" [ui.admin.tenants] add = "테넌트 추가" -title = "테넌트 레지스트리" +title = "테넌트 목록" [ui.admin.tenants.admins] add_button = "관리자 추가" From f6769fa1db6f2724e48615acd7de90267b672192 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 27 Feb 2026 14:40:07 +0900 Subject: [PATCH 09/10] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/tests/tenants.spec.ts | 6 ++-- .../presentation/pages/profile_page.dart | 28 +++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index e2fbb624..b54b4c8b 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -30,7 +30,7 @@ test.describe("Tenants Management", () => { }); test("should list tenants", async ({ page }) => { - await page.route("**/api/v1/admin/tenants*", async (route) => { + await page.route("**/api/v1/admin/tenants**", async (route) => { await route.fulfill({ json: { items: [ @@ -57,7 +57,7 @@ test.describe("Tenants Management", () => { test("should create a new tenant", async ({ page }) => { // Mock GET for list (empty) and for parents - await page.route("**/api/v1/admin/tenants*", async (route) => { + await page.route("**/api/v1/admin/tenants**", async (route) => { if (route.request().method() === "GET") { await route.fulfill({ json: { items: [], total: 0, limit: 100, offset: 0 }, @@ -120,7 +120,7 @@ test.describe("Tenants Management", () => { }, ]; - await page.route("**/api/v1/admin/tenants*", async (route) => { + await page.route("**/api/v1/admin/tenants**", async (route) => { await route.fulfill({ json: { items: mockTenants, diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 7f0da220..dd741004 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -86,8 +86,11 @@ class _ProfilePageState extends ConsumerState { void _onNameFocusChange() { if (!mounted) return; if (!_nameFocus.hasFocus && _nameTouched) { - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _autoSaveIfEditing(profile, 'name'); + Future.microtask(() { + if (!mounted) return; + final profile = ref.read(profileProvider).value ?? _cachedProfile; + if (profile != null) _autoSaveIfEditing(profile, 'name'); + }); } else if (_nameFocus.hasFocus) { _nameTouched = true; } @@ -101,8 +104,11 @@ class _ProfilePageState extends ConsumerState { hasFocus: _departmentFocus.hasFocus, ); if (!_departmentFocus.hasFocus && _departmentTouched) { - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _autoSaveIfEditing(profile, 'department'); + Future.microtask(() { + if (!mounted) return; + final profile = ref.read(profileProvider).value ?? _cachedProfile; + if (profile != null) _autoSaveIfEditing(profile, 'department'); + }); } else if (_departmentFocus.hasFocus) { _departmentTouched = true; } @@ -111,8 +117,11 @@ class _ProfilePageState extends ConsumerState { void _onPhoneFocusChange() { if (!mounted) return; if (!_phoneFocus.hasFocus && _phoneTouched) { - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _handlePhoneFocusChange(profile); + Future.microtask(() { + if (!mounted) return; + final profile = ref.read(profileProvider).value ?? _cachedProfile; + if (profile != null) _handlePhoneFocusChange(profile); + }); } else if (_phoneFocus.hasFocus) { _phoneTouched = true; } @@ -121,8 +130,11 @@ class _ProfilePageState extends ConsumerState { void _onPhoneCodeFocusChange() { if (!mounted) return; if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) { - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _handlePhoneFocusChange(profile); + Future.microtask(() { + if (!mounted) return; + final profile = ref.read(profileProvider).value ?? _cachedProfile; + if (profile != null) _handlePhoneFocusChange(profile); + }); } else if (_phoneCodeFocus.hasFocus) { _phoneCodeTouched = true; } From e1ebb78331b04d8985c39509daff223fa3985aaf Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 27 Feb 2026 15:04:13 +0900 Subject: [PATCH 10/10] =?UTF-8?q?=20[vite]=20http=20proxy=20error:=20/api/?= =?UTF-8?q?v1/admin/tenants=3Flimit=3D1000&offset=3D0=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/tests/tenants.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index b54b4c8b..817f285a 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -27,6 +27,17 @@ test.describe("Tenants Management", () => { await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); }, ); + + // Default mock for tenants to avoid proxy leaks + await page.route("**/api/v1/admin/tenants**", async (route) => { + if (route.request().method() === "GET") { + await route.fulfill({ + json: { items: [], total: 0, limit: 100, offset: 0 }, + }); + } else { + await route.continue(); + } + }); }); test("should list tenants", async ({ page }) => {