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:
@@ -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=
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" &&
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
48
backend/internal/handler/tenant_assignment_policy_test.go
Normal file
48
backend/internal/handler/tenant_assignment_policy_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(", ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() -
|
||||||
|
|||||||
36
devfront/src/lib/i18n.test.ts
Normal file
36
devfront/src/lib/i18n.test.ts
Normal 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = "관계 추가"
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user