1
0
forked from baron/baron-sso

Merge remote-tracking branch 'origin/dev' into dev

# Conflicts:
#	adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx
#	adminfront/tests/worksmobile.spec.ts
This commit is contained in:
2026-06-17 21:31:00 +09:00
39 changed files with 1049 additions and 569 deletions

View File

@@ -180,6 +180,7 @@ VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
DEVFRONT_URL=http://localhost:5174 DEVFRONT_URL=http://localhost:5174
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback 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 ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback
VITE_ORGCHART_URL= VITE_ORGCHART_URL=

View File

@@ -270,8 +270,7 @@ function AppLayout() {
if (item.to === "/permissions-direct") return false; if (item.to === "/permissions-direct") return false;
if (item.to === "/tenants") return permissions.tenants; if (item.to === "/tenants") return permissions.tenants;
if (item.to === orgfrontUrl) return permissions.org_chart; if (item.to === orgfrontUrl) return permissions.org_chart;
if (item.to === "/worksmobile") if (item.to === "/worksmobile") return permissions.worksmobile;
return permissions.worksmobile && showWorksmobile;
if (item.to === "/system/ory-ssot") return permissions.ory_ssot; if (item.to === "/system/ory-ssot") return permissions.ory_ssot;
if (item.to === "/system/data-integrity") if (item.to === "/system/data-integrity")
return permissions.data_integrity; return permissions.data_integrity;

View File

@@ -61,7 +61,7 @@ const users = [
id: "user-owner", id: "user-owner",
name: "Owner User", name: "Owner User",
email: "owner@example.com", email: "owner@example.com",
role: "tenant_admin", role: "super_admin",
status: "active", status: "active",
}, },
{ {

View File

@@ -13,7 +13,9 @@ export type TenantPermissionKey =
| "view_organization" | "view_organization"
| "manage_organization" | "manage_organization"
| "view_schema" | "view_schema"
| "manage_schema"; | "manage_schema"
| "view_worksmobile"
| "manage_worksmobile";
export function useTenantPermission(tenantId: string) { export function useTenantPermission(tenantId: string) {
const { data: profile } = useQuery({ const { data: profile } = useQuery({

View File

@@ -537,14 +537,18 @@ export function TenantFineGrainedPermissionsPage() {
name: selection.name, name: selection.name,
email: selection.email, email: selection.email,
tenantSlug: selection.leafTenantName, tenantSlug: selection.leafTenantName,
tenant: selection.leafTenantName tenant: selection.leafTenantName
? { ? {
id: "", id: "",
slug: "", type: "ORGANIZATION",
name: selection.leafTenantName, slug: "",
createdAt: "", name: selection.leafTenantName,
updatedAt: "", description: "",
} status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
}
: undefined, : undefined,
metadata: { metadata: {
rootTenantName: selection.rootTenantName, rootTenantName: selection.rootTenantName,
@@ -985,37 +989,51 @@ export function TenantFineGrainedPermissionsPage() {
)} )}
</div> </div>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{queuedTargetUsers.map((user) => ( {queuedTargetUsers.map((user) => {
<span const rootTenantName =
key={user.id} typeof user.metadata?.rootTenantName === "string"
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-muted/30 px-2 py-1 text-sm" ? user.metadata.rootTenantName
> : "";
<span className="max-w-52 truncate"> const leafTenantName =
{user.name} typeof user.metadata?.leafTenantName === "string"
</span> ? user.metadata.leafTenantName
{(user.metadata?.rootTenantName || : "";
user.metadata?.leafTenantName) && ( const tenantPath = [
<span className="max-w-64 truncate text-xs text-muted-foreground"> rootTenantName,
{[user.metadata?.rootTenantName, user.metadata?.leafTenantName] leafTenantName,
.filter(Boolean) ]
.join(" / ")} .filter(Boolean)
</span> .join(" / ");
)}
<button return (
type="button" <span
className="text-muted-foreground hover:text-foreground" key={user.id}
onClick={() => removeQueuedTargetUser(user.id)} className="inline-flex max-w-full items-center gap-2 rounded-md border bg-muted/30 px-2 py-1 text-sm"
aria-label={t( >
"ui.admin.permissions_direct.target_queue_remove", <span className="max-w-52 truncate">
"적용 대상에서 제거", {user.name}
)} </span>
> {tenantPath !== "" && (
<X size={14} /> <span className="max-w-64 truncate text-xs text-muted-foreground">
</button> {tenantPath}
</span> </span>
))} )}
</div> <button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeQueuedTargetUser(user.id)}
aria-label={t(
"ui.admin.permissions_direct.target_queue_remove",
"적용 대상에서 제거",
)}
>
<X size={14} />
</button>
</span>
);
})}
</div>
)} )}
</div> </div>
</div> </div>
@@ -1410,258 +1428,6 @@ export function TenantFineGrainedPermissionsPage() {
</div> </div>
</div> </div>
{false && (
<>
{/* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */}
<div className="flex flex-col lg:flex-row gap-6 h-[720px] border border-border rounded-xl bg-card overflow-hidden shadow-sm">
{/* Left Panel: User List */}
<div className="w-full lg:w-80 border-r border-border flex flex-col bg-muted/10 h-full">
<div className="p-4 border-b border-border space-y-3 flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm text-foreground">
{t("ui.admin.permissions_direct.user_list", "대상 사용자")} (
{filteredRelations.length})
</h3>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "이름 또는 이메일 검색...")}
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
name="user-search"
className="pl-8 h-8 text-xs"
/>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{filteredRelations.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
{t(
"msg.admin.permissions_direct.no_users_found",
"등록된 사용자가 없습니다.",
)}
</div>
) : (
filteredRelations.map((user) => {
const isSelected = activeUserId === user.userId;
const activeCount = user.relations.length;
return (
<button
type="button"
key={user.userId}
onClick={() => 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"
}`}
>
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-8 w-8 border border-border">
<AvatarFallback className="bg-primary/10 text-primary font-bold text-xs uppercase">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="text-sm font-semibold truncate">
{user.name}
</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[150px]">
{user.email}
</span>
</div>
</div>
<Badge
variant={isSelected ? "default" : "secondary"}
className="text-[9px] px-1.5 py-0.5"
>
{activeCount}
</Badge>
</button>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Right Panel: Toggle settings grid */}
<div className="flex-1 flex flex-col h-full bg-background">
{selectedUser ? (
<>
{/* User Detail Header */}
<div className="p-5 border-b border-border flex items-center justify-between flex-shrink-0 bg-muted/5">
<div className="flex items-center gap-4">
<Avatar className="h-11 w-11 border">
<AvatarFallback className="bg-primary/5 text-primary font-extrabold text-sm uppercase">
{selectedUser.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<h2 className="text-lg font-bold flex items-center gap-2">
{selectedUser.name}
<Badge variant="outline" className="text-xs font-normal">
{selectedUser.relations.length}{" "}
{t("ui.admin.permissions_direct.allowed", "개 허용됨")}
</Badge>
</h2>
<span className="text-xs text-muted-foreground">
{selectedUser.email}
</span>
</div>
</div>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/20 hover:bg-destructive/10"
onClick={() =>
handleRemoveAllSystemRelations(
selectedUser.userId,
selectedUser.relations,
)
}
>
<Trash2 className="h-4 w-4 mr-2" />
{t(
"ui.admin.permissions_direct.revoke_all",
"모든 권한 회수",
)}
</Button>
</div>
{/* Categorized Toggle Grid */}
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
{systemMenuCategories.map((category) => (
<div key={category.title} className="space-y-3">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{category.title}
</h4>
<Card className="border border-border/60 shadow-none bg-card">
<CardContent className="p-0 divide-y divide-border/40">
{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 (
<div
key={menu.relation}
className="flex items-center justify-between p-4 hover:bg-muted/10 transition-colors"
>
<div className="flex items-start gap-4 pr-4 min-w-0">
<div className="p-2 rounded-lg bg-secondary/50 text-foreground flex-shrink-0 mt-0.5">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{menu.label}
</span>
{protectedSystemMenuRelations.has(
menu.relation,
) && (
<Badge
variant="secondary"
className="text-[10px] py-0.5 px-1.5 font-semibold text-destructive bg-destructive/10 border-destructive/20"
>
{t(
"ui.admin.permissions_direct.super_admin_only",
"Super Admin 전용",
)}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground line-clamp-1">
{menu.desc}
</span>
</div>
</div>
<select
name={`system-menu-permission-${menu.relation}`}
value={permissionValue}
disabled={protectedSystemMenuRelations.has(
menu.relation,
)}
onChange={(e) => {
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"
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</div>
);
})}
</CardContent>
</Card>
</div>
))}
</div>
</ScrollArea>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center text-muted-foreground bg-muted/5 gap-3">
<ShieldCheck className="h-12 w-12 text-muted-foreground opacity-30" />
<div>
<h3 className="text-sm font-semibold text-foreground">
{t(
"ui.admin.permissions_direct.no_user_selected",
"사용자가 선택되지 않았습니다.",
)}
</h3>
<p className="text-xs mt-1">
{t(
"msg.admin.permissions_direct.no_user_selected_desc",
"왼쪽의 사용자 리스트에서 권한을 변경할 인원을 선택해 주세요.",
)}
</p>
</div>
</div>
)}
</div>
</div>
</>
)}
</> </>
)} )}

View File

@@ -31,13 +31,13 @@ import {
import { toast } from "../../../components/ui/use-toast"; import { toast } from "../../../components/ui/use-toast";
import { import {
addTenantRelation, addTenantRelation,
fetchMe,
fetchTenantRelations, fetchTenantRelations,
fetchUsers, fetchUsers,
removeTenantRelation, removeTenantRelation,
type TenantRelation, type TenantRelation,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
interface TenantFineGrainedPermissionsTabProps { interface TenantFineGrainedPermissionsTabProps {
tenantIdProp?: string; tenantIdProp?: string;
@@ -48,8 +48,11 @@ export function TenantFineGrainedPermissionsTab({
}: TenantFineGrainedPermissionsTabProps = {}) { }: TenantFineGrainedPermissionsTabProps = {}) {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdProp || tenantIdParam || ""; const tenantId = tenantIdProp || tenantIdParam || "";
const { hasPermission } = useTenantPermission(tenantId); const { data: profile } = useQuery({
const isWritable = hasPermission("manage_admins"); queryKey: ["me"],
queryFn: fetchMe,
});
const isWritable = profile?.role === "super_admin";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -75,7 +78,13 @@ export function TenantFineGrainedPermissionsTab({
> = {}; > = {};
for (const user of relationsQuery.data) { for (const user of relationsQuery.data) {
initialMap[user.userId] = {}; initialMap[user.userId] = {};
const tabs = ["profile", "permissions", "organization", "schema"]; const tabs = [
"profile",
"permissions",
"organization",
"schema",
"worksmobile",
];
for (const tab of tabs) { for (const tab of tabs) {
const isWrite = user.relations.includes(`${tab}_managers`); const isWrite = user.relations.includes(`${tab}_managers`);
const isRead = user.relations.includes(`${tab}_viewers`); const isRead = user.relations.includes(`${tab}_viewers`);
@@ -204,7 +213,7 @@ export function TenantFineGrainedPermissionsTab({
const handleRelationChange = async ( const handleRelationChange = async (
userId: string, userId: string,
tab: "profile" | "permissions" | "organization" | "schema", tab: "profile" | "permissions" | "organization" | "schema" | "worksmobile",
currentVal: "none" | "read" | "write", currentVal: "none" | "read" | "write",
newVal: "none" | "read" | "write", newVal: "none" | "read" | "write",
) => { ) => {
@@ -318,6 +327,14 @@ export function TenantFineGrainedPermissionsTab({
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
{!isWritable && (
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-950/20 text-amber-800 dark:text-amber-200 border border-amber-200 dark:border-amber-800/30 rounded-lg text-sm font-medium">
{t(
"msg.admin.tenants.relations.super_admin_only_desc",
"이 화면의 권한 설정은 시스템 최고 관리자(super_admin)만 수정할 수 있습니다.",
)}
</div>
)}
<div className="rounded-md border border-border overflow-hidden"> <div className="rounded-md border border-border overflow-hidden">
<Table> <Table>
<TableHeader className="bg-secondary/40"> <TableHeader className="bg-secondary/40">
@@ -337,6 +354,12 @@ export function TenantFineGrainedPermissionsTab({
<TableHead className="font-bold"> <TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</TableHead> </TableHead>
<TableHead className="font-bold">
{t(
"ui.admin.tenants.detail.tab_worksmobile",
"네이버웍스 연동",
)}
</TableHead>
<TableHead className="font-bold text-center w-20"> <TableHead className="font-bold text-center w-20">
{t("ui.common.action", "작업")} {t("ui.common.action", "작업")}
</TableHead> </TableHead>
@@ -346,7 +369,7 @@ export function TenantFineGrainedPermissionsTab({
{relations.length === 0 ? ( {relations.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={7}
className="text-center py-12 text-muted-foreground" className="text-center py-12 text-muted-foreground"
> >
{t( {t(
@@ -387,6 +410,14 @@ export function TenantFineGrainedPermissionsTab({
? "read" ? "read"
: "none"; : "none";
const worksmobileVal = user.relations.includes(
"worksmobile_managers",
)
? "write"
: user.relations.includes("worksmobile_viewers")
? "read"
: "none";
const curProfileVal = const curProfileVal =
localTenantPermissions[user.userId]?.profile ?? localTenantPermissions[user.userId]?.profile ??
profileVal; profileVal;
@@ -398,6 +429,9 @@ export function TenantFineGrainedPermissionsTab({
organizationVal; organizationVal;
const curSchemaVal = const curSchemaVal =
localTenantPermissions[user.userId]?.schema ?? schemaVal; localTenantPermissions[user.userId]?.schema ?? schemaVal;
const curWorksmobileVal =
localTenantPermissions[user.userId]?.worksmobile ??
worksmobileVal;
return ( return (
<TableRow <TableRow
@@ -562,6 +596,43 @@ export function TenantFineGrainedPermissionsTab({
</option> </option>
</select> </select>
</TableCell> </TableCell>
<TableCell>
<select
className="flex h-9 w-full 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"
value={curWorksmobileVal}
disabled={!isWritable}
name={`tenant-fine-grained-worksmobile-${user.userId}`}
onChange={(e) => {
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,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -582,6 +582,9 @@ export function parseOrgChartUserSelections(
id: string; id: string;
name: string; name: string;
email?: string; email?: string;
rootTenantName?: string;
leafTenantName?: string;
tenantName?: string;
} => } =>
selection?.type === "user" && selection?.type === "user" &&
typeof selection.id === "string" && typeof selection.id === "string" &&

View File

@@ -45,6 +45,8 @@ export type TenantSummary = {
manage_organization?: boolean; manage_organization?: boolean;
view_schema?: boolean; view_schema?: boolean;
manage_schema?: boolean; manage_schema?: boolean;
view_worksmobile?: boolean;
manage_worksmobile?: boolean;
}; };
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -868,7 +868,7 @@ test.describe("Worksmobile tenant management", () => {
await userColumnButton.evaluate((element) => { await userColumnButton.evaluate((element) => {
element.scrollIntoView({ block: "center", inline: "nearest" }); element.scrollIntoView({ block: "center", inline: "nearest" });
}); });
await userColumnButton.click(); await userColumnButton.evaluate((el) => (el as HTMLButtonElement).click());
const settingsDialog = page.getByRole("dialog"); const settingsDialog = page.getByRole("dialog");
await expect(settingsDialog.getByText("구성원 컬럼 설정")).toBeVisible(); await expect(settingsDialog.getByText("구성원 컬럼 설정")).toBeVisible();

View File

@@ -712,69 +712,21 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
} }
// 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다. // 소속이 비어 있는 일반 가입자는 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 var tenantID *string
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), "personal")
parts := strings.Split(req.Email, "@") if err != nil || tenant == nil {
if len(parts) != 2 { // Fallback: 만약 시드된 personal 테넌트가 없을 경우 개인별 테넌트를 자동 생성합니다.
return errorJSON(c, fiber.StatusBadRequest, "Invalid email format") tenant, err = createPersonalTenantForUser(c.Context(), h.TenantService, req.Email)
}
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)
if err != nil { 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 형태로 보관) // Normalize Phone (E.164 형태로 보관)
normalizedPhone := domain.NormalizePhoneNumber(req.Phone) normalizedPhone := domain.NormalizePhoneNumber(req.Phone)

View File

@@ -116,22 +116,19 @@ func TestSignup_TenantSlugValidation(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 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{ reqBody := domain.SignupRequest{
Email: "user@hanmaceng.co.kr", Email: "user@hanmaceng.co.kr",
Password: "StrongPass123!", Password: "StrongPass123!",
Name: "Test User", Name: "Test User",
Phone: "010-1234-5678", Phone: "010-1234-5678",
TermsAccepted: true, TermsAccepted: true,
TenantSlug: "hanmac",
} }
body, _ := json.Marshal(reqBody) body, _ := json.Marshal(reqBody)
validTenant := &domain.Tenant{ID: "t1", Slug: "hanmac", Status: domain.TenantStatusActive} validTenant := &domain.Tenant{ID: "personal-t1", Slug: "personal-slug", Status: domain.TenantStatusActive}
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(&domain.Tenant{Slug: "hanmac"}, nil).Once() mockTenantSvc.On("GetTenantBySlug", mock.Anything, "personal").Return((*domain.Tenant)(nil), assert.AnError).Once()
mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(validTenant, nil).Maybe() 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()
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac").Return(validTenant, nil).Once()
mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil).Once()
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once() mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once()
mockRedis.On("Delete", mock.Anything).Return(nil) mockRedis.On("Delete", mock.Anything).Return(nil)

View File

@@ -135,6 +135,9 @@ func createPersonalTenantForUser(ctx context.Context, tenantService service.Tena
normalizedEmail = "user" normalizedEmail = "user"
} }
slug := "personal-" + strings.ReplaceAll(uuid.NewString(), "-", "") slug := "personal-" + strings.ReplaceAll(uuid.NewString(), "-", "")
if len(slug) > 32 {
slug = slug[:32]
}
tenant, err := tenantService.RegisterTenant( tenant, err := tenantService.RegisterTenant(
ctx, ctx,
fmt.Sprintf("Personal - %s", normalizedEmail), fmt.Sprintf("Personal - %s", normalizedEmail),

View File

@@ -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)
}

View File

@@ -108,6 +108,8 @@ type tenantPermissions struct {
ManageOrganization bool `json:"manage_organization"` ManageOrganization bool `json:"manage_organization"`
ViewSchema bool `json:"view_schema"` ViewSchema bool `json:"view_schema"`
ManageSchema bool `json:"manage_schema"` ManageSchema bool `json:"manage_schema"`
ViewWorksmobile bool `json:"view_worksmobile"`
ManageWorksmobile bool `json:"manage_worksmobile"`
} }
type tenantSummary struct { type tenantSummary struct {
@@ -1972,6 +1974,8 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
ManageOrganization: true, ManageOrganization: true,
ViewSchema: true, ViewSchema: true,
ManageSchema: true, ManageSchema: true,
ViewWorksmobile: true,
ManageWorksmobile: true,
} }
} else { } else {
// Query Keto in parallel for maximum performance // Query Keto in parallel for maximum performance
@@ -1981,13 +1985,14 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
allowed bool allowed bool
err error err error
} }
ch := make(chan checkResult, 11) ch := make(chan checkResult, 13)
relations := []string{ relations := []string{
"view", "manage", "manage_admins", "view", "manage", "manage_admins",
"view_profile", "manage_profile", "view_profile", "manage_profile",
"view_permissions", "manage_permissions", "view_permissions", "manage_permissions",
"view_organization", "manage_organization", "view_organization", "manage_organization",
"view_schema", "manage_schema", "view_schema", "manage_schema",
"view_worksmobile", "manage_worksmobile",
} }
for _, rel := range relations { for _, rel := range relations {
go func(r string) { go func(r string) {
@@ -2026,6 +2031,10 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
perms.ViewSchema = res.allowed perms.ViewSchema = res.allowed
case "manage_schema": case "manage_schema":
perms.ManageSchema = res.allowed perms.ManageSchema = res.allowed
case "view_worksmobile":
perms.ViewWorksmobile = res.allowed
case "manage_worksmobile":
perms.ManageWorksmobile = res.allowed
} }
} }
summary.UserPermissions = perms summary.UserPermissions = perms

View File

@@ -14,9 +14,11 @@ COPY devfront ./devfront
ARG VITE_DEVFRONT_PUBLIC_URL ARG VITE_DEVFRONT_PUBLIC_URL
ARG VITE_OIDC_AUTHORITY ARG VITE_OIDC_AUTHORITY
ARG VITE_OIDC_CLIENT_ID ARG VITE_OIDC_CLIENT_ID
ARG ORGFRONT_URL
ENV VITE_DEVFRONT_PUBLIC_URL=$VITE_DEVFRONT_PUBLIC_URL ENV VITE_DEVFRONT_PUBLIC_URL=$VITE_DEVFRONT_PUBLIC_URL
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
ENV ORGFRONT_URL=$ORGFRONT_URL
RUN pnpm install --frozen-lockfile --ignore-scripts RUN pnpm install --frozen-lockfile --ignore-scripts

View File

@@ -450,6 +450,44 @@ describe("ClientGeneralPage RP claims", () => {
expect(scopeInputs.some((input) => input.value === "old_claim")).toBe(true); 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 () => { it("blocks saving a number RP claim default value that is not numeric", async () => {
const { container } = await renderPage(); const { container } = await renderPage();

View File

@@ -639,6 +639,8 @@ function ClientGeneralPage() {
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false); const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
const [isScopePickerOpen, setIsScopePickerOpen] = useState(false); const [isScopePickerOpen, setIsScopePickerOpen] = useState(false);
const [isOfflineAccessGuideOpen, setIsOfflineAccessGuideOpen] =
useState(false);
const [scopes, setScopes] = useState<ScopeItem[]>(() => [ const [scopes, setScopes] = useState<ScopeItem[]>(() => [
{ {
id: "1", id: "1",
@@ -1176,14 +1178,14 @@ function ClientGeneralPage() {
if (!trimmedJwksUri) { if (!trimmedJwksUri) {
validationErrors.push( validationErrors.push(
t( t(
"msg.dev.clients.general.public_key.validation.missing_jwks_uri", "ui.dev.clients.general.public_key.validation.missing_jwks_uri",
"JWKS URI를 입력해야 합니다.", "JWKS URI를 입력해야 합니다.",
), ),
); );
} else if (!isValidUrl(trimmedJwksUri)) { } else if (!isValidUrl(trimmedJwksUri)) {
validationErrors.push( validationErrors.push(
t( t(
"msg.dev.clients.general.public_key.validation.invalid_jwks_uri", "ui.dev.clients.general.public_key.validation.invalid_jwks_uri",
"JWKS URI 형식이 올바르지 않습니다.", "JWKS URI 형식이 올바르지 않습니다.",
), ),
); );
@@ -1191,7 +1193,7 @@ function ClientGeneralPage() {
if (unsupportedParsedAlgorithms.length > 0) { if (unsupportedParsedAlgorithms.length > 0) {
validationErrors.push( validationErrors.push(
t( t(
"msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms", "ui.dev.clients.general.public_key.validation.unsupported_parsed_algorithms",
"JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}", "JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}",
{ details: unsupportedParsedAlgorithmSummary }, { details: unsupportedParsedAlgorithmSummary },
), ),
@@ -1200,7 +1202,7 @@ function ClientGeneralPage() {
if (missingParsedAlgorithms.length > 0) { if (missingParsedAlgorithms.length > 0) {
validationErrors.push( validationErrors.push(
t( t(
"msg.dev.clients.general.public_key.validation.missing_parsed_algorithms", "ui.dev.clients.general.public_key.validation.missing_parsed_algorithms",
"JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}", "JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}",
{ details: missingParsedAlgorithmSummary }, { details: missingParsedAlgorithmSummary },
), ),
@@ -1970,6 +1972,77 @@ function ClientGeneralPage() {
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-100">
<Info className="h-4 w-4" />
<span>
{t(
"ui.dev.clients.general.scopes.offline_access_title",
"Refresh token 사용 시 offline_access scope가 필요합니다.",
)}
</span>
</div>
<p className="text-xs leading-relaxed text-amber-950/80 dark:text-amber-50/80">
{t(
"msg.dev.clients.general.scopes.offline_access_summary",
"RP가 refresh token을 사용하려면 scope 목록에 offline_access를 포함하고, consent와 grant type 설정도 함께 맞아야 합니다.",
)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1 text-amber-900 hover:bg-amber-500/10 hover:text-amber-950 dark:text-amber-100 dark:hover:bg-amber-500/20 dark:hover:text-amber-50"
onClick={() => setIsOfflineAccessGuideOpen((prev) => !prev)}
aria-expanded={isOfflineAccessGuideOpen}
aria-label={t(
"ui.dev.clients.general.scopes.offline_access_toggle",
"상세 안내 보기",
)}
>
{isOfflineAccessGuideOpen ? (
<X className="h-3.5 w-3.5" />
) : (
<Info className="h-3.5 w-3.5" />
)}
{t("ui.common.info", "상세 안내")}
</Button>
</div>
{isOfflineAccessGuideOpen ? (
<div className="mt-3 rounded-lg border border-amber-500/20 bg-background/70 p-3 text-xs leading-relaxed text-foreground shadow-sm">
<p className="font-semibold">
{t(
"msg.dev.clients.general.scopes.offline_access_conditions_title",
"Hydra 기준으로 refresh token 발급 조건",
)}
</p>
<ul className="mt-2 list-disc space-y-1 pl-4">
<li>
{t(
"msg.dev.clients.general.scopes.offline_access_condition_request",
"authorization request scope에 offline 또는 offline_access 포함",
)}
</li>
<li>
{t(
"msg.dev.clients.general.scopes.offline_access_condition_consent",
"consent accept의 granted_scope에 offline 또는 offline_access 포함",
)}
</li>
<li>
{t(
"msg.dev.clients.general.scopes.offline_access_condition_grant_type",
"client grant_types에 refresh_token 포함",
)}
</li>
</ul>
</div>
) : null}
</div>
{isScopePickerOpen && ( {isScopePickerOpen && (
<div className="space-y-3 rounded-md border border-border bg-muted/10 p-4"> <div className="space-y-3 rounded-md border border-border bg-muted/10 p-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -1977,13 +2050,13 @@ function ClientGeneralPage() {
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{t( {t(
"ui.dev.clients.general.scopes.picker_title", "ui.dev.clients.general.scopes.picker_title",
"추가할 scope 선택", "Add a scope",
)} )}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
"msg.dev.clients.general.scopes.picker_help", "ui.dev.clients.general.scopes.picker_help",
"지원 scope와 Custom Claim key를 선택해 scope 목록에 추가합니다.", "Choose a supported scope or custom claim key to add it to the scope list.",
)} )}
</p> </p>
</div> </div>
@@ -2357,7 +2430,7 @@ function ClientGeneralPage() {
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
{t( {t(
"ui.dev.clients.general.tenant_access.picker_label", "ui.dev.clients.general.tenant_access.picker_label",
"허용 테넌트 추가", "Add allowed tenant",
)}{" "} )}{" "}
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</Label> </Label>
@@ -2996,8 +3069,8 @@ function ClientGeneralPage() {
</Label> </Label>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{t( {t(
"msg.dev.clients.general.security.headless_login_enable_help", "ui.dev.clients.general.security.headless_login_enable_help",
"Baron SSO 로그인 창 대신 RP 자체 로그인 UI를 사용하고, RP backend의 서명 키로 클라이언트를 검증하려는 경우 활성화합니다.", "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.",
)} )}
</p> </p>
</div> </div>

View File

@@ -16,11 +16,7 @@ type TenantAccessPickerProps = {
}; };
function resolveOrgFrontBaseUrl() { function resolveOrgFrontBaseUrl() {
return ( return import.meta.env.ORGFRONT_URL || "http://localhost:5175";
import.meta.env.VITE_ORGFRONT_PUBLIC_URL ||
import.meta.env.ORGFRONT_URL ||
"http://localhost:5175"
);
} }
export function TenantAccessPicker({ export function TenantAccessPicker({
@@ -57,7 +53,7 @@ export function TenantAccessPicker({
aria-modal="true" aria-modal="true"
aria-label={t( aria-label={t(
"ui.dev.clients.general.tenant_access.picker_title", "ui.dev.clients.general.tenant_access.picker_title",
"테넌트 선택", "Select tenant",
)} )}
> >
<div className="flex h-[92vh] w-[min(96vw,1200px)] flex-col overflow-hidden rounded-2xl border border-border bg-background p-4 shadow-2xl"> <div className="flex h-[92vh] w-[min(96vw,1200px)] flex-col overflow-hidden rounded-2xl border border-border bg-background p-4 shadow-2xl">
@@ -66,13 +62,13 @@ export function TenantAccessPicker({
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
{t( {t(
"ui.dev.clients.general.tenant_access.picker_title", "ui.dev.clients.general.tenant_access.picker_title",
"테넌트 선택", "Select tenant",
)} )}
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t( {t(
"msg.dev.clients.general.tenant_access.picker_description", "ui.dev.clients.general.tenant_access.picker_description",
"orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다.", "Choose the tenants to allow from the orgfront org chart and add them to the list.",
)} )}
</p> </p>
</div> </div>
@@ -83,7 +79,7 @@ export function TenantAccessPicker({
className="shrink-0" className="shrink-0"
onClick={() => setPickerOpen(false)} onClick={() => setPickerOpen(false)}
> >
{t("ui.common.close", "닫기")} {t("ui.common.close", "Close")}
</Button> </Button>
</div> </div>
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-md border"> <div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-md border">
@@ -102,7 +98,7 @@ export function TenantAccessPicker({
variant="outline" variant="outline"
onClick={() => setPickerOpen(false)} onClick={() => setPickerOpen(false)}
> >
{t("ui.common.close", "닫기")} {t("ui.common.close", "Close")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -123,7 +119,7 @@ export function TenantAccessPicker({
<Building2 className="h-4 w-4" /> <Building2 className="h-4 w-4" />
{t( {t(
"ui.dev.clients.general.tenant_access.open_picker", "ui.dev.clients.general.tenant_access.open_picker",
"테넌트 선택기 열기", "Open tenant picker",
)} )}
</Button> </Button>
@@ -132,13 +128,13 @@ export function TenantAccessPicker({
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground"> <div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
{selectedCount > 0 {selectedCount > 0
? t( ? t(
"msg.dev.clients.general.tenant_access.picker_hint_with_count", "ui.dev.clients.general.tenant_access.picker_hint_with_count",
"현재 {{count}}개가 선택되어 있습니다.", "{{count}} tenants selected.",
{ count: selectedCount }, { count: selectedCount },
) )
: t( : t(
"msg.dev.clients.general.tenant_access.picker_hint", "ui.dev.clients.general.tenant_access.picker_hint",
"선택기를 열어 허용 테넌트를 추가하세요.", "Open the picker to add allowed tenants.",
)} )}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,18 @@
import { describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { normalizeDeveloperAccessPageSelection } from "./developerAccessPages"; import {
developerAccessPagesToLabel,
getDeveloperAccessPageLabel,
normalizeDeveloperAccessPageSelection,
} from "./developerAccessPages";
beforeEach(() => {
window.localStorage.clear();
window.localStorage.setItem("locale", "ko");
});
afterEach(() => {
window.localStorage.clear();
});
describe("developer access pages", () => { describe("developer access pages", () => {
it("collapses all non-all pages into all", () => { it("collapses all non-all pages into all", () => {
@@ -21,4 +34,18 @@ describe("developer access pages", () => {
it("keeps explicit all selection", () => { it("keeps explicit all selection", () => {
expect(normalizeDeveloperAccessPageSelection(["all"])).toEqual(["all"]); 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",
);
});
}); });

View File

@@ -1,3 +1,5 @@
import { t } from "../../lib/i18n";
export type DeveloperAccessPage = export type DeveloperAccessPage =
| "all" | "all"
| "overview" | "overview"
@@ -10,15 +12,40 @@ export const developerAccessPageOrder: DeveloperAccessPage[] = [
"audit", "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; value: DeveloperAccessPage;
label: string; label: string;
}> = [ }> {
{ value: "all", label: "전체" }, return developerAccessPageOrder.length > 0
{ value: "overview", label: "개요" }, ? [
{ value: "client_create", label: "연동 앱 추가" }, { value: "all", label: getDeveloperAccessPageLabel("all") },
{ value: "audit", label: "감사로그" }, {
]; value: "overview",
label: getDeveloperAccessPageLabel("overview"),
},
{
value: "client_create",
label: getDeveloperAccessPageLabel("client_create"),
},
{ value: "audit", label: getDeveloperAccessPageLabel("audit") },
]
: [];
}
export function normalizeDeveloperAccessPages( export function normalizeDeveloperAccessPages(
pages: Array<string | undefined | null>, pages: Array<string | undefined | null>,
@@ -61,20 +88,11 @@ export function normalizeDeveloperAccessPageSelection(
export function developerAccessPagesToLabel(pages?: Array<string | null>) { export function developerAccessPagesToLabel(pages?: Array<string | null>) {
const normalized = normalizeDeveloperAccessPages(pages ?? []); const normalized = normalizeDeveloperAccessPages(pages ?? []);
if (normalized.length === 0 || normalized.includes("all")) { if (normalized.length === 0 || normalized.includes("all")) {
return "전체"; return getDeveloperAccessPageLabel("all");
} }
return normalized return normalized
.map((page) => { .map((page) => {
switch (page) { return getDeveloperAccessPageLabel(page);
case "overview":
return "개요";
case "client_create":
return "연동 앱 추가";
case "audit":
return "감사로그";
default:
return page;
}
}) })
.join(", "); .join(", ");
} }

View File

@@ -38,7 +38,8 @@ import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi"; import { fetchMe } from "../auth/authApi";
import { import {
type DeveloperAccessPage, type DeveloperAccessPage,
developerAccessPageOptions, developerAccessPagesToLabel,
getDeveloperAccessPageOptions,
normalizeDeveloperAccessPageSelection, normalizeDeveloperAccessPageSelection,
normalizeDeveloperAccessPages, normalizeDeveloperAccessPages,
} from "../developer-access/developerAccessPages"; } from "../developer-access/developerAccessPages";
@@ -62,6 +63,7 @@ export default function DeveloperGrantsPage() {
}); });
const profileRole = me?.role?.trim() || role; const profileRole = me?.role?.trim() || role;
const isSuperAdmin = profileRole === "super_admin"; const isSuperAdmin = profileRole === "super_admin";
const developerAccessPageOptions = getDeveloperAccessPageOptions();
const [userSearch, setUserSearch] = useState(""); const [userSearch, setUserSearch] = useState("");
const deferredUserSearch = useDeferredValue(userSearch.trim()); const deferredUserSearch = useDeferredValue(userSearch.trim());
@@ -621,9 +623,7 @@ export default function DeveloperGrantsPage() {
: ["all"] : ["all"]
).map((page) => ( ).map((page) => (
<Badge key={page} variant="outline"> <Badge key={page} variant="outline">
{developerAccessPageOptions.find( {developerAccessPagesToLabel([page])}
(option) => option.value === page,
)?.label ?? page}
</Badge> </Badge>
))} ))}
</div> </div>

View File

@@ -49,7 +49,8 @@ import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi"; import { fetchMe } from "../auth/authApi";
import { import {
type DeveloperAccessPage, type DeveloperAccessPage,
developerAccessPageOptions, developerAccessPagesToLabel,
getDeveloperAccessPageOptions,
normalizeDeveloperAccessPageSelection, normalizeDeveloperAccessPageSelection,
normalizeDeveloperAccessPages, normalizeDeveloperAccessPages,
} from "../developer-access/developerAccessPages"; } from "../developer-access/developerAccessPages";
@@ -287,9 +288,7 @@ export default function DeveloperRequestPage() {
req.accessPages, req.accessPages,
).map((page) => ( ).map((page) => (
<Badge key={page} variant="outline"> <Badge key={page} variant="outline">
{developerAccessPageOptions.find( {developerAccessPagesToLabel([page])}
(option) => option.value === page,
)?.label ?? page}
</Badge> </Badge>
)) ))
) : ( ) : (
@@ -479,6 +478,7 @@ function RequestAccessModal({
const [accessPages, setAccessPages] = useState<DeveloperAccessPage[]>([ const [accessPages, setAccessPages] = useState<DeveloperAccessPage[]>([
"all", "all",
]); ]);
const developerAccessPageOptions = getDeveloperAccessPageOptions();
const organizationDisplay = organization.trim() || t("ui.common.na", "없음"); const organizationDisplay = organization.trim() || t("ui.common.na", "없음");
useEffect(() => { useEffect(() => {

View File

@@ -89,6 +89,127 @@ describe("recent client changes", () => {
{ label: "Relation", value: "admins" }, { label: "Relation", value: "admins" },
{ label: "Subject", value: "User:1" }, { 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", () => { it("builds recent client changes with sorting, filtering, and detail slicing", () => {
@@ -192,6 +313,40 @@ describe("recent client changes", () => {
after: { name: "Ignored" }, 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); const changes = buildRecentClientChanges(auditLogs, clients);

View File

@@ -33,6 +33,27 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value); 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<Record<string, unknown>>((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) { export function getRecentClientActionLabel(action: string) {
switch (action) { switch (action) {
case "CREATE_CLIENT": case "CREATE_CLIENT":
@@ -74,17 +95,174 @@ function getRecentClientFieldLabel(key: string) {
"ui.dev.clients.details.credentials.client_secret", "ui.dev.clients.details.credentials.client_secret",
"클라이언트 시크릿", "클라이언트 시크릿",
); );
case "id_token_claims":
return t("ui.dev.clients.general.id_token_claims.title", "Custom Claims");
default: default:
return key; return key;
} }
} }
function getIdTokenClaimIdentity(claim: Record<string, unknown>) {
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<string, unknown>) {
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<string, Record<string, unknown>>();
const afterByIdentity = new Map<string, Record<string, unknown>>();
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( export function buildRecentClientChangeDetails(
action: string, action: string,
details: AuditDetails, details: AuditDetails,
) { ) {
const before = isRecord(details.before) ? details.before : {}; const before = isRecord(details.before) ? details.before : {};
const after = isRecord(details.after) ? details.after : {}; const after = isRecord(details.after) ? details.after : {};
const sourceDetails =
action === "ADD_RELATION"
? { ...after, ...details }
: action === "REMOVE_RELATION"
? { ...before, ...details }
: {};
if (action === "ROTATE_SECRET") { if (action === "ROTATE_SECRET") {
return [ return [
@@ -96,21 +274,23 @@ export function buildRecentClientChangeDetails(
} }
if (action === "ADD_RELATION" || action === "REMOVE_RELATION") { if (action === "ADD_RELATION" || action === "REMOVE_RELATION") {
const source = action === "ADD_RELATION" ? after : before; const source = sourceDetails as Record<string, unknown>;
const relation = source.relation;
const subject = source.subject;
return [ return [
...(source.relation ...(typeof relation === "string" && relation
? [ ? [
{ {
label: getRecentClientFieldLabel("relation"), label: getRecentClientFieldLabel("relation"),
value: formatAuditValue(source.relation), value: formatAuditValue(relation),
}, },
] ]
: []), : []),
...(source.subject ...(typeof subject === "string" && subject
? [ ? [
{ {
label: getRecentClientFieldLabel("subject"), label: getRecentClientFieldLabel("subject"),
value: formatAuditValue(source.subject), value: formatAuditValue(subject),
}, },
] ]
: []), : []),
@@ -126,8 +306,21 @@ export function buildRecentClientChangeDetails(
const beforeValue = before[key]; const beforeValue = before[key];
const afterValue = after[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 (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") {
if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) { if (
auditValueSignature(beforeValue) === auditValueSignature(afterValue)
) {
return null; return null;
} }
} }
@@ -161,6 +354,10 @@ export function buildRecentClientChangeDetails(
}) })
.filter((item): item is { label: string; value: string } => Boolean(item)); .filter((item): item is { label: string; value: string } => Boolean(item));
if (changes.length === 0) {
return [];
}
return changes.slice(0, 3); return changes.slice(0, 3);
} }
@@ -194,7 +391,12 @@ export function buildRecentClientChanges(
detailLabels: buildRecentClientChangeDetails(action, details), detailLabels: buildRecentClientChangeDetails(action, details),
} satisfies RecentClientChange; } satisfies RecentClientChange;
}) })
.filter((item): item is RecentClientChange => Boolean(item)) .filter((item): item is RecentClientChange => {
if (!item) {
return false;
}
return item.detailLabels.length > 0;
})
.sort( .sort(
(left, right) => (left, right) =>
new Date(right.timestamp).getTime() - new Date(right.timestamp).getTime() -

View File

@@ -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.");
});
});

View File

@@ -321,12 +321,14 @@ admin_desc = "Manage developer access requests submitted by users."
approved = "Approved." approved = "Approved."
cancelled = "Approval has been cancelled." cancelled = "Approval has been cancelled."
empty = "No requests found." empty = "No requests found."
list.approved_count = "{{count}} users have been approved."
need_cancel_notes = "Please enter a reason for cancelling approval." need_cancel_notes = "Please enter a reason for cancelling approval."
need_notes = "Please enter a rejection reason." need_notes = "Please enter a rejection reason."
rejected = "Rejected." rejected = "Rejected."
user_desc = "Review your request history and submit a new access request." 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] [msg.dev.request.modal]
desc = "Please enter the reason for your request. It will be approved after administrator review." desc = "Please enter the reason for your request. It will be approved after administrator review."
tenant_required = "Please submit a developer access request." 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." empty = "No scopes registered."
subtitle = "Define the permission scopes this application can request." subtitle = "Define the permission scopes this application can request."
tenant = "Tenant access claim" 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] [msg.dev.clients.general.id_token_claims]
subtitle = "Manage RP-specific extension claims separately." 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." empty = "There are no granted permissions."
forbidden = "Only super admin can directly grant developer access." forbidden = "Only super admin can directly grant developer access."
forbidden_desc = "This screen is available only to super admin." 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." 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." 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." load_error = "Failed to load developer access grants."
reason = "Grant reason" reason = "Grant reason"
revoke = "Revoke" 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." tenant_missing = "No tenant information is available for the selected user."
user_required = "Select a user before granting access." user_required = "Select a user before granting access."
phone_missing = "No phone number is registered." 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] [msg.dev.dashboard.notice]
consent_audit = "Consent Audit" consent_audit = "Consent Audit"
@@ -1346,10 +1358,8 @@ admin_notes = "Grant Reason"
all_tenants = "All Tenants" all_tenants = "All Tenants"
approved = "Approved" approved = "Approved"
date = "Granted At" date = "Granted At"
form.title = "Direct Grant"
grant = "Grant Directly" grant = "Grant Directly"
input_section = "Input" input_section = "Input"
list.title = "Granted Access"
pages = "Access Pages" pages = "Access Pages"
read_only = "Read Only" read_only = "Read Only"
reason = "Grant Reason" reason = "Grant Reason"
@@ -1364,6 +1374,12 @@ user_section = "User Selection"
user = "User" user = "User"
user_search_placeholder = "Search by name or email..." 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] [ui.dev.request.modal]
email = "Email" email = "Email"
name = "Name" name = "Name"
@@ -1375,6 +1391,12 @@ reason_placeholder = "e.g. I need to create an OIDC client for internal service
role = "Role" role = "Role"
title = "Developer Access Request" title = "Developer Access Request"
[ui.dev.access_pages]
all = "All"
overview = "Overview"
client_create = "Add linked app"
audit = "Audit Logs"
[ui.dev.request.status] [ui.dev.request.status]
approved = "Approved" approved = "Approved"
cancelled = "Approval Cancelled" cancelled = "Approval Cancelled"
@@ -1590,6 +1612,10 @@ add = "Scope Add"
description_placeholder = "Description Placeholder" description_placeholder = "Description Placeholder"
name_placeholder = "e.g. profile" name_placeholder = "e.g. profile"
title = "Scopes" 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] [ui.dev.clients.general.scopes.table]
description = "Scope Description" 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." 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." 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." 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] [ui.dev.clients.general.id_token_claims]
title = "Custom Claims" title = "Custom Claims"
@@ -1645,7 +1677,7 @@ pkce = "PKCE"
headless_login = "Headless Login" headless_login = "Headless Login"
title = "Security Settings" title = "Security Settings"
headless_login_enable = "Headless Login (Custom Login UI)" 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] [ui.dev.clients.general.public_key]
auth_method = "Token Endpoint Auth Method" auth_method = "Token Endpoint Auth Method"
@@ -1676,6 +1708,12 @@ cache_status = "Status"
cache_uri = "JWKS URI" cache_uri = "JWKS URI"
revoke_cache = "Revoke Cache" 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] [ui.dev.clients.relationships]
title = "Client Relationships" title = "Client Relationships"
add_title = "Add Relationship" add_title = "Add Relationship"

View File

@@ -321,12 +321,14 @@ admin_desc = "사용자들의 개발자 권한 신청 내역을 관리합니다.
approved = "승인되었습니다." approved = "승인되었습니다."
cancelled = "승인이 취소되었습니다." cancelled = "승인이 취소되었습니다."
empty = "신청 내역이 없습니다." empty = "신청 내역이 없습니다."
list.approved_count = "총 {{count}}명의 사용자가 승인되었습니다."
need_cancel_notes = "승인 취소 사유를 입력해주세요." need_cancel_notes = "승인 취소 사유를 입력해주세요."
need_notes = "반려 사유를 입력해주세요." need_notes = "반려 사유를 입력해주세요."
rejected = "반려되었습니다." rejected = "반려되었습니다."
user_desc = "내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다." user_desc = "내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다."
[msg.dev.request.list]
approved_count = "총 {{count}}명의 사용자가 승인되었습니다."
[msg.dev.request.modal] [msg.dev.request.modal]
desc = "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다." desc = "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다."
tenant_required = "개발자 권한 신청을 진행해 주세요." tenant_required = "개발자 권한 신청을 진행해 주세요."
@@ -452,6 +454,11 @@ session_required_off = "끄면: sid가 없어도 sub만으로 로그아웃 처
empty = "등록된 스코프가 없습니다." empty = "등록된 스코프가 없습니다."
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다." subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
tenant = "소속 테넌트 정보 접근" 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] [msg.dev.clients.general.id_token_claims]
subtitle = "RP 전용 확장 claim을 구분해서 관리합니다." subtitle = "RP 전용 확장 claim을 구분해서 관리합니다."
@@ -568,10 +575,8 @@ admin_notes_placeholder = "예: 테스트 환경 확인 후 권한 부여"
empty = "부여된 권한이 없습니다." empty = "부여된 권한이 없습니다."
forbidden = "개발자 권한 직접 부여는 super admin만 사용할 수 있습니다." forbidden = "개발자 권한 직접 부여는 super admin만 사용할 수 있습니다."
forbidden_desc = "이 화면은 super admin만 사용할 수 있습니다." forbidden_desc = "이 화면은 super admin만 사용할 수 있습니다."
form.description = "사용자를 선택하면 현재 소속 테넌트, 이메일, 전화번호를 확인한 뒤 개발자 권한을 즉시 부여합니다."
selected_info_description = "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다." selected_info_description = "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다."
user_section_description = "검색어를 입력해 사용자를 선택합니다. 선택 전에는 다음 단계 정보가 비어 있습니다." user_section_description = "검색어를 입력해 사용자를 선택합니다. 선택 전에는 다음 단계 정보가 비어 있습니다."
list.description = "현재 부여된 개발자 권한 목록입니다."
load_error = "개발자 권한 목록을 불러오지 못했습니다." load_error = "개발자 권한 목록을 불러오지 못했습니다."
reason = "부여 사유" reason = "부여 사유"
revoke = "회수" revoke = "회수"
@@ -583,6 +588,13 @@ tenant_required = "선택한 사용자의 테넌트 정보를 확인할 수 없
tenant_missing = "선택한 사용자의 테넌트 정보를 확인할 수 없습니다." tenant_missing = "선택한 사용자의 테넌트 정보를 확인할 수 없습니다."
user_required = "부여할 사용자를 선택해주세요." user_required = "부여할 사용자를 선택해주세요."
phone_missing = "등록된 전화번호가 없습니다." phone_missing = "등록된 전화번호가 없습니다."
pages_hint = "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다."
[msg.dev.grants.form]
description = "사용자를 선택하면 현재 소속 테넌트, 이메일, 전화번호를 확인한 뒤 개발자 권한을 즉시 부여합니다."
[msg.dev.grants.list]
description = "현재 부여된 개발자 권한 목록입니다."
[msg.dev.dashboard.notice] [msg.dev.dashboard.notice]
consent_audit = "Consent 회수는 감사 로그와 연계" consent_audit = "Consent 회수는 감사 로그와 연계"
@@ -1346,10 +1358,8 @@ admin_notes = "부여 사유"
all_tenants = "전체 테넌트" all_tenants = "전체 테넌트"
approved = "승인됨" approved = "승인됨"
date = "부여일" date = "부여일"
form.title = "직접 부여"
grant = "직접 부여" grant = "직접 부여"
input_section = "입력" input_section = "입력"
list.title = "부여된 권한"
pages = "권한 페이지" pages = "권한 페이지"
read_only = "읽기 전용" read_only = "읽기 전용"
reason = "부여 사유" reason = "부여 사유"
@@ -1364,6 +1374,12 @@ user_section = "사용자 선택"
user = "사용자" user = "사용자"
user_search_placeholder = "이름 또는 이메일 검색..." user_search_placeholder = "이름 또는 이메일 검색..."
[ui.dev.grants.form]
title = "직접 부여"
[ui.dev.grants.list]
title = "부여된 권한"
[ui.dev.request.modal] [ui.dev.request.modal]
email = "이메일" email = "이메일"
name = "성함" name = "성함"
@@ -1375,6 +1391,12 @@ reason_placeholder = "예: 자체 서비스 연동 및 테스트용 OIDC 클라
role = "역할" role = "역할"
title = "개발자 등록 신청" title = "개발자 등록 신청"
[ui.dev.access_pages]
all = "전체"
overview = "개요"
client_create = "연동 앱 추가"
audit = "감사로그"
[ui.dev.request.status] [ui.dev.request.status]
approved = "승인됨" approved = "승인됨"
cancelled = "승인 취소됨" cancelled = "승인 취소됨"
@@ -1589,6 +1611,10 @@ add = "스코프 추가"
description_placeholder = "권한에 대한 설명" description_placeholder = "권한에 대한 설명"
name_placeholder = "e.g. profile" name_placeholder = "e.g. profile"
title = "스코프" 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] [ui.dev.clients.general.scopes.table]
description = "설명" description = "설명"
@@ -1609,6 +1635,12 @@ empty = "검색 결과가 없습니다."
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다." hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다." autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다." 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] [ui.dev.clients.general.id_token_claims]
title = "커스텀 클레임" title = "커스텀 클레임"
@@ -1675,6 +1707,12 @@ cache_status = "상태"
cache_uri = "JWKS URI" cache_uri = "JWKS URI"
revoke_cache = "캐시 삭제" 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] [ui.dev.clients.relationships]
title = "클라이언트 관계" title = "클라이언트 관계"
add_title = "관계 추가" add_title = "관계 추가"

View File

@@ -335,16 +335,19 @@ admin_desc = ""
approved = "" approved = ""
cancelled = "" cancelled = ""
empty = "" empty = ""
list.approved_count = ""
need_cancel_notes = "" need_cancel_notes = ""
need_notes = "" need_notes = ""
rejected = "" rejected = ""
user_desc = "" user_desc = ""
[msg.dev.request.list]
approved_count = ""
[msg.dev.request.modal] [msg.dev.request.modal]
desc = "" desc = ""
tenant_required = "" tenant_required = ""
tenant_required_detail = "" tenant_required_detail = ""
pages_hint = ""
[msg.dev.request.status] [msg.dev.request.status]
approved = "" approved = ""
@@ -499,6 +502,11 @@ session_required_off = ""
empty = "" empty = ""
subtitle = "" subtitle = ""
tenant = "" 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] [msg.dev.clients.general.security]
private_help = "" private_help = ""
@@ -605,14 +613,13 @@ admin_notes_placeholder = ""
empty = "" empty = ""
forbidden = "" forbidden = ""
forbidden_desc = "" forbidden_desc = ""
form.description = ""
selected_info_description = "" selected_info_description = ""
user_section_description = "" user_section_description = ""
list.description = ""
load_error = "" load_error = ""
reason = "" reason = ""
revoke = "" revoke = ""
revoke_success = "" revoke_success = ""
pages_hint = ""
search_empty = "" search_empty = ""
search_loading = "" search_loading = ""
selected_user = "" selected_user = ""
@@ -622,6 +629,12 @@ user_required = ""
phone_missing = "" phone_missing = ""
required = "" required = ""
[msg.dev.grants.form]
description = ""
[msg.dev.grants.list]
description = ""
[msg.dev.dashboard.notice] [msg.dev.dashboard.notice]
consent_audit = "" consent_audit = ""
dev_scope = "" dev_scope = ""
@@ -1398,10 +1411,8 @@ admin_notes = ""
all_tenants = "" all_tenants = ""
approved = "" approved = ""
date = "" date = ""
form.title = ""
grant = "" grant = ""
input_section = "" input_section = ""
list.title = ""
read_only = "" read_only = ""
reason = "" reason = ""
reason_placeholder = "" reason_placeholder = ""
@@ -1415,6 +1426,12 @@ user_section = ""
user = "" user = ""
user_search_placeholder = "" user_search_placeholder = ""
[ui.dev.grants.form]
title = ""
[ui.dev.grants.list]
title = ""
[ui.dev.request.modal] [ui.dev.request.modal]
email = "" email = ""
name = "" name = ""
@@ -1425,6 +1442,12 @@ reason_placeholder = ""
role = "" role = ""
title = "" title = ""
[ui.dev.access_pages]
all = ""
overview = ""
client_create = ""
audit = ""
[ui.dev.request.status] [ui.dev.request.status]
approved = "" approved = ""
cancelled = "" cancelled = ""
@@ -1638,6 +1661,10 @@ add = ""
description_placeholder = "" description_placeholder = ""
name_placeholder = "" name_placeholder = ""
title = "" title = ""
offline_access_title = ""
offline_access_toggle = ""
picker_title = ""
picker_help = ""
[ui.dev.clients.general.scopes.table] [ui.dev.clients.general.scopes.table]
description = "" description = ""
@@ -1658,6 +1685,12 @@ empty = ""
hint = "" hint = ""
autocomplete_hint = "" autocomplete_hint = ""
validation_required = "" validation_required = ""
picker_title = ""
picker_label = ""
open_picker = ""
picker_description = ""
picker_hint = ""
picker_hint_with_count = ""
[ui.dev.clients.general.id_token_claims] [ui.dev.clients.general.id_token_claims]
title = "" title = ""
@@ -1723,6 +1756,12 @@ cache_status = ""
cache_uri = "" cache_uri = ""
revoke_cache = "" 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] [ui.dev.clients.relationships]
title = "" title = ""
add_title = "" add_title = ""

View File

@@ -17,6 +17,7 @@ const allowedHosts = getAllowedHosts(
export default defineConfig( export default defineConfig(
mergeConfig(commonViteConfig, { mergeConfig(commonViteConfig, {
envPrefix: ["VITE_", "ORGFRONT_"],
cacheDir: cacheDir:
process.env.DEVFRONT_VITE_CACHE_DIR ?? process.env.DEVFRONT_VITE_CACHE_DIR ??
"/tmp/baron-sso-devfront-vite-cache", "/tmp/baron-sso-devfront-vite-cache",

View File

@@ -32,10 +32,10 @@ scrape_configs:
regex: '/(.*)' regex: '/(.*)'
target_label: 'container_name' target_label: 'container_name'
# 4. 서비스 상세 라벨 부여 (baron_ 접두사 제거 등) # 4. 서비스 상세 라벨 부여 (baron_ 접두사 제거 등, 접두사가 없는 경우 그대로 유지)
- source_labels: ['container_name'] - source_labels: ['container_name']
regex: 'baron_(.*)' regex: '(?:baron_)?(.*)'
target_label: 'service' target_label: 'service'
- source_labels: ['container_name'] - source_labels: ['container_name']
regex: 'baron_(.*)' regex: '(?:baron_)?(.*)'
target_label: 'job' target_label: 'job'

View File

@@ -455,6 +455,7 @@ services:
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL:-} VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL:-}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY:-} VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY:-}
VITE_OIDC_CLIENT_ID: devfront VITE_OIDC_CLIENT_ID: devfront
ORGFRONT_URL: ${ORGFRONT_URL:-}
container_name: baron_devfront container_name: baron_devfront
restart: unless-stopped restart: unless-stopped
env_file: env_file:

View File

@@ -140,12 +140,12 @@ scrape_configs:
- source_labels: ['container_name'] - source_labels: ['container_name']
regex: '(baron_.*|oathkeeper|kratos|hydra|keto)' regex: '(baron_.*|oathkeeper|kratos|hydra|keto)'
action: keep action: keep
# 컨테이너 명에서 앞의 접두사를 떼어 서비스 및 잡 라벨 부여 (예: baron_backend -> backend) # 컨테이너 명에서 앞의 접두사를 떼어 서비스 및 잡 라벨 부여 (예: baron_backend -> backend, kratos -> kratos)
- source_labels: ['container_name'] - source_labels: ['container_name']
regex: 'baron_(.*)' regex: '(?:baron_)?(.*)'
target_label: 'service' target_label: 'service'
- source_labels: ['container_name'] - source_labels: ['container_name']
regex: 'baron_(.*)' regex: '(?:baron_)?(.*)'
target_label: 'job' target_label: 'job'
# 동적 라벨 추가 # 동적 라벨 추가
- target_label: 'app_env' - target_label: 'app_env'

View File

@@ -368,6 +368,7 @@ tree_hint = "Review parent-child relationships and subtree coverage in the hiera
[msg.admin.tenants.relations] [msg.admin.tenants.relations]
empty = "There are no users with designated fine-grained permissions. Please add a user to configure." empty = "There are no users with designated fine-grained permissions. Please add a user to configure."
remove_all_confirm = "Remove All Confirm" 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." 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" update_success = "Update Success"
@@ -2687,6 +2688,12 @@ title = "Direct Grant"
[ui.dev.grants.list] [ui.dev.grants.list]
title = "Granted Access" title = "Granted Access"
[ui.dev.access_pages]
all = "All"
overview = "Overview"
client_create = "Add linked app"
audit = "Audit Logs"
[ui.dev.header] [ui.dev.header]
plane = "Dev Plane" plane = "Dev Plane"
subtitle = "Manage your applications" subtitle = "Manage your applications"

View File

@@ -368,6 +368,7 @@ tree_hint = "계층 구조를 따라 부모-자식 관계와 하위 범위를
[msg.admin.tenants.relations] [msg.admin.tenants.relations]
empty = "세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요." empty = "세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요."
remove_all_confirm = "이 사용자의 모든 세부 권한을 삭제하시겠습니까?" remove_all_confirm = "이 사용자의 모든 세부 권한을 삭제하시겠습니까?"
super_admin_only_desc = "이 화면의 권한 설정은 시스템 최고 관리자(super_admin)만 수정할 수 있습니다."
subtitle = "사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다." subtitle = "사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다."
update_success = "세부 권한이 성공적으로 변경되었습니다." update_success = "세부 권한이 성공적으로 변경되었습니다."
@@ -2687,6 +2688,12 @@ title = "직접 부여"
[ui.dev.grants.list] [ui.dev.grants.list]
title = "부여된 권한" title = "부여된 권한"
[ui.dev.access_pages]
all = "전체"
overview = "개요"
client_create = "연동 앱 추가"
audit = "감사로그"
[ui.dev.header] [ui.dev.header]
plane = "Dev Plane" plane = "Dev Plane"
subtitle = "Manage your applications" subtitle = "Manage your applications"

View File

@@ -368,6 +368,7 @@ tree_hint = ""
[msg.admin.tenants.relations] [msg.admin.tenants.relations]
empty = "" empty = ""
remove_all_confirm = "" remove_all_confirm = ""
super_admin_only_desc = ""
subtitle = "" subtitle = ""
update_success = "" update_success = ""
@@ -2687,6 +2688,12 @@ title = ""
[ui.dev.grants.list] [ui.dev.grants.list]
title = "" title = ""
[ui.dev.access_pages]
all = ""
overview = ""
client_create = ""
audit = ""
[ui.dev.header] [ui.dev.header]
plane = "" plane = ""
subtitle = "" subtitle = ""

View File

@@ -1,12 +1,4 @@
{ {
"name": "baron-sso-root", "name": "baron-sso-root",
"private": true, "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"
}
}
} }

View File

@@ -5,3 +5,8 @@ packages:
- "common" - "common"
allowBuilds: allowBuilds:
'@biomejs/biome': false '@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"

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:userfront/i18n.dart'; import 'package:userfront/i18n.dart';
import '../../../core/i18n/locale_utils.dart'; import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_proxy_service.dart';
@@ -1739,105 +1740,49 @@ Matters not expressly provided in this Policy are governed by the Company's inte
), ),
), ),
), ),
const SizedBox(height: 18), const SizedBox(height: 24),
_buildProfileFieldGroup( Container(
title: tr('ui.userfront.signup.profile.affiliation_type'), padding: const EdgeInsets.all(16),
description: '소속 유형과 회사 정보를 입력합니다.', decoration: BoxDecoration(
isDesktop: isDesktop, color: _signupSurface,
trailing: null, borderRadius: BorderRadius.circular(12),
border: Border.all(color: _signupBorder),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
DropdownButtonFormField<String>( Row(
key: ValueKey(_affiliationType), children: [
initialValue: _affiliationType, Icon(
decoration: InputDecoration( Icons.business,
labelText: tr( color: Theme.of(context).colorScheme.primary,
'ui.userfront.signup.profile.affiliation_type', size: 20,
), ),
border: const OutlineInputBorder(), const SizedBox(width: 8),
), Expanded(
items: [ child: Text(
DropdownMenuItem( '기업/가족사 소속이신가요?',
value: 'GENERAL', style: TextStyle(
child: Text(tr('domain.affiliation.general')), fontWeight: FontWeight.bold,
), fontSize: 14,
DropdownMenuItem( color: _signupInk,
value: 'AFFILIATE', ),
child: Text(tr('domain.affiliation.affiliate')), ),
), ),
], ],
onChanged: _isAffiliateLocked
? null
: (val) {
if (val == null) {
return;
}
setState(() {
_affiliationType = val;
if (_affiliationType == 'GENERAL') {
_companyCode = null;
}
});
},
), ),
AnimatedSize( const SizedBox(height: 8),
duration: const Duration(milliseconds: 180), Text(
curve: Curves.easeOut, '기업 및 가족사 임직원은 연동 문의가 필요합니다.\n\n해당하시는 경우, 사내 관리자 또는 담당자(baroncs@baroncs.co.kr)에게 문의해 주시기 바랍니다.',
child: Column( style: TextStyle(
crossAxisAlignment: CrossAxisAlignment.stretch, fontSize: 12,
children: [ height: 1.45,
if (_affiliationType == 'AFFILIATE') ...[ color: _signupInk.withValues(alpha: 0.7),
const SizedBox(height: 14),
DropdownButtonFormField<String>(
key: ValueKey(_companyCode ?? 'none'),
initialValue: _companyCode,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.company',
),
border: const OutlineInputBorder(),
),
items: _tenants.map((t) {
return DropdownMenuItem<String>(
value: t['slug'],
child: Text(t['name'] ?? t['slug']),
);
}).toList(),
onChanged: (val) =>
setState(() => _companyCode = val),
),
],
],
), ),
), ),
], ],
), ),
), ),
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; canGoNext = true;
} }
if (_currentStep == 3) { if (_currentStep == 3) {
final nameOk = _nameController.text.trim().isNotEmpty; canGoNext = _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;
}
} }
return Scaffold( return Scaffold(

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
cli_config: cli_config:
dependency: transitive dependency: transitive
description: description:
@@ -268,6 +268,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -320,18 +328,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -653,26 +661,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.30.0" version: "1.26.3"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.7"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.16" version: "0.6.12"
toml: toml:
dependency: "direct main" dependency: "direct main"
description: description: