1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/1058-adminfront-tab-rebac-permissions

This commit is contained in:
2026-06-12 20:28:18 +09:00
148 changed files with 11895 additions and 2024 deletions

View File

@@ -141,6 +141,15 @@ function isMetadataRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeCustomClaimPermission(
value: unknown,
fallback: CustomClaimPermission,
): CustomClaimPermission {
return value === "admin_only" || value === "user_and_admin"
? value
: fallback;
}
function cleanMetadataValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value
@@ -209,9 +218,18 @@ function createGlobalCustomClaimRows(
const rawClaims = isMetadataRecord(metadata.global_custom_claims)
? metadata.global_custom_claims
: {};
const rawPermissions = isMetadataRecord(
metadata.global_custom_claim_permissions,
)
? metadata.global_custom_claim_permissions
: {};
return definitions.map((definition, index) => {
const value = rawClaims[definition.key];
const rawPermission = rawPermissions[definition.key];
const permission: Record<string, unknown> = isMetadataRecord(rawPermission)
? rawPermission
: {};
return {
id: `${definition.key}-${index}`,
key: definition.key,
@@ -224,8 +242,14 @@ function createGlobalCustomClaimRows(
? ""
: JSON.stringify(value),
valueType: definition.valueType,
readPermission: definition.readPermission,
writePermission: definition.writePermission,
readPermission: normalizeCustomClaimPermission(
permission.readPermission,
definition.readPermission,
),
writePermission: normalizeCustomClaimPermission(
permission.writePermission,
definition.writePermission,
),
};
});
}
@@ -291,6 +315,48 @@ async function resolveTenantSelection(
};
}
function getTenantVisibility(tenant?: TenantSummary) {
const value = tenant?.config?.visibility;
return typeof value === "string" ? value.trim().toLowerCase() : "public";
}
function isPrivateTenant(tenant?: TenantSummary) {
return getTenantVisibility(tenant) === "private";
}
function appointmentTenantsFromMetadata(
metadata: Record<string, unknown> | undefined,
tenants: TenantSummary[],
) {
const rawAppointments = metadata?.additionalAppointments;
if (!Array.isArray(rawAppointments)) {
return [];
}
return rawAppointments
.map((raw) => {
if (!raw || typeof raw !== "object") {
return null;
}
const appointment = raw as Record<string, unknown>;
const tenantId =
typeof appointment.tenantId === "string" ? appointment.tenantId : "";
const tenantSlug =
typeof appointment.tenantSlug === "string"
? appointment.tenantSlug
: typeof appointment.slug === "string"
? appointment.slug
: "";
return tenants.find(
(tenant) =>
(tenantId && tenant.id === tenantId) ||
(tenantSlug && tenant.slug === tenantSlug),
);
})
.filter((tenant): tenant is TenantSummary => Boolean(tenant))
.filter((tenant) => !isPrivateTenant(tenant));
}
function createEmptyAppointment(): AppointmentDraft {
return {
draftId: createDraftId(),
@@ -385,8 +451,6 @@ function TenantMetadataFields({
register: UseFormRegister<UserFormValues>;
errors: FieldErrors<UserFormValues>;
}) {
if (schema.length === 0) return null;
return (
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm">
<div className="bg-muted/30 px-5 py-3 border-b border-border flex items-center justify-between">
@@ -401,74 +465,85 @@ function TenantMetadataFields({
</span>
</div>
<div className="p-6 grid gap-6 md:grid-cols-2">
{schema.map((field) => (
<div key={field.key} className="space-y-2">
<Label
htmlFor={`metadata.${tenant.id}.${field.key}`}
className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
>
{field.label}
{field.required && <span className="text-destructive">*</span>}
{field.adminOnly && (
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
Admin Only
</span>
)}
{field.isLoginId && (
<span className="ml-2 text-[9px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
{t("ui.admin.users.detail.form.is_login_id", "로그인 ID")}
</span>
)}
</Label>
<Input
id={`metadata.${tenant.id}.${field.key}`}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"}
{...register(`metadata.${tenant.id}.${field.key}` as const, {
required: field.required
? t(
"msg.admin.users.detail.form.field_required",
"필수입니다.",
)
: false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.detail.form.invalid_format",
"형식이 올바르지 않습니다.",
),
}
: undefined,
})}
/>
{(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key] && (
<p className="text-[10px] text-destructive font-medium">
{
(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key]?.message
}
</p>
{schema.length === 0 ? (
<p className="text-sm text-muted-foreground md:col-span-2">
{t(
"msg.admin.users.detail.tenant_schema_empty",
"이 테넌트에 설정된 프로필 필드가 없습니다.",
)}
</div>
))}
</p>
) : (
schema.map((field) => (
<div key={field.key} className="space-y-2">
<Label
htmlFor={`metadata.${tenant.id}.${field.key}`}
className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
>
{field.label}
{field.required && <span className="text-destructive">*</span>}
{field.adminOnly && (
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
Admin Only
</span>
)}
{field.isLoginId && (
<span className="ml-2 text-[9px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
{t("ui.admin.users.detail.form.is_login_id", "로그인 ID")}
</span>
)}
</Label>
<Input
id={`metadata.${tenant.id}.${field.key}`}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={
field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"
}
{...register(`metadata.${tenant.id}.${field.key}` as const, {
required: field.required
? t(
"msg.admin.users.detail.form.field_required",
"필수입니다.",
)
: false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.detail.form.invalid_format",
"형식이 올바르지 않습니다.",
),
}
: undefined,
})}
/>
{(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key] && (
<p className="text-[10px] text-destructive font-medium">
{
(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key]?.message
}
</p>
)}
</div>
))
)}
</div>
</div>
);
@@ -1103,12 +1178,30 @@ function UserDetailPage() {
const userAffiliatedTenants = React.useMemo(() => {
const joined = user?.joinedTenants || [];
const primary = user?.tenant;
const all = [...joined];
if (primary && !joined.some((t) => t.id === primary.id)) {
const appointmentTenants = appointmentTenantsFromMetadata(
user?.metadata as Record<string, unknown> | undefined,
tenants,
);
const all = joined.filter((tenant) => {
const fullTenant = tenants.find((item) => item.id === tenant.id);
return !isPrivateTenant(fullTenant ?? tenant);
});
if (
primary &&
!isPrivateTenant(
tenants.find((tenant) => tenant.id === primary.id) ?? primary,
) &&
!all.some((t) => t.id === primary.id)
) {
all.unshift(primary);
}
for (const tenant of appointmentTenants) {
if (!all.some((item) => item.id === tenant.id)) {
all.push(tenant);
}
}
return all;
}, [user?.joinedTenants, user?.tenant]);
}, [tenants, user?.joinedTenants, user?.metadata, user?.tenant]);
const selectableRepresentativeTenants = React.useMemo(
() =>
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
@@ -1962,7 +2055,7 @@ function UserDetailPage() {
<CardDescription>
{t(
"msg.admin.users.detail.custom_claims.description",
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
)}
</CardDescription>
</div>