From 94db1dab08333e8c24f1955bec19b7542b732816 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 7 May 2026 13:43:24 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=EC=84=B8=EC=85=98=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EC=9E=84=EA=B3=84=EA=B0=92=205=EB=B6=84->10=EB=B6=84=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/lib/sessionSliding.test.ts | 4 ++-- adminfront/src/lib/sessionSliding.ts | 2 +- devfront/src/lib/sessionSliding.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/adminfront/src/lib/sessionSliding.test.ts b/adminfront/src/lib/sessionSliding.test.ts index cce36661..6e234b63 100644 --- a/adminfront/src/lib/sessionSliding.test.ts +++ b/adminfront/src/lib/sessionSliding.test.ts @@ -8,7 +8,7 @@ import { describe("shouldAttemptSlidingSessionRenew", () => { const nowMs = 1_700_000_000_000; - it("returns false when remaining time is above the 5 minute threshold", () => { + it("returns false when remaining time is above the 10 minute threshold", () => { expect( shouldAttemptSlidingSessionRenew({ expiresAtSec: Math.floor( @@ -24,7 +24,7 @@ describe("shouldAttemptSlidingSessionRenew", () => { ).toBe(false); }); - it("returns true when remaining time is within the 5 minute threshold", () => { + it("returns true when remaining time is within the 10 minute threshold", () => { expect( shouldAttemptSlidingSessionRenew({ expiresAtSec: Math.floor( diff --git a/adminfront/src/lib/sessionSliding.ts b/adminfront/src/lib/sessionSliding.ts index 9caff6cd..9fd60fda 100644 --- a/adminfront/src/lib/sessionSliding.ts +++ b/adminfront/src/lib/sessionSliding.ts @@ -1,4 +1,4 @@ -export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000; +export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000; export const SESSION_RENEW_THROTTLE_MS = 30 * 1000; type SlidingSessionRenewDecisionParams = { diff --git a/devfront/src/lib/sessionSliding.ts b/devfront/src/lib/sessionSliding.ts index be152778..831ef8e8 100644 --- a/devfront/src/lib/sessionSliding.ts +++ b/devfront/src/lib/sessionSliding.ts @@ -1,4 +1,4 @@ -export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000; +export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000; export const SESSION_RENEW_THROTTLE_MS = 30 * 1000; type SlidingSessionRenewDecisionParams = { From 888863094d3b7b3f82abd2124fe3bd42c59231f8 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 7 May 2026 13:58:41 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EC=9E=90=EB=8F=99=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rp-auto-login-guide.md | 117 ++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/docs/rp-auto-login-guide.md b/docs/rp-auto-login-guide.md index fb9a9faa..fc45cdab 100644 --- a/docs/rp-auto-login-guide.md +++ b/docs/rp-auto-login-guide.md @@ -24,6 +24,29 @@ devfront의 RP 일반 설정에서 다음 항목을 입력합니다. 3. Redirect URI 목록에는 RP callback URL을 등록합니다. 4. 저장 후 userfront 연동 앱 카드에서 “연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다.” 안내가 보이는지 확인합니다. +### 설정 규칙 + +- `자동 로그인 시작 URL`은 반드시 `http://` 또는 `https://`로 시작하는 완전한 절대 URL이어야 합니다. +- Baron은 이 값을 브라우저 진입 URL로만 사용합니다. Baron이 `/oauth2/auth?...`를 대신 생성하지 않습니다. +- 따라서 이 URL은 RP 내부의 "로그인 시작 엔드포인트"여야 합니다. +- 루트(`/`)가 아니라 실제로 OIDC 시작을 트리거하는 경로를 넣어야 합니다. + +허용 예시: + +```text +http://localhost:3333/login +http://localhost:3333/login?auto=1 +https://rp.example.com/login?auto=1&returnTo=%2Fdashboard +``` + +비권장 예시: + +```text +http:localhost:3333/login +localhost:3333/login +/login?auto=1 +``` + 예시: ```text @@ -32,6 +55,24 @@ auto_login_url: https://org.example.com/login?auto=1 redirect_uri: https://org.example.com/auth/callback ``` +### 로컬 RP 예시 + +로컬에서 `client_id=f5cdd938-a3ae-4e47-ab83-4c13e59949f5` RP가 `http://localhost:3333`에서 실행 중이라면, 메인 페이지가 `/login` 링크를 노출하고 `/login` 호출 시 Baron OIDC authorize endpoint로 `302`를 반환하는지 먼저 확인합니다. + +이 경우 devfront 설정 탭의 권장 입력값은 다음과 같습니다. + +```text +자동 로그인 지원: ON +자동 로그인 시작 URL: http://localhost:3333/login +Redirect URI: http://localhost:3333/callback +``` + +주의: + +- `http://localhost:3333/`는 홈 URL일 뿐, 로그인 시작 URL이 아닐 수 있습니다. +- `http://localhost:3333/login?auto=1`도 저장은 가능하지만, RP가 `/login`만으로 이미 즉시 OIDC를 시작한다면 `?auto=1`은 필수가 아닙니다. +- 현재 확인된 로컬 데모 RP는 `/login`만 호출해도 Baron OIDC로 바로 리다이렉트합니다. + ## RP 구현 요구사항 RP는 `auto_login_url`에서 다음 동작을 구현해야 합니다. @@ -74,6 +115,81 @@ userfront는 backend의 linked RP 응답을 기준으로 진입 URL을 선택합 이 기준 때문에 `auto_login_supported=false`인 RP는 accidental auto-login을 수행하지 않습니다. +## 시퀀스 다이어그램 + +### 1. 설정 저장 흐름 + +```mermaid +sequenceDiagram + participant Admin as 운영자 + participant DevFront as DevFront 설정 화면 + participant Backend as Baron Backend + participant Hydra as Hydra Client Metadata + + Admin->>DevFront: RP 설정 화면 진입 + Admin->>DevFront: 자동 로그인 지원 ON + Admin->>DevFront: 자동 로그인 시작 URL 입력 + Admin->>DevFront: 저장 + DevFront->>Backend: RP 일반 설정 수정 요청 + Backend->>Backend: auto_login_url 검증 + Note over Backend: scheme=http/https
host 존재 필요 + Backend->>Hydra: client metadata update + Hydra-->>Backend: 저장 완료 + Backend-->>DevFront: 저장 성공 + DevFront-->>Admin: 저장 완료 표시 +``` + +### 2. userfront에서 자동 로그인 시작 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant UserFront as UserFront + participant Backend as Baron Backend + participant RP as RP 로그인 시작 URL + participant Baron as Baron OIDC/Hydra + + User->>UserFront: 연동 앱 카드 클릭 + UserFront->>Backend: linked RP 목록/상태 조회 + Backend-->>UserFront: auto_login_supported, auto_login_url 반환 + + alt auto_login_supported = true + UserFront->>RP: GET auto_login_url + Note over RP: RP가 state, nonce,
PKCE verifier/challenge 생성 + RP-->>UserFront: 302 Baron authorize endpoint + UserFront->>Baron: GET /oidc/oauth2/auth + else auto_login_supported = false + UserFront->>UserFront: 일반 url 또는 init_url 사용 + end +``` + +### 3. 로컬 3333 RP 예시 흐름 + +```mermaid +sequenceDiagram + participant User as 사용자 브라우저 + participant UserFront as UserFront + participant RP as localhost:3333 RP + participant Baron as Baron OIDC + + User->>UserFront: RP 카드 클릭 + UserFront->>RP: GET http://localhost:3333/login + RP-->>User: 302 /oidc/oauth2/auth?...&client_id=f5cdd938-a3ae-4e47-ab83-4c13e59949f5 + User->>Baron: GET authorize endpoint + Baron-->>User: 로그인/동의 또는 기존 세션 진행 + Baron-->>RP: callback redirect + RP-->>User: RP 세션 생성 후 앱 화면 진입 +``` + +## 로직 요약 + +1. DevFront는 `auto_login_supported`, `auto_login_url`을 RP metadata로 저장합니다. +2. Backend는 `auto_login_supported=true`일 때만 `auto_login_url`을 linked RP 응답에 포함시켜 userfront가 사용할 수 있게 합니다. +3. UserFront는 카드 클릭 시 이 URL로 직접 이동합니다. +4. RP는 그 진입점에서 OIDC 요청을 "직접" 생성해야 합니다. +5. Callback 검증에 필요한 `state`, `nonce`, PKCE 상태는 RP 저장소가 소유해야 합니다. +6. 그래서 Baron이 RP 대신 authorize URL을 만들어 주는 구조로 바꾸면 안 됩니다. + ## 검증 체크리스트 RP 등록자는 다음을 확인해야 합니다. @@ -101,6 +217,7 @@ npm run test -- tests/orgfront-auto-login.spec.ts --project=chromium | RP 로그인 화면에 머무름 | RP가 `auto=1` 쿼리를 읽어 자동으로 `signinRedirect` 또는 동일한 OIDC 시작 함수를 호출하는지 확인합니다. | | callback에서 state 오류 발생 | userfront나 backend가 만든 `/oauth2/auth?...` URL을 직접 쓰지 말고 RP 자체 로그인 시작 URL에서 OIDC 요청을 생성해야 합니다. | | 등록 저장이 실패함 | `auto_login_supported=true`일 때 `auto_login_url`이 비어 있거나 `http/https` URL이 아닌지 확인합니다. | +| `http:localhost:3333/login` 같은 값이 브라우저에서는 열림 | 브라우저 보정에 기대지 말고 `http://localhost:3333/login`처럼 완전한 절대 URL로 저장합니다. | ## 구현 예시 From 59cb4822192140be604725eb9cff4c31587ebfa5 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 8 May 2026 14:11:35 +0900 Subject: [PATCH 3/6] =?UTF-8?q?devfront=20=EC=95=B1=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EB=A1=9C=EA=B3=A0=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientsPage.tsx | 11 +--- .../clients/components/ClientLogo.tsx | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 devfront/src/features/clients/components/ClientLogo.tsx diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 2d1fd352..bb0ebbe6 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -5,8 +5,6 @@ import { Filter, Plus, Search, - ServerCog, - ShieldHalf, X, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -50,6 +48,7 @@ import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { cn } from "../../lib/utils"; import { fetchMe } from "../auth/authApi"; +import { ClientLogo } from "./components/ClientLogo"; function ClientsPage() { const navigate = useNavigate(); @@ -498,13 +497,7 @@ function ClientsPage() { to={`/clients/${client.id}`} className="flex items-center gap-3 transition-colors hover:text-primary" > -
- {client.type === "private" ? ( - - ) : ( - - )} -
+

{client.name || diff --git a/devfront/src/features/clients/components/ClientLogo.tsx b/devfront/src/features/clients/components/ClientLogo.tsx new file mode 100644 index 00000000..0c7b0a56 --- /dev/null +++ b/devfront/src/features/clients/components/ClientLogo.tsx @@ -0,0 +1,52 @@ +import { ServerCog, ShieldHalf } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "../../../components/ui/avatar"; +import type { ClientSummary, ClientType } from "../../../lib/devApi"; +import { t } from "../../../lib/i18n"; + +type ClientLogoProps = { + client: Pick; +}; + +function readLogoUrl(metadata?: Record): string | undefined { + const logoUrl = metadata?.logo_url; + if (typeof logoUrl !== "string") { + return undefined; + } + + const trimmedLogoUrl = logoUrl.trim(); + return trimmedLogoUrl.length > 0 ? trimmedLogoUrl : undefined; +} + +function TypeFallbackIcon({ type }: { type: ClientType }) { + if (type === "private") { + return

@@ -556,7 +562,7 @@ function UserListPage() {
requestSort("name_email")} >
@@ -568,7 +574,7 @@ function UserListPage() {
requestSort("status")} >
@@ -577,7 +583,7 @@ function UserListPage() {
requestSort("tenant_dept")} >
@@ -594,7 +600,7 @@ function UserListPage() { visibleColumns[field.key] !== false && ( requestSort(field.key)} >
@@ -605,7 +611,7 @@ function UserListPage() { ), )} requestSort("createdAt")} >
@@ -693,28 +699,42 @@ function UserListPage() { )}
- {" "} +
- + {t(`ui.common.status.${user.status}`, user.status)} diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 79844871..d2b06aea 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -131,12 +131,17 @@ empty = "" import_error = "" import_success = "" loading = "" +no_results = "" subtitle = "" [msg.admin.groups.members] +add_modal_desc = "" add_success = "" +all_added = "" count = "" empty = "" +move_modal_desc = "" +move_success = "" remove_confirm = "" remove_success = "" title = "" @@ -234,6 +239,9 @@ subtitle = "" desc = "" empty = "" limit_notice = "" +remove_confirm = "" +remove_error = "" +remove_success = "" [msg.admin.tenants.registry] count = "" @@ -250,6 +258,7 @@ empty = "" subtitle = "" [msg.admin.users] +confirm_remove_org = "" export_error = "" status_error = "" @@ -827,8 +836,11 @@ unit_level_placeholder = "" title = "" [ui.admin.groups.members] +add_modal_title = "" +move_modal_title = "" [ui.admin.groups.members.table] +actions = "" email = "" name = "" remove = "" @@ -985,8 +997,12 @@ search_placeholder = "" select_placeholder = "" [ui.admin.tenants.members] +add_existing = "" +create_new = "" descendants = "" direct = "" +remove = "" +view_profile = "" [msg.admin.apikeys.registry] count = "" @@ -1013,6 +1029,7 @@ total = "" total_label = "" [ui.admin.tenants.members.table] +actions = "" email = "" name = "" role = "" diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index bb0ebbe6..5657fb42 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -1,12 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { - BookOpenText, - Filter, - Plus, - Search, - X, -} from "lucide-react"; +import { BookOpenText, Filter, Plus, Search, X } from "lucide-react"; import { useEffect, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; diff --git a/devfront/src/features/clients/components/ClientLogo.tsx b/devfront/src/features/clients/components/ClientLogo.tsx index 0c7b0a56..397a1d99 100644 --- a/devfront/src/features/clients/components/ClientLogo.tsx +++ b/devfront/src/features/clients/components/ClientLogo.tsx @@ -1,6 +1,10 @@ import { ServerCog, ShieldHalf } from "lucide-react"; import { useMemo, useState } from "react"; -import { Avatar, AvatarFallback, AvatarImage } from "../../../components/ui/avatar"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "../../../components/ui/avatar"; import type { ClientSummary, ClientType } from "../../../lib/devApi"; import { t } from "../../../lib/i18n"; @@ -28,7 +32,10 @@ function TypeFallbackIcon({ type }: { type: ClientType }) { export function ClientLogo({ client }: ClientLogoProps) { const [didImageFail, setDidImageFail] = useState(false); - const logoUrl = useMemo(() => readLogoUrl(client.metadata), [client.metadata]); + const logoUrl = useMemo( + () => readLogoUrl(client.metadata), + [client.metadata], + ); const showImage = Boolean(logoUrl) && !didImageFail; const clientName = client.name || t("ui.dev.clients.untitled", "Untitled"); diff --git a/locales/en.toml b/locales/en.toml index 2da95a9f..785df7d0 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -131,12 +131,17 @@ empty = "No organization units have been registered yet." import_error = "Import Error" import_success = "Import Success" loading = "Loading..." +no_results = "No groups found." subtitle = "Manage departments and teams under the current tenant." [msg.admin.groups.members] +add_modal_desc = "Search and select members to add from users in this tenant." add_success = "Member added successfully." +all_added = "All tenant members are already in this group." count = "{{count}} members loaded." empty = "No members are assigned to this organization unit." +move_modal_desc = "Select the target group to move the selected member." +move_success = "Member moved successfully." remove_confirm = "Are you sure you want to remove this member?" remove_success = "Member removed successfully." title = "Member Management" @@ -229,6 +234,9 @@ subtitle = "Set the basic tenant profile information." desc = "View the list of users belonging to this organization." empty = "No members found." limit_notice = "Showing members from the first 10 descendant organizations due to size limits." +remove_confirm = "Are you sure you want to exclude '{{name}}' from this organization?" +remove_error = "An error occurred while excluding from organization." +remove_success = "Successfully excluded from organization." [msg.admin.tenants.owners] add_success = "Owner added successfully." @@ -255,6 +263,7 @@ empty = "No child tenants are connected." subtitle = "Review and manage child tenants linked under this tenant." [msg.admin.users] +confirm_remove_org = "Do you want to remove this user from the organization?" export_error = "Failed to export users." status_error = "Failed to update user status." @@ -1026,8 +1035,11 @@ unit_level_placeholder = "Unit Level Placeholder" title = "User Groups" [ui.admin.groups.members] +add_modal_title = "Add Member to Group" +move_modal_title = "Move Department" [ui.admin.groups.members.table] +actions = "ACTIONS" email = "Email" name = "Name" remove = "Remove" @@ -1217,13 +1229,17 @@ self_delete_blocked = "You cannot delete your own account." title = "API Key Registry" [ui.admin.tenants.members] +add_existing = "Assign Existing Member" +create_new = "Create New Member" delete_selected = "Delete Selected" +remove = "Exclude from Organization" view_org_chart = "View Full Org Chart" direct_label = "Direct" list_title = "Member Management" title = "Tenant Members ({{count}})" total = "Total" total_label = "Total" +view_profile = "View Profile" [ui.admin.tenants.import_preview] candidates = "Candidates" @@ -1235,6 +1251,7 @@ no_candidates = "No matching tenants found." title = "Import Preview" [ui.admin.tenants.members.table] +actions = "ACTIONS" email = "EMAIL" name = "NAME" role = "ROLE" diff --git a/locales/ko.toml b/locales/ko.toml index fe5e2203..466eebcf 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -612,12 +612,17 @@ empty = "테넌트에 등록된 조직 단위가 없습니다." import_error = "가져오기 실패" import_success = "조직도가 임포트되었습니다." loading = "로딩 중..." +no_results = "그룹이 없습니다." subtitle = "이 테넌트에 정의된 사용자 그룹 목록입니다." [msg.admin.groups.members] +add_modal_desc = "이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요." add_success = "구성원이 추가되었습니다." +all_added = "모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다." count = "{{count}} 명" empty = "멤버가 없습니다." +move_modal_desc = "선택한 멤버를 이동할 대상 그룹을 선택하세요." +move_success = "멤버가 이동되었습니다." remove_confirm = "제거하시겠습니까?" remove_success = "구성원이 제외되었습니다." title = "[{{name}}] 멤버 관리" @@ -710,6 +715,9 @@ subtitle = "필수 정보만 입력해도 생성 가능합니다. Slug는 없으 desc = "조직에 소속된 사용자 목록을 확인합니다." empty = "소속된 사용자가 없습니다." limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다." +remove_confirm = "'{{name}}'님을 이 조직에서 제외하시겠습니까?" +remove_error = "조직에서 제외하는 중 오류가 발생했습니다." +remove_success = "조직에서 제외되었습니다." [msg.admin.tenants.owners] add_success = "소유자가 추가되었습니다." @@ -736,6 +744,7 @@ empty = "하위 테넌트가 없습니다." subtitle = "현재 테넌트 하위에 생성된 조직입니다." [msg.admin.users] +confirm_remove_org = "이 조직에서 사용자를 제외하시겠습니까?" export_error = "사용자 내보내기에 실패했습니다." status_error = "사용자 상태 변경에 실패했습니다." @@ -1505,8 +1514,11 @@ unit_level_placeholder = "예: 본부, 팀" title = "User Groups" [ui.admin.groups.members] +add_modal_title = "그룹에 멤버 추가" +move_modal_title = "부서 이동" [ui.admin.groups.members.table] +actions = "ACTIONS" email = "이메일" name = "이름" remove = "제거" @@ -1679,13 +1691,17 @@ status_error = "사용자 상태 변경에 실패했습니다: {{error}}" title = "API Key Registry" [ui.admin.tenants.members] +add_existing = "기존 멤버 배정" +create_new = "신규 멤버 생성" delete_selected = "선택 삭제" +remove = "조직에서 제외" view_org_chart = "전체 조직도 보기" direct_label = "직속" list_title = "구성원 관리" title = "테넌트 구성원 ({{count}})" total = "전체" total_label = "전체" +view_profile = "상세 정보" [ui.admin.tenants.import_preview] candidates = "후보" @@ -1697,6 +1713,7 @@ no_candidates = "매칭 가능한 테넌트가 없습니다." title = "임포트 미리보기" [ui.admin.tenants.members.table] +actions = "ACTIONS" email = "EMAIL" name = "NAME" role = "ROLE" diff --git a/locales/template.toml b/locales/template.toml index 0e24f3ec..5627cf0d 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -481,12 +481,17 @@ empty = "" import_error = "" import_success = "" loading = "" +no_results = "" subtitle = "" [msg.admin.groups.members] +add_modal_desc = "" add_success = "" +all_added = "" count = "" empty = "" +move_modal_desc = "" +move_success = "" remove_confirm = "" remove_success = "" title = "" @@ -579,6 +584,9 @@ subtitle = "" desc = "" empty = "" limit_notice = "" +remove_confirm = "" +remove_error = "" +remove_success = "" [msg.admin.tenants.owners] add_success = "" @@ -605,6 +613,7 @@ empty = "" subtitle = "" [msg.admin.users] +confirm_remove_org = "" export_error = "" status_error = "" @@ -1374,8 +1383,11 @@ unit_level_placeholder = "" title = "" [ui.admin.groups.members] +add_modal_title = "" +move_modal_title = "" [ui.admin.groups.members.table] +actions = "" email = "" name = "" remove = "" @@ -1521,13 +1533,17 @@ search_placeholder = "" select_placeholder = "" [ui.admin.tenants.members] +add_existing = "" +create_new = "" descendants = "" direct = "" direct_label = "" list_title = "" +remove = "" title = "" total = "" total_label = "" +view_profile = "" [msg.admin.apikeys.registry] count = "" @@ -1554,15 +1570,20 @@ status_error = "" title = "" [ui.admin.tenants.members] +add_existing = "" +create_new = "" delete_selected = "" +remove = "" view_org_chart = "" direct_label = "" list_title = "" title = "" total = "" total_label = "" +view_profile = "" [ui.admin.tenants.members.table] +actions = "" email = "" name = "" role = "" From 8307f65f6a797cfb4e9876a74b53457b1140ae26 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 8 May 2026 15:23:15 +0900 Subject: [PATCH 5/6] =?UTF-8?q?ef286330a2=20=EB=B0=98=EC=98=81=20code-chec?= =?UTF-8?q?k=20=EC=98=A4=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/src/locales/template.toml | 2 + backend/check_aaa2.go | 3 +- backend/internal/handler/user_handler.go | 91 ++++++++++++------------ locales/en.toml | 4 ++ locales/ko.toml | 4 ++ locales/template.toml | 4 ++ 6 files changed, 62 insertions(+), 46 deletions(-) diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index d2b06aea..f02882e6 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -911,6 +911,8 @@ export_with_ids = "" export_without_ids = "" import = "" title = "" +view.hierarchy = "" +view.list = "" view_org_chart = "" [ui.admin.tenants.domain_conflict] diff --git a/backend/check_aaa2.go b/backend/check_aaa2.go index 29a5df6c..55fe2c0c 100644 --- a/backend/check_aaa2.go +++ b/backend/check_aaa2.go @@ -2,9 +2,10 @@ package main import ( "fmt" + "log" + "gorm.io/driver/postgres" "gorm.io/gorm" - "log" ) type User struct { diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 8e841a6d..d921c33e 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -1620,54 +1620,55 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } } } else { - // Normal update (Move): replace primary company code and remove the old one from existingCodes - currentPrimary := extractTraitString(traits, "companyCode") - if currentPrimary != "" && currentPrimary != code { - // Remove old primary from existingCodes - var newCodes []string - for _, existing := range existingCodes { - if existing != currentPrimary { - newCodes = append(newCodes, existing) - } - } - existingCodes = newCodes + // Normal update (Move): replace primary company code and remove the old one from existingCodes + currentPrimary := extractTraitString(traits, "companyCode") + if currentPrimary != "" && currentPrimary != code { + // Remove old primary from existingCodes + var newCodes []string + for _, existing := range existingCodes { + if existing != currentPrimary { + newCodes = append(newCodes, existing) + } + } + existingCodes = newCodes - // [Keto Sync] Remove membership for the old tenant - if h.TenantService != nil && h.KetoOutboxRepo != nil { - go func(removedSlug string) { - bgCtx := context.Background() - if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil { - _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: t.ID, - Relation: "members", - Subject: "User:" + userID, - Action: domain.KetoOutboxActionDelete, - }) - } - }(currentPrimary) - } - } + // [Keto Sync] Remove membership for the old tenant + if h.TenantService != nil && h.KetoOutboxRepo != nil { + go func(removedSlug string) { + bgCtx := context.Background() + if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil { + _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: t.ID, + Relation: "members", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionDelete, + }) + } + }(currentPrimary) + } + } - traits["companyCode"] = code - // Resolve TenantID for Kratos Trait - if h.TenantService != nil && code != "" { - if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil { - traits["tenant_id"] = tenant.ID - } - } + traits["companyCode"] = code + // Resolve TenantID for Kratos Trait + if h.TenantService != nil && code != "" { + if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil { + traits["tenant_id"] = tenant.ID + } + } - found := false - for _, existing := range existingCodes { - if existing == code { - found = true - break - } - } - if !found && code != "" { - existingCodes = append(existingCodes, code) - } - } } + found := false + for _, existing := range existingCodes { + if existing == code { + found = true + break + } + } + if !found && code != "" { + existingCodes = append(existingCodes, code) + } + } + } // Deduplicate and save back companyCodes var codesToSave []string diff --git a/locales/en.toml b/locales/en.toml index 785df7d0..10e89f32 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -1112,6 +1112,10 @@ seed_badge = "Seed" title = "Tenant Registry" view_org_chart = "View Full Org Chart" +[ui.admin.tenants.view] +hierarchy = "Hierarchy" +list = "List" + [ui.admin.tenants.domain_conflict] description = "" title = "Domain conflict" diff --git a/locales/ko.toml b/locales/ko.toml index 466eebcf..8a9f1f15 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -1587,6 +1587,10 @@ seed_badge = "초기 설정" title = "테넌트 목록" view_org_chart = "전체 조직도 보기" +[ui.admin.tenants.view] +hierarchy = "계층 구조" +list = "평면 목록" + [ui.admin.tenants.admins] add_button = "관리자 추가" already_admin = "이미 관리자" diff --git a/locales/template.toml b/locales/template.toml index 5627cf0d..5c5df6b4 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -1456,6 +1456,10 @@ seed_badge = "" title = "" view_org_chart = "" +[ui.admin.tenants.view] +hierarchy = "" +list = "" + [ui.admin.tenants.admins] add_button = "" already_admin = "" From 636da587e35f38d6f94b7fc42eb842b965f315c1 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 8 May 2026 16:11:50 +0900 Subject: [PATCH 6/6] =?UTF-8?q?5ee9a46663=20=EB=B0=98=EC=98=81=20code-chec?= =?UTF-8?q?k=20=EC=98=A4=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 --- .../src/features/users/UserDetailPage.tsx | 16 ++++---- .../src/features/users/UserListPage.tsx | 37 ++++++------------ scripts/run_adminfront_ci_tests.sh | 39 ++++++++++++++++--- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 9d8cb79c..9d9c1240 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -796,15 +796,15 @@ function UserDetailPage() { payload.metadata = { ...metadata, additionalAppointments: appointments, - primaryTenantId: primaryAppointment?.tenantId, - primaryTenantName: primaryAppointment?.tenantName, - primaryTenantSlug: primaryAppointment?.tenantSlug, - primaryTenantIsOwner: primaryAppointment?.isOwner ?? false, + primaryTenantId: primary?.tenantId, + primaryTenantName: primary?.tenantName, + primaryTenantSlug: primary?.tenantSlug, + primaryTenantIsOwner: primary?.isOwner ?? false, }; - payload.tenantSlug = primaryAppointment?.tenantSlug; - payload.primaryTenantId = primaryAppointment?.tenantId; - payload.primaryTenantName = primaryAppointment?.tenantName; - payload.primaryTenantIsOwner = primaryAppointment?.isOwner ?? false; + payload.tenantSlug = primary?.tenantSlug; + payload.primaryTenantId = primary?.tenantId; + payload.primaryTenantName = primary?.tenantName; + payload.primaryTenantIsOwner = primary?.isOwner ?? false; } mutation.mutate(payload); diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 2123d30a..ad8fa582 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -40,6 +40,7 @@ import { SelectTrigger, SelectValue, } from "../../components/ui/select"; +import { Switch } from "../../components/ui/switch"; import { Table, TableBody, @@ -702,39 +703,25 @@ function UserListPage() {
- + aria-label={t( + "ui.admin.users.list.toggle_status", + "{{name}} 활성 상태", + { name: user.name }, + )} + data-testid={`user-status-toggle-${user.id}`} + /> {t(`ui.common.status.${user.status}`, user.status)} diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh index 3b6549e3..27491756 100755 --- a/scripts/run_adminfront_ci_tests.sh +++ b/scripts/run_adminfront_ci_tests.sh @@ -2,10 +2,36 @@ set -euo pipefail job_name="${1:-adminfront-tests}" +repo_root="$(pwd)" +tmp_dir="" + +cleanup() { + if [ -n "${tmp_dir:-}" ] && [ -d "$tmp_dir" ]; then + rm -rf "$tmp_dir" + fi +} + +trap cleanup EXIT INT TERM mkdir -p reports rm -rf adminfront/node_modules +tmp_dir="$(mktemp -d /tmp/baron-sso-adminfront-tests.XXXXXX)" +playwright_browsers_path="$tmp_dir/ms-playwright" + +if command -v rsync >/dev/null 2>&1; then + rsync -rlptD --delete \ + --exclude 'node_modules' \ + --exclude 'playwright-report' \ + --exclude 'test-results' \ + "$repo_root/adminfront/" "$tmp_dir/adminfront/" +else + cp -R "$repo_root/adminfront" "$tmp_dir/adminfront" + rm -rf "$tmp_dir/adminfront/node_modules" \ + "$tmp_dir/adminfront/playwright-report" \ + "$tmp_dir/adminfront/test-results" +fi + is_port_available() { local port="$1" node -e ' @@ -43,7 +69,7 @@ fi set +e ( - cd adminfront + cd "$tmp_dir/adminfront" npm ci --ignore-scripts ) 2>&1 | tee reports/adminfront-install.log install_exit_code=${PIPESTATUS[0]} @@ -71,8 +97,8 @@ fi set +e ( - cd adminfront - "${playwright_install_cmd[@]}" + cd "$tmp_dir/adminfront" + PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" "${playwright_install_cmd[@]}" ) 2>&1 | tee reports/adminfront-provision.log provision_exit_code=${PIPESTATUS[0]} set -e @@ -106,13 +132,16 @@ if ! is_port_available "$port"; then fi echo "==> adminfront using PORT=$port" ( - cd adminfront - PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \ + cd "$tmp_dir/adminfront" + PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" PLAYWRIGHT_BROWSERS_PATH="$playwright_browsers_path" \ node ./node_modules/playwright/cli.js test ) 2>&1 | tee reports/adminfront-test.log test_exit_code=${PIPESTATUS[0]} set -e +[ -d "$tmp_dir/adminfront/playwright-report" ] && rm -rf reports/adminfront-playwright-report && cp -R "$tmp_dir/adminfront/playwright-report" reports/adminfront-playwright-report || true +[ -d "$tmp_dir/adminfront/test-results" ] && rm -rf reports/adminfront-test-results && cp -R "$tmp_dir/adminfront/test-results" reports/adminfront-test-results || true + if [ "$test_exit_code" -ne 0 ]; then { echo "# Adminfront Test Failure Report"