forked from baron/baron-sso
chore: consolidate local integration changes
This commit is contained in:
@@ -60,10 +60,14 @@ import {
|
||||
TabsTrigger,
|
||||
} from "../../components/ui/tabs";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||
import type {
|
||||
GlobalCustomClaimDefinition,
|
||||
PasswordPolicyResponse,
|
||||
} from "../../lib/adminApi";
|
||||
import {
|
||||
deleteUser,
|
||||
fetchAllTenants,
|
||||
fetchGlobalCustomClaimDefinitions,
|
||||
fetchMe,
|
||||
fetchPasswordPolicy,
|
||||
fetchTenant,
|
||||
@@ -111,6 +115,25 @@ type PickerTarget = { kind: "appointment"; index: number };
|
||||
type AppointmentDraft = UserAppointment & {
|
||||
draftId: string;
|
||||
};
|
||||
type GlobalCustomClaimType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "date"
|
||||
| "datetime";
|
||||
type CustomClaimPermission = "admin_only" | "user_and_admin";
|
||||
type GlobalCustomClaimRow = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
valueType: GlobalCustomClaimType;
|
||||
readPermission: CustomClaimPermission;
|
||||
writePermission: CustomClaimPermission;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const PASSWORD_RESET_MIN_LENGTH = 12;
|
||||
|
||||
@@ -179,6 +202,74 @@ function createDraftId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
||||
}
|
||||
|
||||
function createGlobalCustomClaimRows(
|
||||
metadata: Record<string, unknown>,
|
||||
definitions: GlobalCustomClaimDefinition[],
|
||||
): GlobalCustomClaimRow[] {
|
||||
const rawClaims = isMetadataRecord(metadata.global_custom_claims)
|
||||
? metadata.global_custom_claims
|
||||
: {};
|
||||
|
||||
return definitions.map((definition, index) => {
|
||||
const value = rawClaims[definition.key];
|
||||
return {
|
||||
id: `${definition.key}-${index}`,
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
value:
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value == null
|
||||
? ""
|
||||
: JSON.stringify(value),
|
||||
valueType: definition.valueType,
|
||||
readPermission: definition.readPermission,
|
||||
writePermission: definition.writePermission,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function globalCustomClaimInputType(valueType: GlobalCustomClaimType) {
|
||||
if (valueType === "date") {
|
||||
return "date";
|
||||
}
|
||||
if (valueType === "datetime") {
|
||||
return "datetime-local";
|
||||
}
|
||||
if (valueType === "number") {
|
||||
return "number";
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
|
||||
function globalCustomClaimRowsToMetadata(rows: GlobalCustomClaimRow[]) {
|
||||
const claims: Record<string, unknown> = {};
|
||||
const types: Record<string, GlobalCustomClaimType> = {};
|
||||
const permissions: Record<
|
||||
string,
|
||||
{
|
||||
readPermission: CustomClaimPermission;
|
||||
writePermission: CustomClaimPermission;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.key.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
claims[key] = row.value.trim();
|
||||
types[key] = row.valueType;
|
||||
permissions[key] = {
|
||||
readPermission: row.readPermission,
|
||||
writePermission: row.writePermission,
|
||||
};
|
||||
}
|
||||
|
||||
return { claims, types, permissions };
|
||||
}
|
||||
|
||||
async function resolveTenantSelection(
|
||||
selection: OrgChartTenantSelection,
|
||||
tenants: TenantSummary[],
|
||||
@@ -408,6 +499,9 @@ function UserDetailPage() {
|
||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||
AppointmentDraft[]
|
||||
>([]);
|
||||
const [globalCustomClaimRows, setGlobalCustomClaimRows] = React.useState<
|
||||
GlobalCustomClaimRow[]
|
||||
>([]);
|
||||
const [pickerTarget, setPickerTarget] = React.useState<PickerTarget | null>(
|
||||
null,
|
||||
);
|
||||
@@ -449,6 +543,14 @@ function UserDetailPage() {
|
||||
queryKey: ["password-policy"],
|
||||
queryFn: fetchPasswordPolicy,
|
||||
});
|
||||
const { data: globalCustomClaimDefinitionsData } = useQuery({
|
||||
queryKey: ["global-custom-claim-definitions"],
|
||||
queryFn: fetchGlobalCustomClaimDefinitions,
|
||||
});
|
||||
const globalCustomClaimDefinitions = React.useMemo(
|
||||
() => globalCustomClaimDefinitionsData?.items ?? [],
|
||||
[globalCustomClaimDefinitionsData?.items],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -757,6 +859,9 @@ function UserDetailPage() {
|
||||
? "hanmac"
|
||||
: "external";
|
||||
setUserCategory(resolvedUserCategory);
|
||||
setGlobalCustomClaimRows(
|
||||
createGlobalCustomClaimRows(metadata, globalCustomClaimDefinitions),
|
||||
);
|
||||
const familyFallbackTenants = [
|
||||
...(user.joinedTenants ?? []),
|
||||
...(user.tenant ? [user.tenant] : []),
|
||||
@@ -814,7 +919,14 @@ function UserDetailPage() {
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]);
|
||||
}, [
|
||||
globalCustomClaimDefinitions,
|
||||
hanmacFamilyTenantId,
|
||||
personalTenant,
|
||||
tenants,
|
||||
user,
|
||||
reset,
|
||||
]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
||||
@@ -963,6 +1075,29 @@ function UserDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateGlobalCustomClaimRow = (
|
||||
id: string,
|
||||
patch: Partial<GlobalCustomClaimRow>,
|
||||
) => {
|
||||
setGlobalCustomClaimRows((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
};
|
||||
|
||||
const saveGlobalCustomClaims = () => {
|
||||
const { claims, types, permissions } = globalCustomClaimRowsToMetadata(
|
||||
globalCustomClaimRows,
|
||||
);
|
||||
mutation.mutate({
|
||||
metadata: {
|
||||
...((user?.metadata as Record<string, unknown> | undefined) ?? {}),
|
||||
global_custom_claims: claims,
|
||||
global_custom_claim_types: types,
|
||||
global_custom_claim_permissions: permissions,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const userAffiliatedTenants = React.useMemo(() => {
|
||||
const joined = user?.joinedTenants || [];
|
||||
const primary = user?.tenant;
|
||||
@@ -1121,6 +1256,17 @@ function UserDetailPage() {
|
||||
<Building2 size={16} className="mr-2" />
|
||||
{t("ui.admin.users.detail.tabs.tenants", "테넌트 프로필")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="customClaims"
|
||||
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
||||
data-testid="global-custom-claim-tab"
|
||||
>
|
||||
<Key size={16} className="mr-2" />
|
||||
{t(
|
||||
"ui.admin.users.detail.tabs.custom_claims",
|
||||
"전역 Custom Claims",
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
||||
@@ -1793,6 +1939,135 @@ function UserDetailPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="customClaims"
|
||||
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
|
||||
>
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Key size={18} className="text-primary" />
|
||||
{t(
|
||||
"ui.admin.users.detail.custom_claims.title",
|
||||
"사용자별 Custom Claim 값",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.detail.custom_claims.description",
|
||||
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => navigate("/users/custom-claims")}
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.admin.users.global_custom_claims.manage_definitions",
|
||||
"전역 정의 관리",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-8">
|
||||
{globalCustomClaimRows.length === 0 ? (
|
||||
<div className="rounded-2xl border-2 border-dashed bg-muted/5 py-12 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.detail.custom_claims.empty",
|
||||
"전역으로 정의된 custom claim이 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{globalCustomClaimRows.map((claim) => (
|
||||
<div
|
||||
key={claim.id}
|
||||
className="grid gap-3 lg:grid-cols-[minmax(180px,0.8fr)_130px_150px_160px_minmax(220px,1fr)]"
|
||||
>
|
||||
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
|
||||
{claim.key}
|
||||
</div>
|
||||
<Badge
|
||||
variant="muted"
|
||||
className="h-10 justify-center rounded-md px-3 font-mono text-xs"
|
||||
>
|
||||
{claim.valueType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="muted"
|
||||
className="h-10 justify-center rounded-md px-3 text-xs"
|
||||
>
|
||||
{claim.readPermission === "user_and_admin"
|
||||
? t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
)
|
||||
: t(
|
||||
"ui.common.custom_claim_permission.admin_only",
|
||||
"관리자만 가능",
|
||||
)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="muted"
|
||||
className="h-10 justify-center rounded-md px-3 text-xs"
|
||||
>
|
||||
{claim.writePermission === "user_and_admin"
|
||||
? t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
)
|
||||
: t(
|
||||
"ui.common.custom_claim_permission.admin_only",
|
||||
"관리자만 가능",
|
||||
)}
|
||||
</Badge>
|
||||
<Input
|
||||
type={globalCustomClaimInputType(claim.valueType)}
|
||||
value={claim.value}
|
||||
onChange={(event) =>
|
||||
updateGlobalCustomClaimRow(claim.id, {
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
data-testid={`global-custom-claim-value-${claim.key || claim.id}`}
|
||||
placeholder="claim value"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={mutation.isPending}
|
||||
onClick={saveGlobalCustomClaims}
|
||||
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<span className="text-base font-bold">
|
||||
{t(
|
||||
"ui.admin.users.detail.custom_claims.save",
|
||||
"사용자 Claim 값 저장",
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</form>
|
||||
|
||||
<TabsContent
|
||||
|
||||
Reference in New Issue
Block a user