diff --git a/.env.sample b/.env.sample
index 6b98db23..2200fd2d 100644
--- a/.env.sample
+++ b/.env.sample
@@ -180,6 +180,7 @@ VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
DEVFRONT_URL=http://localhost:5174
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
+ORGFRONT_URL=http://localhost:5175
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback
VITE_ORGCHART_URL=
diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx
index 2a44b16a..07493e1c 100644
--- a/adminfront/src/components/layout/AppLayout.tsx
+++ b/adminfront/src/components/layout/AppLayout.tsx
@@ -270,8 +270,7 @@ function AppLayout() {
if (item.to === "/permissions-direct") return false;
if (item.to === "/tenants") return permissions.tenants;
if (item.to === orgfrontUrl) return permissions.org_chart;
- if (item.to === "/worksmobile")
- return permissions.worksmobile && showWorksmobile;
+ if (item.to === "/worksmobile") return permissions.worksmobile;
if (item.to === "/system/ory-ssot") return permissions.ory_ssot;
if (item.to === "/system/data-integrity")
return permissions.data_integrity;
diff --git a/adminfront/src/features/coverage/adminTenantTabs.test.tsx b/adminfront/src/features/coverage/adminTenantTabs.test.tsx
index 2ec4b09f..efa5ea33 100644
--- a/adminfront/src/features/coverage/adminTenantTabs.test.tsx
+++ b/adminfront/src/features/coverage/adminTenantTabs.test.tsx
@@ -61,7 +61,7 @@ const users = [
id: "user-owner",
name: "Owner User",
email: "owner@example.com",
- role: "tenant_admin",
+ role: "super_admin",
status: "active",
},
{
diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.ts b/adminfront/src/features/tenants/hooks/useTenantPermission.ts
index b7c3c5e8..19241fd7 100644
--- a/adminfront/src/features/tenants/hooks/useTenantPermission.ts
+++ b/adminfront/src/features/tenants/hooks/useTenantPermission.ts
@@ -13,7 +13,9 @@ export type TenantPermissionKey =
| "view_organization"
| "manage_organization"
| "view_schema"
- | "manage_schema";
+ | "manage_schema"
+ | "view_worksmobile"
+ | "manage_worksmobile";
export function useTenantPermission(tenantId: string) {
const { data: profile } = useQuery({
diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx
index 9d2ef487..dc0dbb2c 100644
--- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx
@@ -537,14 +537,18 @@ export function TenantFineGrainedPermissionsPage() {
name: selection.name,
email: selection.email,
tenantSlug: selection.leafTenantName,
- tenant: selection.leafTenantName
- ? {
- id: "",
- slug: "",
- name: selection.leafTenantName,
- createdAt: "",
- updatedAt: "",
- }
+ tenant: selection.leafTenantName
+ ? {
+ id: "",
+ type: "ORGANIZATION",
+ slug: "",
+ name: selection.leafTenantName,
+ description: "",
+ status: "active",
+ memberCount: 0,
+ createdAt: "",
+ updatedAt: "",
+ }
: undefined,
metadata: {
rootTenantName: selection.rootTenantName,
@@ -985,37 +989,51 @@ export function TenantFineGrainedPermissionsPage() {
)}
) : (
-
- {queuedTargetUsers.map((user) => (
-
-
- {user.name}
-
- {(user.metadata?.rootTenantName ||
- user.metadata?.leafTenantName) && (
-
- {[user.metadata?.rootTenantName, user.metadata?.leafTenantName]
- .filter(Boolean)
- .join(" / ")}
-
- )}
- removeQueuedTargetUser(user.id)}
- aria-label={t(
- "ui.admin.permissions_direct.target_queue_remove",
- "적용 대상에서 제거",
- )}
- >
-
-
-
- ))}
-
+
+ {queuedTargetUsers.map((user) => {
+ const rootTenantName =
+ typeof user.metadata?.rootTenantName === "string"
+ ? user.metadata.rootTenantName
+ : "";
+ const leafTenantName =
+ typeof user.metadata?.leafTenantName === "string"
+ ? user.metadata.leafTenantName
+ : "";
+ const tenantPath = [
+ rootTenantName,
+ leafTenantName,
+ ]
+ .filter(Boolean)
+ .join(" / ");
+
+ return (
+
+
+ {user.name}
+
+ {tenantPath !== "" && (
+
+ {tenantPath}
+
+ )}
+ removeQueuedTargetUser(user.id)}
+ aria-label={t(
+ "ui.admin.permissions_direct.target_queue_remove",
+ "적용 대상에서 제거",
+ )}
+ >
+
+
+
+ );
+ })}
+
)}
@@ -1410,258 +1428,6 @@ export function TenantFineGrainedPermissionsPage() {
- {false && (
- <>
- {/* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */}
-
- {/* Left Panel: User List */}
-
-
-
-
- {t("ui.admin.permissions_direct.user_list", "대상 사용자")} (
- {filteredRelations.length})
-
-
-
-
- setUserSearchTerm(e.target.value)}
- name="user-search"
- className="pl-8 h-8 text-xs"
- />
-
-
-
-
- {filteredRelations.length === 0 ? (
-
- {t(
- "msg.admin.permissions_direct.no_users_found",
- "등록된 사용자가 없습니다.",
- )}
-
- ) : (
- filteredRelations.map((user) => {
- const isSelected = activeUserId === user.userId;
- const activeCount = user.relations.length;
-
- return (
-
setActiveUserId(user.userId)}
- className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-all ${
- isSelected
- ? "bg-primary/10 text-primary border-l-4 border-primary shadow-sm"
- : "hover:bg-muted/50 text-foreground"
- }`}
- >
-
-
-
- {user.name.charAt(0)}
-
-
-
-
- {user.name}
-
-
- {user.email}
-
-
-
-
- {activeCount}
-
-
- );
- })
- )}
-
-
-
-
- {/* Right Panel: Toggle settings grid */}
-
- {selectedUser ? (
- <>
- {/* User Detail Header */}
-
-
-
-
- {selectedUser.name.charAt(0)}
-
-
-
-
- {selectedUser.name}
-
- {selectedUser.relations.length}{" "}
- {t("ui.admin.permissions_direct.allowed", "개 허용됨")}
-
-
-
- {selectedUser.email}
-
-
-
-
- handleRemoveAllSystemRelations(
- selectedUser.userId,
- selectedUser.relations,
- )
- }
- >
-
- {t(
- "ui.admin.permissions_direct.revoke_all",
- "모든 권한 회수",
- )}
-
-
-
- {/* Categorized Toggle Grid */}
-
-
- {systemMenuCategories.map((category) => (
-
-
- {category.title}
-
-
-
- {category.menus.map((menu) => {
- const isWrite = selectedUser.relations.includes(
- `${menu.relation}_managers`,
- );
- const isRead = selectedUser.relations.includes(
- `${menu.relation}_viewers`,
- );
- const serverValue: "none" | "read" | "write" =
- isWrite ? "write" : isRead ? "read" : "none";
- const permissionValue =
- localSystemPermissions[selectedUser.userId]?.[
- menu.relation
- ] ?? serverValue;
- const Icon = menu.icon;
-
- return (
-
-
-
-
-
-
-
-
- {menu.label}
-
- {protectedSystemMenuRelations.has(
- menu.relation,
- ) && (
-
- {t(
- "ui.admin.permissions_direct.super_admin_only",
- "Super Admin 전용",
- )}
-
- )}
-
-
- {menu.desc}
-
-
-
-
{
- const nextVal = e.target.value as
- | "none"
- | "read"
- | "write";
- // 🌟 1단계: 로컬 임시 상태 즉시 갱신 (0ms 반응 보장)
- setLocalSystemPermissions((prev) => ({
- ...prev,
- [selectedUser.userId]: {
- ...(prev[selectedUser.userId] ?? {}),
- [menu.relation]: nextVal,
- },
- }));
- // 🌟 2단계: 백그라운드 비동기 API 요청 수행
- handleSystemRelationChange(
- selectedUser.userId,
- menu.relation,
- permissionValue,
- nextVal,
- );
- }}
- className="flex h-9 w-[180px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
- >
-
- {t("ui.common.none", "권한 없음")}
-
-
- {t("ui.common.read", "조회 가능 (Read)")}
-
-
- {t("ui.common.write", "수정 가능 (Write)")}
-
-
-
- );
- })}
-
-
-
- ))}
-
-
- >
- ) : (
-
-
-
-
- {t(
- "ui.admin.permissions_direct.no_user_selected",
- "사용자가 선택되지 않았습니다.",
- )}
-
-
- {t(
- "msg.admin.permissions_direct.no_user_selected_desc",
- "왼쪽의 사용자 리스트에서 권한을 변경할 인원을 선택해 주세요.",
- )}
-
-
-
- )}
-
-
- >
- )}
>
)}
diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx
index 779237b1..b5f3335e 100644
--- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx
+++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx
@@ -31,13 +31,13 @@ import {
import { toast } from "../../../components/ui/use-toast";
import {
addTenantRelation,
+ fetchMe,
fetchTenantRelations,
fetchUsers,
removeTenantRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
-import { useTenantPermission } from "../hooks/useTenantPermission";
interface TenantFineGrainedPermissionsTabProps {
tenantIdProp?: string;
@@ -48,8 +48,11 @@ export function TenantFineGrainedPermissionsTab({
}: TenantFineGrainedPermissionsTabProps = {}) {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdProp || tenantIdParam || "";
- const { hasPermission } = useTenantPermission(tenantId);
- const isWritable = hasPermission("manage_admins");
+ const { data: profile } = useQuery({
+ queryKey: ["me"],
+ queryFn: fetchMe,
+ });
+ const isWritable = profile?.role === "super_admin";
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -75,7 +78,13 @@ export function TenantFineGrainedPermissionsTab({
> = {};
for (const user of relationsQuery.data) {
initialMap[user.userId] = {};
- const tabs = ["profile", "permissions", "organization", "schema"];
+ const tabs = [
+ "profile",
+ "permissions",
+ "organization",
+ "schema",
+ "worksmobile",
+ ];
for (const tab of tabs) {
const isWrite = user.relations.includes(`${tab}_managers`);
const isRead = user.relations.includes(`${tab}_viewers`);
@@ -204,7 +213,7 @@ export function TenantFineGrainedPermissionsTab({
const handleRelationChange = async (
userId: string,
- tab: "profile" | "permissions" | "organization" | "schema",
+ tab: "profile" | "permissions" | "organization" | "schema" | "worksmobile",
currentVal: "none" | "read" | "write",
newVal: "none" | "read" | "write",
) => {
@@ -318,6 +327,14 @@ export function TenantFineGrainedPermissionsTab({
+ {!isWritable && (
+
+ {t(
+ "msg.admin.tenants.relations.super_admin_only_desc",
+ "이 화면의 권한 설정은 시스템 최고 관리자(super_admin)만 수정할 수 있습니다.",
+ )}
+
+ )}
@@ -337,6 +354,12 @@ export function TenantFineGrainedPermissionsTab({
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
+
+ {t(
+ "ui.admin.tenants.detail.tab_worksmobile",
+ "네이버웍스 연동",
+ )}
+
{t("ui.common.action", "작업")}
@@ -346,7 +369,7 @@ export function TenantFineGrainedPermissionsTab({
{relations.length === 0 ? (
{t(
@@ -387,6 +410,14 @@ export function TenantFineGrainedPermissionsTab({
? "read"
: "none";
+ const worksmobileVal = user.relations.includes(
+ "worksmobile_managers",
+ )
+ ? "write"
+ : user.relations.includes("worksmobile_viewers")
+ ? "read"
+ : "none";
+
const curProfileVal =
localTenantPermissions[user.userId]?.profile ??
profileVal;
@@ -398,6 +429,9 @@ export function TenantFineGrainedPermissionsTab({
organizationVal;
const curSchemaVal =
localTenantPermissions[user.userId]?.schema ?? schemaVal;
+ const curWorksmobileVal =
+ localTenantPermissions[user.userId]?.worksmobile ??
+ worksmobileVal;
return (
+
+ {
+ const nextVal = e.target.value as
+ | "none"
+ | "read"
+ | "write";
+ setLocalTenantPermissions((prev) => ({
+ ...prev,
+ [user.userId]: {
+ ...(prev[user.userId] ?? {}),
+ worksmobile: nextVal,
+ },
+ }));
+ handleRelationChange(
+ user.userId,
+ "worksmobile",
+ worksmobileVal,
+ nextVal,
+ );
+ }}
+ >
+
+ {t("ui.common.none", "권한 없음")}
+
+
+ {t("ui.common.read", "조회 가능 (Read)")}
+
+
+ {t("ui.common.write", "수정 가능 (Write)")}
+
+
+
selection?.type === "user" &&
typeof selection.id === "string" &&
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts
index 11818d8f..829c1d04 100644
--- a/adminfront/src/lib/adminApi.ts
+++ b/adminfront/src/lib/adminApi.ts
@@ -45,6 +45,8 @@ export type TenantSummary = {
manage_organization?: boolean;
view_schema?: boolean;
manage_schema?: boolean;
+ view_worksmobile?: boolean;
+ manage_worksmobile?: boolean;
};
createdAt: string;
updatedAt: string;
diff --git a/adminfront/tests/worksmobile.spec.ts b/adminfront/tests/worksmobile.spec.ts
index 75af0911..6395fc89 100644
--- a/adminfront/tests/worksmobile.spec.ts
+++ b/adminfront/tests/worksmobile.spec.ts
@@ -868,7 +868,7 @@ test.describe("Worksmobile tenant management", () => {
await userColumnButton.evaluate((element) => {
element.scrollIntoView({ block: "center", inline: "nearest" });
});
- await userColumnButton.click();
+ await userColumnButton.evaluate((el) => (el as HTMLButtonElement).click());
const settingsDialog = page.getByRole("dialog");
await expect(settingsDialog.getByText("구성원 컬럼 설정")).toBeVisible();
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index 8560eaba..019b1656 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -712,69 +712,21 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
}
// 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다.
- tenantSlug := strings.TrimSpace(req.TenantSlug)
+ // 모든 온라인 가입자는 기본적으로 개인(Personal) 테넌트 소속으로 가입합니다.
+ // 기업/가족사 소속 연동은 별도 문의를 통해 처리되므로 온라인 가입 흐름에서는 제외합니다.
+ req.AffiliationType = "GENERAL"
+ slog.Info("[Signup] Forcing AffiliationType to GENERAL (Default personal tenant signup policy)", "email", req.Email)
+
var tenantID *string
-
- parts := strings.Split(req.Email, "@")
- if len(parts) != 2 {
- return errorJSON(c, fiber.StatusBadRequest, "Invalid email format")
- }
- domainName := parts[1]
-
- // Check if this domain belongs to a predefined family affiliate
- isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
-
- // [Strict Policy] Force AffiliationType based on predefined family slugs (User cannot choose)
- if isInternal {
- req.AffiliationType = "AFFILIATE"
- slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email)
- } else {
- req.AffiliationType = "GENERAL"
- slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email)
- }
-
- if tenantSlug != "" {
- // [Security] Cross-check: If domain is NOT internal, they cannot provide a tenantSlug
- if !isInternal {
- slog.Warn("[Signup] Security violation: non-internal email providing tenantSlug", "email", req.Email)
- return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.")
- }
-
- if !affiliateSlugs[strings.ToLower(tenantSlug)] {
- return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.")
- }
-
- tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
- if err == nil && tenant != nil {
- if tenant.Status == domain.TenantStatusActive {
- slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
- tenantSlug = tenant.Slug
- tenantID = &tenant.ID
- } else {
- return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.")
- }
- } else {
- slog.Warn("[Signup] Attempted to join non-existent organization", "slug", tenantSlug, "email", req.Email)
- return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
- }
- } else {
- // If it's a family affiliate domain, they MUST select one of the family companies
- if isInternal {
- return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.")
- }
- }
-
- if tenantID == nil && req.AffiliationType == "AFFILIATE" {
- return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.")
- }
- if tenantID == nil && req.AffiliationType == "GENERAL" {
- tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, req.Email)
+ tenant, err := h.TenantService.GetTenantBySlug(c.Context(), "personal")
+ if err != nil || tenant == nil {
+ // Fallback: 만약 시드된 personal 테넌트가 없을 경우 개인별 테넌트를 자동 생성합니다.
+ tenant, err = createPersonalTenantForUser(c.Context(), h.TenantService, req.Email)
if err != nil {
- return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
+ return errorJSON(c, fiber.StatusServiceUnavailable, "failed to resolve personal tenant")
}
- tenantSlug = tenant.Slug
- tenantID = &tenant.ID
}
+ tenantID = &tenant.ID
// Normalize Phone (E.164 형태로 보관)
normalizedPhone := domain.NormalizePhoneNumber(req.Phone)
diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go
index 0ee23240..7d1e479e 100644
--- a/backend/internal/handler/auth_handler_signup_test.go
+++ b/backend/internal/handler/auth_handler_signup_test.go
@@ -116,22 +116,19 @@ func TestSignup_TenantSlugValidation(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
- t.Run("Active Tenant Slug", func(t *testing.T) {
+ t.Run("Success creates Personal Tenant", func(t *testing.T) {
reqBody := domain.SignupRequest{
Email: "user@hanmaceng.co.kr",
Password: "StrongPass123!",
Name: "Test User",
Phone: "010-1234-5678",
TermsAccepted: true,
- TenantSlug: "hanmac",
}
body, _ := json.Marshal(reqBody)
- validTenant := &domain.Tenant{ID: "t1", Slug: "hanmac", Status: domain.TenantStatusActive}
- mockTenantSvc.On("GetTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(&domain.Tenant{Slug: "hanmac"}, nil).Once()
- mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(validTenant, nil).Maybe()
- mockTenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac").Return(validTenant, nil).Once()
- mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil).Once()
+ validTenant := &domain.Tenant{ID: "personal-t1", Slug: "personal-slug", Status: domain.TenantStatusActive}
+ mockTenantSvc.On("GetTenantBySlug", mock.Anything, "personal").Return((*domain.Tenant)(nil), assert.AnError).Once()
+ mockTenantSvc.On("RegisterTenant", mock.Anything, "Personal - user@hanmaceng.co.kr", mock.Anything, domain.TenantTypePersonal, "Automatically provisioned personal tenant", []string(nil), (*string)(nil), "").Return(validTenant, nil).Once()
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once()
mockRedis.On("Delete", mock.Anything).Return(nil)
diff --git a/backend/internal/handler/tenant_assignment_policy.go b/backend/internal/handler/tenant_assignment_policy.go
index fa381207..63678875 100644
--- a/backend/internal/handler/tenant_assignment_policy.go
+++ b/backend/internal/handler/tenant_assignment_policy.go
@@ -135,6 +135,9 @@ func createPersonalTenantForUser(ctx context.Context, tenantService service.Tena
normalizedEmail = "user"
}
slug := "personal-" + strings.ReplaceAll(uuid.NewString(), "-", "")
+ if len(slug) > 32 {
+ slug = slug[:32]
+ }
tenant, err := tenantService.RegisterTenant(
ctx,
fmt.Sprintf("Personal - %s", normalizedEmail),
diff --git a/backend/internal/handler/tenant_assignment_policy_test.go b/backend/internal/handler/tenant_assignment_policy_test.go
new file mode 100644
index 00000000..9fa3d9ea
--- /dev/null
+++ b/backend/internal/handler/tenant_assignment_policy_test.go
@@ -0,0 +1,48 @@
+package handler
+
+import (
+ "baron-sso-backend/internal/domain"
+ "baron-sso-backend/internal/utils"
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+func TestCreatePersonalTenantForUser_SlugLength(t *testing.T) {
+ mockTenantService := &MockTenantService{}
+ ctx := context.Background()
+
+ var capturedSlug string
+ mockTenantService.On(
+ "RegisterTenant",
+ ctx,
+ "Personal - user@example.com",
+ mock.AnythingOfType("string"),
+ domain.TenantTypePersonal,
+ "Automatically provisioned personal tenant",
+ []string(nil),
+ (*string)(nil),
+ "",
+ ).Run(func(args mock.Arguments) {
+ capturedSlug = args.String(2)
+ }).Return(&domain.Tenant{
+ ID: "personal-tenant-id",
+ Slug: "personal-slug",
+ Name: "Personal - user@example.com",
+ }, nil)
+
+ tenant, err := createPersonalTenantForUser(ctx, mockTenantService, "user@example.com")
+ assert.NoError(t, err)
+ assert.NotNil(t, tenant)
+
+ // Ensure the generated slug is strictly 32 characters or less
+ assert.LessOrEqual(t, len(capturedSlug), 32)
+ assert.True(t, strings.HasPrefix(capturedSlug, "personal-"))
+
+ // Ensure that the captured slug actually passes ValidateSlug!
+ valid, msg := utils.ValidateSlug(capturedSlug)
+ assert.True(t, valid, "Slug must be valid: "+msg)
+}
diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go
index 1c965a51..eb9d965e 100644
--- a/backend/internal/handler/tenant_handler.go
+++ b/backend/internal/handler/tenant_handler.go
@@ -108,6 +108,8 @@ type tenantPermissions struct {
ManageOrganization bool `json:"manage_organization"`
ViewSchema bool `json:"view_schema"`
ManageSchema bool `json:"manage_schema"`
+ ViewWorksmobile bool `json:"view_worksmobile"`
+ ManageWorksmobile bool `json:"manage_worksmobile"`
}
type tenantSummary struct {
@@ -1972,6 +1974,8 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
ManageOrganization: true,
ViewSchema: true,
ManageSchema: true,
+ ViewWorksmobile: true,
+ ManageWorksmobile: true,
}
} else {
// Query Keto in parallel for maximum performance
@@ -1981,13 +1985,14 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
allowed bool
err error
}
- ch := make(chan checkResult, 11)
+ ch := make(chan checkResult, 13)
relations := []string{
"view", "manage", "manage_admins",
"view_profile", "manage_profile",
"view_permissions", "manage_permissions",
"view_organization", "manage_organization",
"view_schema", "manage_schema",
+ "view_worksmobile", "manage_worksmobile",
}
for _, rel := range relations {
go func(r string) {
@@ -2026,6 +2031,10 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
perms.ViewSchema = res.allowed
case "manage_schema":
perms.ManageSchema = res.allowed
+ case "view_worksmobile":
+ perms.ViewWorksmobile = res.allowed
+ case "manage_worksmobile":
+ perms.ManageWorksmobile = res.allowed
}
}
summary.UserPermissions = perms
diff --git a/devfront/Dockerfile b/devfront/Dockerfile
index ed1c21b0..9cfb2134 100644
--- a/devfront/Dockerfile
+++ b/devfront/Dockerfile
@@ -14,9 +14,11 @@ COPY devfront ./devfront
ARG VITE_DEVFRONT_PUBLIC_URL
ARG VITE_OIDC_AUTHORITY
ARG VITE_OIDC_CLIENT_ID
+ARG ORGFRONT_URL
ENV VITE_DEVFRONT_PUBLIC_URL=$VITE_DEVFRONT_PUBLIC_URL
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
+ENV ORGFRONT_URL=$ORGFRONT_URL
RUN pnpm install --frozen-lockfile --ignore-scripts
diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx
index 726bfe06..2fa975a5 100644
--- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx
@@ -450,6 +450,44 @@ describe("ClientGeneralPage RP claims", () => {
expect(scopeInputs.some((input) => input.value === "old_claim")).toBe(true);
});
+ it("shows the offline_access guide in the scopes section and expands its details", async () => {
+ const { container } = await renderPage();
+
+ expect(container.textContent).toContain(
+ "Refresh token 사용 시 offline_access scope가 필요합니다.",
+ );
+ expect(container.textContent).toContain(
+ "scope 목록에 offline_access를 포함하고",
+ );
+
+ const guideToggleButton = Array.from(
+ container.querySelectorAll("button"),
+ ).find((button) =>
+ (button.getAttribute("aria-label") ?? "").includes("상세 안내 보기"),
+ );
+ expect(guideToggleButton).toBeDefined();
+
+ await act(async () => {
+ guideToggleButton?.dispatchEvent(
+ new MouseEvent("click", { bubbles: true }),
+ );
+ });
+ await flush();
+
+ expect(container.textContent).toContain(
+ "Hydra 기준으로 refresh token 발급 조건",
+ );
+ expect(container.textContent).toContain(
+ "authorization request scope에 offline 또는 offline_access 포함",
+ );
+ expect(container.textContent).toContain(
+ "consent accept의 granted_scope에 offline 또는 offline_access 포함",
+ );
+ expect(container.textContent).toContain(
+ "client grant_types에 refresh_token 포함",
+ );
+ });
+
it("blocks saving a number RP claim default value that is not numeric", async () => {
const { container } = await renderPage();
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index c68afeec..3ab2cd64 100644
--- a/devfront/src/features/clients/ClientGeneralPage.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.tsx
@@ -639,6 +639,8 @@ function ClientGeneralPage() {
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
const [isScopePickerOpen, setIsScopePickerOpen] = useState(false);
+ const [isOfflineAccessGuideOpen, setIsOfflineAccessGuideOpen] =
+ useState(false);
const [scopes, setScopes] = useState(() => [
{
id: "1",
@@ -1176,14 +1178,14 @@ function ClientGeneralPage() {
if (!trimmedJwksUri) {
validationErrors.push(
t(
- "msg.dev.clients.general.public_key.validation.missing_jwks_uri",
+ "ui.dev.clients.general.public_key.validation.missing_jwks_uri",
"JWKS URI를 입력해야 합니다.",
),
);
} else if (!isValidUrl(trimmedJwksUri)) {
validationErrors.push(
t(
- "msg.dev.clients.general.public_key.validation.invalid_jwks_uri",
+ "ui.dev.clients.general.public_key.validation.invalid_jwks_uri",
"JWKS URI 형식이 올바르지 않습니다.",
),
);
@@ -1191,7 +1193,7 @@ function ClientGeneralPage() {
if (unsupportedParsedAlgorithms.length > 0) {
validationErrors.push(
t(
- "msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms",
+ "ui.dev.clients.general.public_key.validation.unsupported_parsed_algorithms",
"JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}",
{ details: unsupportedParsedAlgorithmSummary },
),
@@ -1200,7 +1202,7 @@ function ClientGeneralPage() {
if (missingParsedAlgorithms.length > 0) {
validationErrors.push(
t(
- "msg.dev.clients.general.public_key.validation.missing_parsed_algorithms",
+ "ui.dev.clients.general.public_key.validation.missing_parsed_algorithms",
"JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}",
{ details: missingParsedAlgorithmSummary },
),
@@ -1970,6 +1972,77 @@ function ClientGeneralPage() {
+
+
+
+
+
+
+ {t(
+ "ui.dev.clients.general.scopes.offline_access_title",
+ "Refresh token 사용 시 offline_access scope가 필요합니다.",
+ )}
+
+
+
+ {t(
+ "msg.dev.clients.general.scopes.offline_access_summary",
+ "RP가 refresh token을 사용하려면 scope 목록에 offline_access를 포함하고, consent와 grant type 설정도 함께 맞아야 합니다.",
+ )}
+
+
+
setIsOfflineAccessGuideOpen((prev) => !prev)}
+ aria-expanded={isOfflineAccessGuideOpen}
+ aria-label={t(
+ "ui.dev.clients.general.scopes.offline_access_toggle",
+ "상세 안내 보기",
+ )}
+ >
+ {isOfflineAccessGuideOpen ? (
+
+ ) : (
+
+ )}
+ {t("ui.common.info", "상세 안내")}
+
+
+ {isOfflineAccessGuideOpen ? (
+
+
+ {t(
+ "msg.dev.clients.general.scopes.offline_access_conditions_title",
+ "Hydra 기준으로 refresh token 발급 조건",
+ )}
+
+
+
+ {t(
+ "msg.dev.clients.general.scopes.offline_access_condition_request",
+ "authorization request scope에 offline 또는 offline_access 포함",
+ )}
+
+
+ {t(
+ "msg.dev.clients.general.scopes.offline_access_condition_consent",
+ "consent accept의 granted_scope에 offline 또는 offline_access 포함",
+ )}
+
+
+ {t(
+ "msg.dev.clients.general.scopes.offline_access_condition_grant_type",
+ "client grant_types에 refresh_token 포함",
+ )}
+
+
+
+ ) : null}
+
+
{isScopePickerOpen && (
@@ -1977,13 +2050,13 @@ function ClientGeneralPage() {
{t(
"ui.dev.clients.general.scopes.picker_title",
- "추가할 scope 선택",
+ "Add a scope",
)}
{t(
- "msg.dev.clients.general.scopes.picker_help",
- "지원 scope와 Custom Claim key를 선택해 scope 목록에 추가합니다.",
+ "ui.dev.clients.general.scopes.picker_help",
+ "Choose a supported scope or custom claim key to add it to the scope list.",
)}
@@ -2357,7 +2430,7 @@ function ClientGeneralPage() {
{t(
"ui.dev.clients.general.tenant_access.picker_label",
- "허용 테넌트 추가",
+ "Add allowed tenant",
)}{" "}
*
@@ -2996,8 +3069,8 @@ function ClientGeneralPage() {
{t(
- "msg.dev.clients.general.security.headless_login_enable_help",
- "Baron SSO 로그인 창 대신 RP 자체 로그인 UI를 사용하고, RP backend의 서명 키로 클라이언트를 검증하려는 경우 활성화합니다.",
+ "ui.dev.clients.general.security.headless_login_enable_help",
+ "Enable this when the RP uses its own login UI instead of the Baron SSO login page and the RP backend validates the client with a signing key.",
)}
diff --git a/devfront/src/features/clients/components/TenantAccessPicker.tsx b/devfront/src/features/clients/components/TenantAccessPicker.tsx
index 259bc938..e923d227 100644
--- a/devfront/src/features/clients/components/TenantAccessPicker.tsx
+++ b/devfront/src/features/clients/components/TenantAccessPicker.tsx
@@ -16,11 +16,7 @@ type TenantAccessPickerProps = {
};
function resolveOrgFrontBaseUrl() {
- return (
- import.meta.env.VITE_ORGFRONT_PUBLIC_URL ||
- import.meta.env.ORGFRONT_URL ||
- "http://localhost:5175"
- );
+ return import.meta.env.ORGFRONT_URL || "http://localhost:5175";
}
export function TenantAccessPicker({
@@ -57,7 +53,7 @@ export function TenantAccessPicker({
aria-modal="true"
aria-label={t(
"ui.dev.clients.general.tenant_access.picker_title",
- "테넌트 선택",
+ "Select tenant",
)}
>
@@ -66,13 +62,13 @@ export function TenantAccessPicker({
{t(
"ui.dev.clients.general.tenant_access.picker_title",
- "테넌트 선택",
+ "Select tenant",
)}
{t(
- "msg.dev.clients.general.tenant_access.picker_description",
- "orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다.",
+ "ui.dev.clients.general.tenant_access.picker_description",
+ "Choose the tenants to allow from the orgfront org chart and add them to the list.",
)}
@@ -83,7 +79,7 @@ export function TenantAccessPicker({
className="shrink-0"
onClick={() => setPickerOpen(false)}
>
- {t("ui.common.close", "닫기")}
+ {t("ui.common.close", "Close")}
@@ -102,7 +98,7 @@ export function TenantAccessPicker({
variant="outline"
onClick={() => setPickerOpen(false)}
>
- {t("ui.common.close", "닫기")}
+ {t("ui.common.close", "Close")}
@@ -123,7 +119,7 @@ export function TenantAccessPicker({
{t(
"ui.dev.clients.general.tenant_access.open_picker",
- "테넌트 선택기 열기",
+ "Open tenant picker",
)}
@@ -132,13 +128,13 @@ export function TenantAccessPicker({
{selectedCount > 0
? t(
- "msg.dev.clients.general.tenant_access.picker_hint_with_count",
- "현재 {{count}}개가 선택되어 있습니다.",
+ "ui.dev.clients.general.tenant_access.picker_hint_with_count",
+ "{{count}} tenants selected.",
{ count: selectedCount },
)
: t(
- "msg.dev.clients.general.tenant_access.picker_hint",
- "선택기를 열어 허용 테넌트를 추가하세요.",
+ "ui.dev.clients.general.tenant_access.picker_hint",
+ "Open the picker to add allowed tenants.",
)}
diff --git a/devfront/src/features/developer-access/developerAccessPages.test.ts b/devfront/src/features/developer-access/developerAccessPages.test.ts
index 2415cf42..22df6dd5 100644
--- a/devfront/src/features/developer-access/developerAccessPages.test.ts
+++ b/devfront/src/features/developer-access/developerAccessPages.test.ts
@@ -1,5 +1,18 @@
-import { describe, expect, it } from "vitest";
-import { normalizeDeveloperAccessPageSelection } from "./developerAccessPages";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ developerAccessPagesToLabel,
+ getDeveloperAccessPageLabel,
+ normalizeDeveloperAccessPageSelection,
+} from "./developerAccessPages";
+
+beforeEach(() => {
+ window.localStorage.clear();
+ window.localStorage.setItem("locale", "ko");
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+});
describe("developer access pages", () => {
it("collapses all non-all pages into all", () => {
@@ -21,4 +34,18 @@ describe("developer access pages", () => {
it("keeps explicit all selection", () => {
expect(normalizeDeveloperAccessPageSelection(["all"])).toEqual(["all"]);
});
+
+ it("returns localized labels for access pages", () => {
+ expect(getDeveloperAccessPageLabel("all")).toBe("전체");
+ expect(developerAccessPagesToLabel(["overview", "audit"])).toBe(
+ "개요, 감사로그",
+ );
+
+ window.localStorage.setItem("locale", "en");
+
+ expect(getDeveloperAccessPageLabel("client_create")).toBe("Add linked app");
+ expect(developerAccessPagesToLabel(["overview", "audit"])).toBe(
+ "Overview, Audit Logs",
+ );
+ });
});
diff --git a/devfront/src/features/developer-access/developerAccessPages.ts b/devfront/src/features/developer-access/developerAccessPages.ts
index ce7608f1..0e44187f 100644
--- a/devfront/src/features/developer-access/developerAccessPages.ts
+++ b/devfront/src/features/developer-access/developerAccessPages.ts
@@ -1,3 +1,5 @@
+import { t } from "../../lib/i18n";
+
export type DeveloperAccessPage =
| "all"
| "overview"
@@ -10,15 +12,40 @@ export const developerAccessPageOrder: DeveloperAccessPage[] = [
"audit",
];
-export const developerAccessPageOptions: Array<{
+export function getDeveloperAccessPageLabel(page: DeveloperAccessPage): string {
+ switch (page) {
+ case "all":
+ return t("ui.dev.access_pages.all", "전체");
+ case "overview":
+ return t("ui.dev.access_pages.overview", "개요");
+ case "client_create":
+ return t("ui.dev.access_pages.client_create", "연동 앱 추가");
+ case "audit":
+ return t("ui.dev.access_pages.audit", "감사로그");
+ default:
+ return page;
+ }
+}
+
+export function getDeveloperAccessPageOptions(): Array<{
value: DeveloperAccessPage;
label: string;
-}> = [
- { value: "all", label: "전체" },
- { value: "overview", label: "개요" },
- { value: "client_create", label: "연동 앱 추가" },
- { value: "audit", label: "감사로그" },
-];
+}> {
+ return developerAccessPageOrder.length > 0
+ ? [
+ { value: "all", label: getDeveloperAccessPageLabel("all") },
+ {
+ value: "overview",
+ label: getDeveloperAccessPageLabel("overview"),
+ },
+ {
+ value: "client_create",
+ label: getDeveloperAccessPageLabel("client_create"),
+ },
+ { value: "audit", label: getDeveloperAccessPageLabel("audit") },
+ ]
+ : [];
+}
export function normalizeDeveloperAccessPages(
pages: Array,
@@ -61,20 +88,11 @@ export function normalizeDeveloperAccessPageSelection(
export function developerAccessPagesToLabel(pages?: Array) {
const normalized = normalizeDeveloperAccessPages(pages ?? []);
if (normalized.length === 0 || normalized.includes("all")) {
- return "전체";
+ return getDeveloperAccessPageLabel("all");
}
return normalized
.map((page) => {
- switch (page) {
- case "overview":
- return "개요";
- case "client_create":
- return "연동 앱 추가";
- case "audit":
- return "감사로그";
- default:
- return page;
- }
+ return getDeveloperAccessPageLabel(page);
})
.join(", ");
}
diff --git a/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx b/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx
index 8b967b86..79249fd8 100644
--- a/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx
+++ b/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx
@@ -38,7 +38,8 @@ import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
import {
type DeveloperAccessPage,
- developerAccessPageOptions,
+ developerAccessPagesToLabel,
+ getDeveloperAccessPageOptions,
normalizeDeveloperAccessPageSelection,
normalizeDeveloperAccessPages,
} from "../developer-access/developerAccessPages";
@@ -62,6 +63,7 @@ export default function DeveloperGrantsPage() {
});
const profileRole = me?.role?.trim() || role;
const isSuperAdmin = profileRole === "super_admin";
+ const developerAccessPageOptions = getDeveloperAccessPageOptions();
const [userSearch, setUserSearch] = useState("");
const deferredUserSearch = useDeferredValue(userSearch.trim());
@@ -621,9 +623,7 @@ export default function DeveloperGrantsPage() {
: ["all"]
).map((page) => (
- {developerAccessPageOptions.find(
- (option) => option.value === page,
- )?.label ?? page}
+ {developerAccessPagesToLabel([page])}
))}
diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.tsx
index 5f11a631..fee23d91 100644
--- a/devfront/src/features/developer-request/DeveloperRequestPage.tsx
+++ b/devfront/src/features/developer-request/DeveloperRequestPage.tsx
@@ -49,7 +49,8 @@ import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
import {
type DeveloperAccessPage,
- developerAccessPageOptions,
+ developerAccessPagesToLabel,
+ getDeveloperAccessPageOptions,
normalizeDeveloperAccessPageSelection,
normalizeDeveloperAccessPages,
} from "../developer-access/developerAccessPages";
@@ -287,9 +288,7 @@ export default function DeveloperRequestPage() {
req.accessPages,
).map((page) => (
- {developerAccessPageOptions.find(
- (option) => option.value === page,
- )?.label ?? page}
+ {developerAccessPagesToLabel([page])}
))
) : (
@@ -479,6 +478,7 @@ function RequestAccessModal({
const [accessPages, setAccessPages] = useState([
"all",
]);
+ const developerAccessPageOptions = getDeveloperAccessPageOptions();
const organizationDisplay = organization.trim() || t("ui.common.na", "없음");
useEffect(() => {
diff --git a/devfront/src/features/overview/recentClientChanges.test.ts b/devfront/src/features/overview/recentClientChanges.test.ts
index b69c9a28..2dee876e 100644
--- a/devfront/src/features/overview/recentClientChanges.test.ts
+++ b/devfront/src/features/overview/recentClientChanges.test.ts
@@ -89,6 +89,127 @@ describe("recent client changes", () => {
{ label: "Relation", value: "admins" },
{ label: "Subject", value: "User:1" },
]);
+
+ expect(
+ buildRecentClientChangeDetails("ADD_RELATION", {
+ relation: "config_editor",
+ subject: "User:2",
+ }),
+ ).toEqual([
+ { label: "Relation", value: "config_editor" },
+ { label: "Subject", value: "User:2" },
+ ]);
+ });
+
+ it("ignores audit object key order changes in update details", () => {
+ mockLocale("ko");
+
+ expect(
+ buildRecentClientChangeDetails("UPDATE_CLIENT", {
+ before: {
+ id_token_claims: [
+ {
+ key: "license",
+ namespace: "rp_claims",
+ nullable: true,
+ readPermission: "admin_only",
+ value: "",
+ valueType: "text",
+ writePermission: "admin_only",
+ },
+ {
+ key: "date",
+ namespace: "rp_claims",
+ nullable: true,
+ readPermission: "admin_only",
+ value: "",
+ valueType: "date",
+ writePermission: "admin_only",
+ },
+ ],
+ },
+ after: {
+ id_token_claims: [
+ {
+ namespace: "rp_claims",
+ key: "license",
+ value: "",
+ valueType: "text",
+ nullable: true,
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ {
+ namespace: "rp_claims",
+ key: "date",
+ value: "",
+ valueType: "date",
+ nullable: true,
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ ],
+ },
+ }),
+ ).toEqual([]);
+ });
+
+ it("summarizes id_token_claims additions and removals", () => {
+ mockLocale("ko");
+
+ expect(
+ buildRecentClientChangeDetails("UPDATE_CLIENT", {
+ before: {
+ id_token_claims: [
+ {
+ namespace: "rp_claims",
+ key: "license",
+ value: "",
+ valueType: "text",
+ nullable: true,
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ {
+ namespace: "rp_claims",
+ key: "date",
+ value: "",
+ valueType: "date",
+ nullable: true,
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ ],
+ },
+ after: {
+ id_token_claims: [
+ {
+ namespace: "rp_claims",
+ key: "license",
+ value: "",
+ valueType: "text",
+ nullable: true,
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ {
+ namespace: "rp_claims",
+ key: "test",
+ value: "",
+ valueType: "text",
+ nullable: true,
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ ],
+ },
+ }),
+ ).toEqual([
+ {
+ label: "커스텀 클레임",
+ value: "+ test (text), - date (date)",
+ },
+ ]);
});
it("builds recent client changes with sorting, filtering, and detail slicing", () => {
@@ -192,6 +313,40 @@ describe("recent client changes", () => {
after: { name: "Ignored" },
},
),
+ makeAuditLog(
+ "evt-9",
+ "2026-05-27T15:00:00.000Z",
+ "UPDATE_CLIENT",
+ "client-a",
+ {
+ before: {
+ id_token_claims: [
+ {
+ key: "license",
+ namespace: "rp_claims",
+ nullable: true,
+ readPermission: "admin_only",
+ value: "",
+ valueType: "text",
+ writePermission: "admin_only",
+ },
+ ],
+ },
+ after: {
+ id_token_claims: [
+ {
+ namespace: "rp_claims",
+ key: "license",
+ value: "",
+ valueType: "text",
+ nullable: true,
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ ],
+ },
+ },
+ ),
];
const changes = buildRecentClientChanges(auditLogs, clients);
diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts
index 3e6614a0..b331346d 100644
--- a/devfront/src/features/overview/recentClientChanges.ts
+++ b/devfront/src/features/overview/recentClientChanges.ts
@@ -33,6 +33,27 @@ function isRecord(value: unknown): value is Record {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
+function normalizeAuditValue(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map((item) => normalizeAuditValue(item));
+ }
+
+ if (isRecord(value)) {
+ return Object.keys(value)
+ .sort()
+ .reduce>((acc, key) => {
+ acc[key] = normalizeAuditValue(value[key]);
+ return acc;
+ }, {});
+ }
+
+ return value;
+}
+
+function auditValueSignature(value: unknown) {
+ return JSON.stringify(normalizeAuditValue(value));
+}
+
export function getRecentClientActionLabel(action: string) {
switch (action) {
case "CREATE_CLIENT":
@@ -74,17 +95,174 @@ function getRecentClientFieldLabel(key: string) {
"ui.dev.clients.details.credentials.client_secret",
"클라이언트 시크릿",
);
+ case "id_token_claims":
+ return t("ui.dev.clients.general.id_token_claims.title", "Custom Claims");
default:
return key;
}
}
+function getIdTokenClaimIdentity(claim: Record) {
+ const namespace =
+ typeof claim.namespace === "string" && claim.namespace
+ ? claim.namespace
+ : null;
+ const key = typeof claim.key === "string" && claim.key ? claim.key : null;
+
+ if (!namespace || !key) {
+ return null;
+ }
+
+ return { namespace, key };
+}
+
+function formatIdTokenClaimDisplayName(claim: Record) {
+ const identity = getIdTokenClaimIdentity(claim);
+ if (!identity) {
+ return "unknown";
+ }
+ if (identity.namespace === "rp_claims") {
+ return identity.key;
+ }
+ return `${identity.namespace}:${identity.key}`;
+}
+
+function isSimpleAuditScalar(value: unknown) {
+ return (
+ value === null ||
+ value === undefined ||
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ );
+}
+
+function formatIdTokenClaimChangeSummary(
+ beforeValue: unknown,
+ afterValue: unknown,
+) {
+ if (!isRecord(beforeValue) || !isRecord(afterValue)) {
+ return null;
+ }
+
+ const beforeDisplayName = formatIdTokenClaimDisplayName(beforeValue);
+ const afterDisplayName = formatIdTokenClaimDisplayName(afterValue);
+ if (beforeDisplayName !== afterDisplayName) {
+ return `~ ${beforeDisplayName} → ${afterDisplayName}`;
+ }
+
+ const beforeValueType =
+ typeof beforeValue.valueType === "string" ? beforeValue.valueType : null;
+ const afterValueType =
+ typeof afterValue.valueType === "string" ? afterValue.valueType : null;
+
+ if (beforeValueType && afterValueType && beforeValueType !== afterValueType) {
+ return `~ ${beforeDisplayName}: ${beforeValueType} → ${afterValueType}`;
+ }
+
+ const beforeScalar = beforeValue.value;
+ const afterScalar = afterValue.value;
+ if (
+ isSimpleAuditScalar(beforeScalar) &&
+ isSimpleAuditScalar(afterScalar) &&
+ formatAuditValue(beforeScalar) !== formatAuditValue(afterScalar)
+ ) {
+ return `~ ${beforeDisplayName}: ${formatAuditValue(beforeScalar)} → ${formatAuditValue(afterScalar)}`;
+ }
+
+ return `~ ${beforeDisplayName}`;
+}
+
+function summarizeIdTokenClaimArrayChange(
+ beforeValue: unknown,
+ afterValue: unknown,
+) {
+ if (!Array.isArray(beforeValue) || !Array.isArray(afterValue)) {
+ return null;
+ }
+
+ const beforeClaims = beforeValue.filter(isRecord);
+ const afterClaims = afterValue.filter(isRecord);
+ const beforeByIdentity = new Map>();
+ const afterByIdentity = new Map>();
+
+ for (const claim of beforeClaims) {
+ const identity = getIdTokenClaimIdentity(claim);
+ if (identity) {
+ beforeByIdentity.set(`${identity.namespace}:${identity.key}`, claim);
+ }
+ }
+
+ for (const claim of afterClaims) {
+ const identity = getIdTokenClaimIdentity(claim);
+ if (identity) {
+ afterByIdentity.set(`${identity.namespace}:${identity.key}`, claim);
+ }
+ }
+
+ const additions: string[] = [];
+ const removals: string[] = [];
+ const updates: string[] = [];
+
+ for (const [identity, afterClaim] of afterByIdentity.entries()) {
+ const beforeClaim = beforeByIdentity.get(identity);
+ const displayName = formatIdTokenClaimDisplayName(afterClaim);
+
+ if (!beforeClaim) {
+ const valueType =
+ typeof afterClaim.valueType === "string" ? afterClaim.valueType : null;
+ additions.push(
+ valueType ? `+ ${displayName} (${valueType})` : `+ ${displayName}`,
+ );
+ continue;
+ }
+
+ if (auditValueSignature(beforeClaim) === auditValueSignature(afterClaim)) {
+ continue;
+ }
+
+ const summary = formatIdTokenClaimChangeSummary(beforeClaim, afterClaim);
+ if (summary) {
+ updates.push(summary);
+ }
+ }
+
+ for (const [identity, beforeClaim] of beforeByIdentity.entries()) {
+ if (afterByIdentity.has(identity)) {
+ continue;
+ }
+ const displayName = formatIdTokenClaimDisplayName(beforeClaim);
+ const valueType =
+ typeof beforeClaim.valueType === "string" ? beforeClaim.valueType : null;
+ removals.push(
+ valueType ? `- ${displayName} (${valueType})` : `- ${displayName}`,
+ );
+ }
+
+ const parts = [...additions, ...removals, ...updates].slice(0, 4);
+ if (parts.length === 0) {
+ return null;
+ }
+
+ if (additions.length + removals.length + updates.length > parts.length) {
+ parts.push("...");
+ }
+
+ return parts.join(", ");
+}
+
export function buildRecentClientChangeDetails(
action: string,
details: AuditDetails,
) {
const before = isRecord(details.before) ? details.before : {};
const after = isRecord(details.after) ? details.after : {};
+ const sourceDetails =
+ action === "ADD_RELATION"
+ ? { ...after, ...details }
+ : action === "REMOVE_RELATION"
+ ? { ...before, ...details }
+ : {};
if (action === "ROTATE_SECRET") {
return [
@@ -96,21 +274,23 @@ export function buildRecentClientChangeDetails(
}
if (action === "ADD_RELATION" || action === "REMOVE_RELATION") {
- const source = action === "ADD_RELATION" ? after : before;
+ const source = sourceDetails as Record;
+ const relation = source.relation;
+ const subject = source.subject;
return [
- ...(source.relation
+ ...(typeof relation === "string" && relation
? [
{
label: getRecentClientFieldLabel("relation"),
- value: formatAuditValue(source.relation),
+ value: formatAuditValue(relation),
},
]
: []),
- ...(source.subject
+ ...(typeof subject === "string" && subject
? [
{
label: getRecentClientFieldLabel("subject"),
- value: formatAuditValue(source.subject),
+ value: formatAuditValue(subject),
},
]
: []),
@@ -126,8 +306,21 @@ export function buildRecentClientChangeDetails(
const beforeValue = before[key];
const afterValue = after[key];
+ if (key === "id_token_claims") {
+ const value = summarizeIdTokenClaimArrayChange(beforeValue, afterValue);
+ if (!value) {
+ return null;
+ }
+ return {
+ label: getRecentClientFieldLabel(key),
+ value,
+ };
+ }
+
if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") {
- if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) {
+ if (
+ auditValueSignature(beforeValue) === auditValueSignature(afterValue)
+ ) {
return null;
}
}
@@ -161,6 +354,10 @@ export function buildRecentClientChangeDetails(
})
.filter((item): item is { label: string; value: string } => Boolean(item));
+ if (changes.length === 0) {
+ return [];
+ }
+
return changes.slice(0, 3);
}
@@ -194,7 +391,12 @@ export function buildRecentClientChanges(
detailLabels: buildRecentClientChangeDetails(action, details),
} satisfies RecentClientChange;
})
- .filter((item): item is RecentClientChange => Boolean(item))
+ .filter((item): item is RecentClientChange => {
+ if (!item) {
+ return false;
+ }
+ return item.detailLabels.length > 0;
+ })
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
diff --git a/devfront/src/lib/i18n.test.ts b/devfront/src/lib/i18n.test.ts
new file mode 100644
index 00000000..b44d349c
--- /dev/null
+++ b/devfront/src/lib/i18n.test.ts
@@ -0,0 +1,36 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { t } from "./i18n";
+
+afterEach(() => {
+ window.localStorage.clear();
+});
+
+describe("i18n", () => {
+ it("returns English copy for the developer request and grants screens", () => {
+ window.localStorage.setItem("locale", "en");
+
+ expect(t("ui.dev.request.list.title", "신청 내역")).toBe("Request History");
+ expect(
+ t(
+ "msg.dev.request.list.approved_count",
+ "총 {{count}}명의 사용자가 승인되었습니다.",
+ { count: 0 },
+ ),
+ ).toBe("0 users have been approved.");
+ expect(t("ui.dev.grants.form.title", "직접 부여")).toBe("Direct Grant");
+ expect(
+ t(
+ "msg.dev.grants.form.description",
+ "사용자를 선택하면 현재 소속 정보가 표시되고, 그 사용자에게 개발자 권한을 즉시 부여합니다.",
+ ),
+ ).toBe(
+ "Select a user to view their current tenant, email, and phone, then grant developer access immediately.",
+ );
+ expect(
+ t(
+ "msg.dev.grants.list.description",
+ "현재 부여된 개발자 권한 목록입니다.",
+ ),
+ ).toBe("Current developer access grants.");
+ });
+});
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml
index 4f2b0c60..60ce8c2c 100644
--- a/devfront/src/locales/en.toml
+++ b/devfront/src/locales/en.toml
@@ -321,12 +321,14 @@ admin_desc = "Manage developer access requests submitted by users."
approved = "Approved."
cancelled = "Approval has been cancelled."
empty = "No requests found."
-list.approved_count = "{{count}} users have been approved."
need_cancel_notes = "Please enter a reason for cancelling approval."
need_notes = "Please enter a rejection reason."
rejected = "Rejected."
user_desc = "Review your request history and submit a new access request."
+[msg.dev.request.list]
+approved_count = "{{count}} users have been approved."
+
[msg.dev.request.modal]
desc = "Please enter the reason for your request. It will be approved after administrator review."
tenant_required = "Please submit a developer access request."
@@ -452,6 +454,11 @@ session_required_off = "Off: process logout using sub even if sid is missing."
empty = "No scopes registered."
subtitle = "Define the permission scopes this application can request."
tenant = "Tenant access claim"
+offline_access_summary = "If the RP needs refresh tokens, include offline_access in the scope list and align the consent and grant type settings as well."
+offline_access_conditions_title = "Hydra conditions for issuing refresh tokens"
+offline_access_condition_request = "Include offline or offline_access in the authorization request scope."
+offline_access_condition_consent = "Include offline or offline_access in the consent accept granted_scope."
+offline_access_condition_grant_type = "Include refresh_token in the client grant_types."
[msg.dev.clients.general.id_token_claims]
subtitle = "Manage RP-specific extension claims separately."
@@ -568,10 +575,8 @@ admin_notes_placeholder = "e.g. Grant access after verifying the test environmen
empty = "There are no granted permissions."
forbidden = "Only super admin can directly grant developer access."
forbidden_desc = "This screen is available only to super admin."
-form.description = "Select a user to view their current tenant, email, and phone, then grant developer access immediately."
selected_info_description = "Review the selected user's tenant, email, and phone."
user_section_description = "Enter a search term to select a user. The next-step information stays empty until a user is chosen."
-list.description = "Current developer access grants."
load_error = "Failed to load developer access grants."
reason = "Grant reason"
revoke = "Revoke"
@@ -583,6 +588,13 @@ tenant_required = "The selected user's tenant information is unavailable."
tenant_missing = "No tenant information is available for the selected user."
user_required = "Select a user before granting access."
phone_missing = "No phone number is registered."
+pages_hint = "If you select All, Overview, Add linked app, and Audit Logs are all included."
+
+[msg.dev.grants.form]
+description = "Select a user to view their current tenant, email, and phone, then grant developer access immediately."
+
+[msg.dev.grants.list]
+description = "Current developer access grants."
[msg.dev.dashboard.notice]
consent_audit = "Consent Audit"
@@ -1346,10 +1358,8 @@ admin_notes = "Grant Reason"
all_tenants = "All Tenants"
approved = "Approved"
date = "Granted At"
-form.title = "Direct Grant"
grant = "Grant Directly"
input_section = "Input"
-list.title = "Granted Access"
pages = "Access Pages"
read_only = "Read Only"
reason = "Grant Reason"
@@ -1364,6 +1374,12 @@ user_section = "User Selection"
user = "User"
user_search_placeholder = "Search by name or email..."
+[ui.dev.grants.form]
+title = "Direct Grant"
+
+[ui.dev.grants.list]
+title = "Granted Access"
+
[ui.dev.request.modal]
email = "Email"
name = "Name"
@@ -1375,6 +1391,12 @@ reason_placeholder = "e.g. I need to create an OIDC client for internal service
role = "Role"
title = "Developer Access Request"
+[ui.dev.access_pages]
+all = "All"
+overview = "Overview"
+client_create = "Add linked app"
+audit = "Audit Logs"
+
[ui.dev.request.status]
approved = "Approved"
cancelled = "Approval Cancelled"
@@ -1590,6 +1612,10 @@ add = "Scope Add"
description_placeholder = "Description Placeholder"
name_placeholder = "e.g. profile"
title = "Scopes"
+offline_access_title = "offline_access scope is required when using refresh tokens."
+offline_access_toggle = "Show details"
+picker_title = "Select a scope to add"
+picker_help = "Choose a supported scope or custom claim key to add it to the scope list."
[ui.dev.clients.general.scopes.table]
description = "Scope Description"
@@ -1610,6 +1636,12 @@ empty = "No tenants match your search."
hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant."
autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list."
validation_required = "Select at least one allowed tenant when tenant access restriction is enabled."
+picker_title = "Select tenant"
+picker_label = "Add allowed tenant"
+open_picker = "Open tenant picker"
+picker_description = "Choose the tenants to allow from the orgfront org chart and add them to the list."
+picker_hint = "Open the picker to add allowed tenants."
+picker_hint_with_count = "{{count}} tenants selected."
[ui.dev.clients.general.id_token_claims]
title = "Custom Claims"
@@ -1645,7 +1677,7 @@ pkce = "PKCE"
headless_login = "Headless Login"
title = "Security Settings"
headless_login_enable = "Headless Login (Custom Login UI)"
-headless_login_enable_help = "Enable this when the RP uses its own login UI and the RP backend proves the client with signed keys instead of the Baron SSO login page."
+headless_login_enable_help = "Enable this when the RP uses its own login UI instead of the Baron SSO login page and the RP backend validates the client with a signing key."
[ui.dev.clients.general.public_key]
auth_method = "Token Endpoint Auth Method"
@@ -1676,6 +1708,12 @@ cache_status = "Status"
cache_uri = "JWKS URI"
revoke_cache = "Revoke Cache"
+[ui.dev.clients.general.public_key.validation]
+missing_jwks_uri = "Enter a JWKS URI."
+invalid_jwks_uri = "JWKS URI format is invalid."
+unsupported_parsed_algorithms = "The JWKS contains unsupported algorithms: {{details}}"
+missing_parsed_algorithms = "The JWKS contains keys without an `alg` declaration: {{details}}"
+
[ui.dev.clients.relationships]
title = "Client Relationships"
add_title = "Add Relationship"
diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml
index bc5638ca..d175aa63 100644
--- a/devfront/src/locales/ko.toml
+++ b/devfront/src/locales/ko.toml
@@ -321,12 +321,14 @@ admin_desc = "사용자들의 개발자 권한 신청 내역을 관리합니다.
approved = "승인되었습니다."
cancelled = "승인이 취소되었습니다."
empty = "신청 내역이 없습니다."
-list.approved_count = "총 {{count}}명의 사용자가 승인되었습니다."
need_cancel_notes = "승인 취소 사유를 입력해주세요."
need_notes = "반려 사유를 입력해주세요."
rejected = "반려되었습니다."
user_desc = "내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다."
+[msg.dev.request.list]
+approved_count = "총 {{count}}명의 사용자가 승인되었습니다."
+
[msg.dev.request.modal]
desc = "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다."
tenant_required = "개발자 권한 신청을 진행해 주세요."
@@ -452,6 +454,11 @@ session_required_off = "끄면: sid가 없어도 sub만으로 로그아웃 처
empty = "등록된 스코프가 없습니다."
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
tenant = "소속 테넌트 정보 접근"
+offline_access_summary = "RP가 refresh token을 사용하려면 scope 목록에 offline_access를 포함하고, consent와 grant type 설정도 함께 맞아야 합니다."
+offline_access_conditions_title = "Hydra 기준으로 refresh token 발급 조건"
+offline_access_condition_request = "authorization request scope에 offline 또는 offline_access 포함"
+offline_access_condition_consent = "consent accept의 granted_scope에 offline 또는 offline_access 포함"
+offline_access_condition_grant_type = "client grant_types에 refresh_token 포함"
[msg.dev.clients.general.id_token_claims]
subtitle = "RP 전용 확장 claim을 구분해서 관리합니다."
@@ -568,10 +575,8 @@ admin_notes_placeholder = "예: 테스트 환경 확인 후 권한 부여"
empty = "부여된 권한이 없습니다."
forbidden = "개발자 권한 직접 부여는 super admin만 사용할 수 있습니다."
forbidden_desc = "이 화면은 super admin만 사용할 수 있습니다."
-form.description = "사용자를 선택하면 현재 소속 테넌트, 이메일, 전화번호를 확인한 뒤 개발자 권한을 즉시 부여합니다."
selected_info_description = "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다."
user_section_description = "검색어를 입력해 사용자를 선택합니다. 선택 전에는 다음 단계 정보가 비어 있습니다."
-list.description = "현재 부여된 개발자 권한 목록입니다."
load_error = "개발자 권한 목록을 불러오지 못했습니다."
reason = "부여 사유"
revoke = "회수"
@@ -583,6 +588,13 @@ tenant_required = "선택한 사용자의 테넌트 정보를 확인할 수 없
tenant_missing = "선택한 사용자의 테넌트 정보를 확인할 수 없습니다."
user_required = "부여할 사용자를 선택해주세요."
phone_missing = "등록된 전화번호가 없습니다."
+pages_hint = "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다."
+
+[msg.dev.grants.form]
+description = "사용자를 선택하면 현재 소속 테넌트, 이메일, 전화번호를 확인한 뒤 개발자 권한을 즉시 부여합니다."
+
+[msg.dev.grants.list]
+description = "현재 부여된 개발자 권한 목록입니다."
[msg.dev.dashboard.notice]
consent_audit = "Consent 회수는 감사 로그와 연계"
@@ -1346,10 +1358,8 @@ admin_notes = "부여 사유"
all_tenants = "전체 테넌트"
approved = "승인됨"
date = "부여일"
-form.title = "직접 부여"
grant = "직접 부여"
input_section = "입력"
-list.title = "부여된 권한"
pages = "권한 페이지"
read_only = "읽기 전용"
reason = "부여 사유"
@@ -1364,6 +1374,12 @@ user_section = "사용자 선택"
user = "사용자"
user_search_placeholder = "이름 또는 이메일 검색..."
+[ui.dev.grants.form]
+title = "직접 부여"
+
+[ui.dev.grants.list]
+title = "부여된 권한"
+
[ui.dev.request.modal]
email = "이메일"
name = "성함"
@@ -1375,6 +1391,12 @@ reason_placeholder = "예: 자체 서비스 연동 및 테스트용 OIDC 클라
role = "역할"
title = "개발자 등록 신청"
+[ui.dev.access_pages]
+all = "전체"
+overview = "개요"
+client_create = "연동 앱 추가"
+audit = "감사로그"
+
[ui.dev.request.status]
approved = "승인됨"
cancelled = "승인 취소됨"
@@ -1589,6 +1611,10 @@ add = "스코프 추가"
description_placeholder = "권한에 대한 설명"
name_placeholder = "e.g. profile"
title = "스코프"
+offline_access_title = "Refresh token 사용 시 offline_access scope가 필요합니다."
+offline_access_toggle = "상세 안내 보기"
+picker_title = "추가할 scope 선택"
+picker_help = "지원 scope와 Custom Claim key를 선택해 scope 목록에 추가합니다."
[ui.dev.clients.general.scopes.table]
description = "설명"
@@ -1609,6 +1635,12 @@ empty = "검색 결과가 없습니다."
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
+picker_title = "테넌트 선택"
+picker_label = "허용 테넌트 추가"
+open_picker = "테넌트 선택기 열기"
+picker_description = "orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다."
+picker_hint = "선택기를 열어 허용 테넌트를 추가하세요."
+picker_hint_with_count = "현재 {{count}}개가 선택되어 있습니다."
[ui.dev.clients.general.id_token_claims]
title = "커스텀 클레임"
@@ -1675,6 +1707,12 @@ cache_status = "상태"
cache_uri = "JWKS URI"
revoke_cache = "캐시 삭제"
+[ui.dev.clients.general.public_key.validation]
+missing_jwks_uri = "JWKS URI를 입력해야 합니다."
+invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다."
+unsupported_parsed_algorithms = "JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}"
+missing_parsed_algorithms = "JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}"
+
[ui.dev.clients.relationships]
title = "클라이언트 관계"
add_title = "관계 추가"
diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml
index bb8af261..45f2d56f 100644
--- a/devfront/src/locales/template.toml
+++ b/devfront/src/locales/template.toml
@@ -335,16 +335,19 @@ admin_desc = ""
approved = ""
cancelled = ""
empty = ""
-list.approved_count = ""
need_cancel_notes = ""
need_notes = ""
rejected = ""
user_desc = ""
+[msg.dev.request.list]
+approved_count = ""
+
[msg.dev.request.modal]
desc = ""
tenant_required = ""
tenant_required_detail = ""
+pages_hint = ""
[msg.dev.request.status]
approved = ""
@@ -499,6 +502,11 @@ session_required_off = ""
empty = ""
subtitle = ""
tenant = ""
+offline_access_summary = ""
+offline_access_conditions_title = ""
+offline_access_condition_request = ""
+offline_access_condition_consent = ""
+offline_access_condition_grant_type = ""
[msg.dev.clients.general.security]
private_help = ""
@@ -605,14 +613,13 @@ admin_notes_placeholder = ""
empty = ""
forbidden = ""
forbidden_desc = ""
-form.description = ""
selected_info_description = ""
user_section_description = ""
-list.description = ""
load_error = ""
reason = ""
revoke = ""
revoke_success = ""
+pages_hint = ""
search_empty = ""
search_loading = ""
selected_user = ""
@@ -622,6 +629,12 @@ user_required = ""
phone_missing = ""
required = ""
+[msg.dev.grants.form]
+description = ""
+
+[msg.dev.grants.list]
+description = ""
+
[msg.dev.dashboard.notice]
consent_audit = ""
dev_scope = ""
@@ -1398,10 +1411,8 @@ admin_notes = ""
all_tenants = ""
approved = ""
date = ""
-form.title = ""
grant = ""
input_section = ""
-list.title = ""
read_only = ""
reason = ""
reason_placeholder = ""
@@ -1415,6 +1426,12 @@ user_section = ""
user = ""
user_search_placeholder = ""
+[ui.dev.grants.form]
+title = ""
+
+[ui.dev.grants.list]
+title = ""
+
[ui.dev.request.modal]
email = ""
name = ""
@@ -1425,6 +1442,12 @@ reason_placeholder = ""
role = ""
title = ""
+[ui.dev.access_pages]
+all = ""
+overview = ""
+client_create = ""
+audit = ""
+
[ui.dev.request.status]
approved = ""
cancelled = ""
@@ -1638,6 +1661,10 @@ add = ""
description_placeholder = ""
name_placeholder = ""
title = ""
+offline_access_title = ""
+offline_access_toggle = ""
+picker_title = ""
+picker_help = ""
[ui.dev.clients.general.scopes.table]
description = ""
@@ -1658,6 +1685,12 @@ empty = ""
hint = ""
autocomplete_hint = ""
validation_required = ""
+picker_title = ""
+picker_label = ""
+open_picker = ""
+picker_description = ""
+picker_hint = ""
+picker_hint_with_count = ""
[ui.dev.clients.general.id_token_claims]
title = ""
@@ -1723,6 +1756,12 @@ cache_status = ""
cache_uri = ""
revoke_cache = ""
+[ui.dev.clients.general.public_key.validation]
+missing_jwks_uri = ""
+invalid_jwks_uri = ""
+unsupported_parsed_algorithms = ""
+missing_parsed_algorithms = ""
+
[ui.dev.clients.relationships]
title = ""
add_title = ""
diff --git a/devfront/vite.config.ts b/devfront/vite.config.ts
index ae610f9a..d4c89085 100644
--- a/devfront/vite.config.ts
+++ b/devfront/vite.config.ts
@@ -17,6 +17,7 @@ const allowedHosts = getAllowedHosts(
export default defineConfig(
mergeConfig(commonViteConfig, {
+ envPrefix: ["VITE_", "ORGFRONT_"],
cacheDir:
process.env.DEVFRONT_VITE_CACHE_DIR ??
"/tmp/baron-sso-devfront-vite-cache",
diff --git a/docker/promtail-config.template.yaml b/docker/promtail-config.template.yaml
index 45ac4bfb..d1802254 100644
--- a/docker/promtail-config.template.yaml
+++ b/docker/promtail-config.template.yaml
@@ -32,10 +32,10 @@ scrape_configs:
regex: '/(.*)'
target_label: 'container_name'
- # 4. 서비스 상세 라벨 부여 (baron_ 접두사 제거 등)
+ # 4. 서비스 상세 라벨 부여 (baron_ 접두사 제거 등, 접두사가 없는 경우 그대로 유지)
- source_labels: ['container_name']
- regex: 'baron_(.*)'
+ regex: '(?:baron_)?(.*)'
target_label: 'service'
- source_labels: ['container_name']
- regex: 'baron_(.*)'
+ regex: '(?:baron_)?(.*)'
target_label: 'job'
diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml
index 31ca94e2..977aa841 100644
--- a/docker/staging_pull_compose.template.yaml
+++ b/docker/staging_pull_compose.template.yaml
@@ -455,6 +455,7 @@ services:
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL:-}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY:-}
VITE_OIDC_CLIENT_ID: devfront
+ ORGFRONT_URL: ${ORGFRONT_URL:-}
container_name: baron_devfront
restart: unless-stopped
env_file:
diff --git a/docs/external_healthcheck_monitoring_design.md b/docs/external_healthcheck_monitoring_design.md
index c1b644be..f66d65c9 100644
--- a/docs/external_healthcheck_monitoring_design.md
+++ b/docs/external_healthcheck_monitoring_design.md
@@ -140,12 +140,12 @@ scrape_configs:
- source_labels: ['container_name']
regex: '(baron_.*|oathkeeper|kratos|hydra|keto)'
action: keep
- # 컨테이너 명에서 앞의 접두사를 떼어 서비스 및 잡 라벨 부여 (예: baron_backend -> backend)
+ # 컨테이너 명에서 앞의 접두사를 떼어 서비스 및 잡 라벨 부여 (예: baron_backend -> backend, kratos -> kratos)
- source_labels: ['container_name']
- regex: 'baron_(.*)'
+ regex: '(?:baron_)?(.*)'
target_label: 'service'
- source_labels: ['container_name']
- regex: 'baron_(.*)'
+ regex: '(?:baron_)?(.*)'
target_label: 'job'
# 동적 라벨 추가
- target_label: 'app_env'
diff --git a/locales/en.toml b/locales/en.toml
index 99387a15..cae2ac0c 100644
--- a/locales/en.toml
+++ b/locales/en.toml
@@ -368,6 +368,7 @@ tree_hint = "Review parent-child relationships and subtree coverage in the hiera
[msg.admin.tenants.relations]
empty = "There are no users with designated fine-grained permissions. Please add a user to configure."
remove_all_confirm = "Remove All Confirm"
+super_admin_only_desc = "The permission settings on this screen can only be modified by the system administrator (super_admin)."
subtitle = "Isolate and assign fine-grained view and edit permissions for each tab on a per-user basis. Parent inherited permissions are automatically preserved."
update_success = "Update Success"
@@ -2687,6 +2688,12 @@ title = "Direct Grant"
[ui.dev.grants.list]
title = "Granted Access"
+[ui.dev.access_pages]
+all = "All"
+overview = "Overview"
+client_create = "Add linked app"
+audit = "Audit Logs"
+
[ui.dev.header]
plane = "Dev Plane"
subtitle = "Manage your applications"
diff --git a/locales/ko.toml b/locales/ko.toml
index fce1b247..1158fd53 100644
--- a/locales/ko.toml
+++ b/locales/ko.toml
@@ -368,6 +368,7 @@ tree_hint = "계층 구조를 따라 부모-자식 관계와 하위 범위를
[msg.admin.tenants.relations]
empty = "세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요."
remove_all_confirm = "이 사용자의 모든 세부 권한을 삭제하시겠습니까?"
+super_admin_only_desc = "이 화면의 권한 설정은 시스템 최고 관리자(super_admin)만 수정할 수 있습니다."
subtitle = "사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다."
update_success = "세부 권한이 성공적으로 변경되었습니다."
@@ -2687,6 +2688,12 @@ title = "직접 부여"
[ui.dev.grants.list]
title = "부여된 권한"
+[ui.dev.access_pages]
+all = "전체"
+overview = "개요"
+client_create = "연동 앱 추가"
+audit = "감사로그"
+
[ui.dev.header]
plane = "Dev Plane"
subtitle = "Manage your applications"
diff --git a/locales/template.toml b/locales/template.toml
index 5a508530..6fd8d024 100644
--- a/locales/template.toml
+++ b/locales/template.toml
@@ -368,6 +368,7 @@ tree_hint = ""
[msg.admin.tenants.relations]
empty = ""
remove_all_confirm = ""
+super_admin_only_desc = ""
subtitle = ""
update_success = ""
@@ -2687,6 +2688,12 @@ title = ""
[ui.dev.grants.list]
title = ""
+[ui.dev.access_pages]
+all = ""
+overview = ""
+client_create = ""
+audit = ""
+
[ui.dev.header]
plane = ""
subtitle = ""
diff --git a/package.json b/package.json
index e4b774cf..994411ef 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,4 @@
{
"name": "baron-sso-root",
- "private": true,
- "pnpm": {
- "overrides": {
- "@types/node": "24.12.4",
- "undici": "7.26.0",
- "electron-to-chromium": "1.5.360",
- "@csstools/css-syntax-patches-for-csstree": "1.1.4"
- }
- }
+ "private": true
}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 44657a32..f45723af 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -5,3 +5,8 @@ packages:
- "common"
allowBuilds:
'@biomejs/biome': false
+overrides:
+ "@types/node": "24.12.4"
+ "undici": "7.26.0"
+ "electron-to-chromium": "1.5.360"
+ "@csstools/css-syntax-patches-for-csstree": "1.1.4"
diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart
index 20afee5f..80b71823 100644
--- a/userfront/lib/features/auth/presentation/signup_screen.dart
+++ b/userfront/lib/features/auth/presentation/signup_screen.dart
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
+import 'package:url_launcher/url_launcher.dart';
import 'package:userfront/i18n.dart';
import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart';
@@ -1739,105 +1740,49 @@ Matters not expressly provided in this Policy are governed by the Company's inte
),
),
),
- const SizedBox(height: 18),
- _buildProfileFieldGroup(
- title: tr('ui.userfront.signup.profile.affiliation_type'),
- description: '소속 유형과 회사 정보를 입력합니다.',
- isDesktop: isDesktop,
- trailing: null,
+ const SizedBox(height: 24),
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: _signupSurface,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: _signupBorder),
+ ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- DropdownButtonFormField(
- key: ValueKey(_affiliationType),
- initialValue: _affiliationType,
- decoration: InputDecoration(
- labelText: tr(
- 'ui.userfront.signup.profile.affiliation_type',
+ Row(
+ children: [
+ Icon(
+ Icons.business,
+ color: Theme.of(context).colorScheme.primary,
+ size: 20,
),
- border: const OutlineInputBorder(),
- ),
- items: [
- DropdownMenuItem(
- value: 'GENERAL',
- child: Text(tr('domain.affiliation.general')),
- ),
- DropdownMenuItem(
- value: 'AFFILIATE',
- child: Text(tr('domain.affiliation.affiliate')),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ '기업/가족사 소속이신가요?',
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ color: _signupInk,
+ ),
+ ),
),
],
- onChanged: _isAffiliateLocked
- ? null
- : (val) {
- if (val == null) {
- return;
- }
- setState(() {
- _affiliationType = val;
- if (_affiliationType == 'GENERAL') {
- _companyCode = null;
- }
- });
- },
),
- AnimatedSize(
- duration: const Duration(milliseconds: 180),
- curve: Curves.easeOut,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- if (_affiliationType == 'AFFILIATE') ...[
- const SizedBox(height: 14),
- DropdownButtonFormField(
- key: ValueKey(_companyCode ?? 'none'),
- initialValue: _companyCode,
- decoration: InputDecoration(
- labelText: tr(
- 'ui.userfront.signup.profile.company',
- ),
- border: const OutlineInputBorder(),
- ),
- items: _tenants.map((t) {
- return DropdownMenuItem(
- value: t['slug'],
- child: Text(t['name'] ?? t['slug']),
- );
- }).toList(),
- onChanged: (val) =>
- setState(() => _companyCode = val),
- ),
- ],
- ],
+ const SizedBox(height: 8),
+ Text(
+ '기업 및 가족사 임직원은 연동 문의가 필요합니다.\n\n해당하시는 경우, 사내 관리자 또는 담당자(baroncs@baroncs.co.kr)에게 문의해 주시기 바랍니다.',
+ style: TextStyle(
+ fontSize: 12,
+ height: 1.45,
+ color: _signupInk.withValues(alpha: 0.7),
),
),
],
),
),
- const SizedBox(height: 18),
- _buildProfileFieldGroup(
- title: _affiliationType == 'AFFILIATE'
- ? tr('ui.userfront.signup.profile.department')
- : tr(
- 'ui.userfront.signup.profile.department_optional',
- ),
- description: _affiliationType == 'AFFILIATE'
- ? '가족사 사용자는 부서명을 입력해주세요.'
- : '선택 입력 항목입니다.',
- isDesktop: isDesktop,
- child: TextFormField(
- controller: _deptController,
- onChanged: (_) => setState(() {}),
- decoration: InputDecoration(
- labelText: _affiliationType == 'AFFILIATE'
- ? tr('ui.userfront.signup.profile.department')
- : tr(
- 'ui.userfront.signup.profile.department_optional',
- ),
- border: const OutlineInputBorder(),
- ),
- ),
- ),
],
),
),
@@ -2313,15 +2258,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte
canGoNext = true;
}
if (_currentStep == 3) {
- final nameOk = _nameController.text.trim().isNotEmpty;
- if (_affiliationType == 'GENERAL') {
- canGoNext = nameOk;
- } else {
- // AFFILIATE 필수: 이름 + 가족사 선택 + 부서명
- final companyOk = _companyCode != null;
- final deptOk = _deptController.text.trim().isNotEmpty;
- canGoNext = nameOk && companyOk && deptOk;
- }
+ canGoNext = _nameController.text.trim().isNotEmpty;
}
return Scaffold(
diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock
index b23d80a9..8b6fff8c 100644
--- a/userfront/pubspec.lock
+++ b/userfront/pubspec.lock
@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
- sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+ sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
- version: "1.4.1"
+ version: "1.4.0"
cli_config:
dependency: transitive
description:
@@ -268,6 +268,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
+ js:
+ dependency: transitive
+ description:
+ name: js
+ sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -320,18 +328,18 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
+ sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
- version: "0.12.19"
+ version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+ sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
- version: "0.13.0"
+ version: "0.11.1"
meta:
dependency: transitive
description:
@@ -653,26 +661,26 @@ packages:
dependency: transitive
description:
name: test
- sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
+ sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
- version: "1.30.0"
+ version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
- sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
+ sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
- version: "0.7.10"
+ version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
- sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
+ sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
- version: "0.6.16"
+ version: "0.6.12"
toml:
dependency: "direct main"
description: