forked from baron/baron-sso
2504 lines
94 KiB
TypeScript
2504 lines
94 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import {
|
|
ArrowLeft,
|
|
BadgeCheck,
|
|
Building2,
|
|
Copy,
|
|
Eye,
|
|
EyeOff,
|
|
History,
|
|
Key,
|
|
Loader2,
|
|
Mail,
|
|
Plus,
|
|
RefreshCw,
|
|
Save,
|
|
Shield,
|
|
ShieldAlert,
|
|
Trash2,
|
|
Users,
|
|
X,
|
|
} from "lucide-react";
|
|
import * as React from "react";
|
|
import {
|
|
type FieldErrors,
|
|
type UseFormRegister,
|
|
useForm,
|
|
} from "react-hook-form";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { Badge } from "../../components/ui/badge";
|
|
import { Button } from "../../components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "../../components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "../../components/ui/dialog";
|
|
import { Input } from "../../components/ui/input";
|
|
import { Label } from "../../components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "../../components/ui/select";
|
|
import { Switch } from "../../components/ui/switch";
|
|
import {
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
} from "../../components/ui/tabs";
|
|
import { toast } from "../../components/ui/use-toast";
|
|
import type {
|
|
GlobalCustomClaimDefinition,
|
|
PasswordPolicyResponse,
|
|
} from "../../lib/adminApi";
|
|
import {
|
|
deleteUser,
|
|
fetchAllTenants,
|
|
fetchGlobalCustomClaimDefinitions,
|
|
fetchMe,
|
|
fetchPasswordPolicy,
|
|
fetchTenant,
|
|
fetchUser,
|
|
fetchUserRpHistory,
|
|
type TenantSummary,
|
|
type UserAppointment,
|
|
type UserUpdateRequest,
|
|
updateUser,
|
|
} from "../../lib/adminApi";
|
|
import { t } from "../../lib/i18n";
|
|
import {
|
|
canManageUserInTenantScope,
|
|
normalizeAdminRole,
|
|
} from "../../lib/roles";
|
|
import { generateSecurePassword } from "../../lib/utils";
|
|
import {
|
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
|
filterTenantsByMembershipRoot,
|
|
getTenantGradeOptions,
|
|
isHanmacFamilyTenant,
|
|
type OrgChartTenantSelection,
|
|
parseOrgChartTenantSelection,
|
|
resolveUserMembershipTenantTab,
|
|
USER_MEMBERSHIP_TENANT_TABS,
|
|
type UserMembershipTenantTabId,
|
|
} from "./orgChartPicker";
|
|
import { formatUserPolicyMessage } from "./userPolicyMessages";
|
|
import type { UserSchemaField } from "./userSchemaFields";
|
|
import {
|
|
normalizeUserStatusValue,
|
|
userStatusLabel,
|
|
userStatusValues,
|
|
} from "./userStatus";
|
|
import { resolvePersonalTenant } from "./utils/personalTenant";
|
|
|
|
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
|
email: string;
|
|
metadata: Record<string, unknown> & {
|
|
employee_id?: string;
|
|
sub_email?: string | string[];
|
|
};
|
|
};
|
|
type UserCategory = UserMembershipTenantTabId;
|
|
|
|
type PasswordResetMode = "generated" | "manual";
|
|
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;
|
|
|
|
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
|
|
.filter((item): item is string => typeof item === "string")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
if (isMetadataRecord(value)) {
|
|
return Object.fromEntries(
|
|
Object.entries(value).filter(
|
|
([_, fieldValue]) =>
|
|
fieldValue !== undefined && fieldValue !== null && fieldValue !== "",
|
|
),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function normalizeEmployeeIDMetadataValue(value: unknown) {
|
|
if (typeof value === "string" || typeof value === "number") {
|
|
return String(value).trim();
|
|
}
|
|
if (!isMetadataRecord(value)) {
|
|
return "";
|
|
}
|
|
const entries = Object.entries(value)
|
|
.map(([key, fieldValue]) => ({
|
|
index: Number(key),
|
|
value: typeof fieldValue === "string" ? fieldValue : "",
|
|
}))
|
|
.filter((entry) => Number.isInteger(entry.index) && entry.value.length > 0)
|
|
.sort((a, b) => a.index - b.index);
|
|
if (entries.length === 0) {
|
|
return "";
|
|
}
|
|
return entries
|
|
.map((entry) => entry.value)
|
|
.join("")
|
|
.trim();
|
|
}
|
|
|
|
function normalizeSubEmails(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.filter((item): item is string => typeof item === "string")
|
|
.map((item) => item.trim())
|
|
.filter((item) => item.includes("@"));
|
|
}
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
return value
|
|
.split(/[;,\n\r\t]/)
|
|
.map((email) => email.trim())
|
|
.filter((email) => email.includes("@"));
|
|
}
|
|
return [];
|
|
}
|
|
|
|
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
|
|
: {};
|
|
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,
|
|
label: definition.label,
|
|
description: definition.description,
|
|
value:
|
|
typeof value === "string"
|
|
? value
|
|
: value == null
|
|
? ""
|
|
: JSON.stringify(value),
|
|
valueType: definition.valueType,
|
|
readPermission: normalizeCustomClaimPermission(
|
|
permission.readPermission,
|
|
definition.readPermission,
|
|
),
|
|
writePermission: normalizeCustomClaimPermission(
|
|
permission.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[],
|
|
) {
|
|
const cached = tenants.find((tenant) => tenant.id === selection.id);
|
|
if (cached) {
|
|
return {
|
|
id: cached.id,
|
|
name: cached.name,
|
|
slug: cached.slug,
|
|
};
|
|
}
|
|
|
|
const tenant = await fetchTenant(selection.id);
|
|
return {
|
|
id: tenant.id,
|
|
name: tenant.name,
|
|
slug: tenant.slug,
|
|
};
|
|
}
|
|
|
|
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(),
|
|
tenantId: "",
|
|
tenantName: "",
|
|
tenantSlug: "",
|
|
isPrimary: false,
|
|
isOwner: false,
|
|
isAdmin: false,
|
|
isManager: false,
|
|
grade: "",
|
|
jobTitle: "",
|
|
position: "",
|
|
};
|
|
}
|
|
|
|
function validateManualPassword(
|
|
password: string,
|
|
policy?: PasswordPolicyResponse,
|
|
) {
|
|
if (password.trim().length === 0) {
|
|
return t(
|
|
"msg.admin.users.detail.password_manual_required",
|
|
"비밀번호를 입력해 주세요.",
|
|
);
|
|
}
|
|
|
|
const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH;
|
|
if (password.length < minLength) {
|
|
return t(
|
|
"msg.userfront.reset.error.min_length",
|
|
"비밀번호는 최소 {{count}}자 이상이어야 합니다.",
|
|
{ count: String(minLength) },
|
|
);
|
|
}
|
|
|
|
const hasLower = /[a-z]/.test(password);
|
|
const hasUpper = /[A-Z]/.test(password);
|
|
const hasNumber = /[0-9]/.test(password);
|
|
const hasSymbol = /[\W_]/.test(password);
|
|
let typeCount = 0;
|
|
if (hasLower) typeCount++;
|
|
if (hasUpper) typeCount++;
|
|
if (hasNumber) typeCount++;
|
|
if (hasSymbol) typeCount++;
|
|
|
|
const minTypes = policy?.minCharacterTypes ?? 0;
|
|
if (minTypes > 0 && typeCount < minTypes) {
|
|
return t(
|
|
"msg.userfront.reset.error.min_types",
|
|
"비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.",
|
|
{ count: String(minTypes) },
|
|
);
|
|
}
|
|
|
|
if ((policy?.lowercase ?? true) && !hasLower) {
|
|
return t(
|
|
"msg.userfront.reset.error.lowercase",
|
|
"최소 1개 이상의 소문자를 포함해야 합니다.",
|
|
);
|
|
}
|
|
if ((policy?.uppercase ?? false) && !hasUpper) {
|
|
return t(
|
|
"msg.userfront.reset.error.uppercase",
|
|
"최소 1개 이상의 대문자를 포함해야 합니다.",
|
|
);
|
|
}
|
|
if ((policy?.number ?? true) && !hasNumber) {
|
|
return t(
|
|
"msg.userfront.reset.error.number",
|
|
"최소 1개 이상의 숫자를 포함해야 합니다.",
|
|
);
|
|
}
|
|
if ((policy?.nonAlphanumeric ?? true) && !hasSymbol) {
|
|
return t(
|
|
"msg.userfront.reset.error.symbol",
|
|
"최소 1개 이상의 특수문자를 포함해야 합니다.",
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function TenantMetadataFields({
|
|
tenant,
|
|
schema,
|
|
register,
|
|
errors,
|
|
}: {
|
|
tenant: { id: string; name: string; slug: string };
|
|
schema: UserSchemaField[];
|
|
register: UseFormRegister<UserFormValues>;
|
|
errors: FieldErrors<UserFormValues>;
|
|
}) {
|
|
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">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 size={16} className="text-primary" />
|
|
<span className="text-sm font-bold uppercase tracking-tight">
|
|
{tenant.name}
|
|
</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono opacity-50 bg-background px-2 py-0.5 rounded border">
|
|
{tenant.slug}
|
|
</span>
|
|
</div>
|
|
<div className="p-6 grid gap-6 md:grid-cols-2">
|
|
{schema.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground md:col-span-2">
|
|
{t(
|
|
"msg.admin.users.detail.tenant_schema_empty",
|
|
"이 테넌트에 설정된 프로필 필드가 없습니다.",
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function UserDetailPage() {
|
|
const params = useParams<{ id: string }>();
|
|
const userId = params.id ?? "";
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [_error, _setError] = React.useState<string | null>(null);
|
|
const [_successMsg, _setSuccessMsg] = React.useState<string | null>(null);
|
|
const [isPasswordResetOpen, setIsPasswordResetOpen] = React.useState(false);
|
|
const [generatedPassword, setGeneratedPassword] = React.useState<
|
|
string | null
|
|
>(null);
|
|
const [passwordResetMode, setPasswordResetMode] =
|
|
React.useState<PasswordResetMode>("generated");
|
|
const [manualPassword, setManualPassword] = React.useState("");
|
|
const [manualPasswordConfirm, setManualPasswordConfirm] = React.useState("");
|
|
const [isManualPasswordVisible, setIsManualPasswordVisible] =
|
|
React.useState(false);
|
|
const [passwordResetError, setPasswordResetError] = React.useState<
|
|
string | null
|
|
>(null);
|
|
const [userCategory, setUserCategory] =
|
|
React.useState<UserCategory>("hanmac-family");
|
|
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
|
AppointmentDraft[]
|
|
>([]);
|
|
const [globalCustomClaimRows, setGlobalCustomClaimRows] = React.useState<
|
|
GlobalCustomClaimRow[]
|
|
>([]);
|
|
const [pickerTarget, setPickerTarget] = React.useState<PickerTarget | null>(
|
|
null,
|
|
);
|
|
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
|
|
|
|
const [activeTab, setActiveTab] = React.useState("info");
|
|
|
|
const { data: profile } = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: fetchMe,
|
|
});
|
|
|
|
const {
|
|
data: user,
|
|
isLoading,
|
|
isError,
|
|
} = useQuery({
|
|
queryKey: ["user", userId],
|
|
queryFn: () => fetchUser(userId),
|
|
enabled: userId.length > 0 && userId !== "new",
|
|
});
|
|
|
|
const { data: tenantsData } = useQuery({
|
|
queryKey: ["tenants", "all"],
|
|
queryFn: () => fetchAllTenants(),
|
|
});
|
|
const tenants = React.useMemo(
|
|
() => tenantsData?.items ?? [],
|
|
[tenantsData?.items],
|
|
);
|
|
|
|
const rpHistoryQuery = useQuery({
|
|
queryKey: ["user-rp-history", userId],
|
|
queryFn: () => fetchUserRpHistory(userId),
|
|
enabled: !!userId && userId !== "new",
|
|
});
|
|
|
|
const { data: passwordPolicy } = useQuery({
|
|
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,
|
|
handleSubmit,
|
|
reset,
|
|
watch,
|
|
setValue,
|
|
formState: { errors },
|
|
} = useForm<UserFormValues>({
|
|
defaultValues: {
|
|
name: "",
|
|
phone: "",
|
|
role: "user",
|
|
status: "active",
|
|
tenantSlug: "",
|
|
department: "",
|
|
grade: "",
|
|
position: "",
|
|
jobTitle: "",
|
|
metadata: {},
|
|
},
|
|
});
|
|
|
|
const profileRole = normalizeAdminRole(profile?.role);
|
|
const isAdmin = profileRole === "super_admin";
|
|
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
|
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
|
|
const isWritable =
|
|
isAdmin ||
|
|
isSelf ||
|
|
canManageCurrentUser ||
|
|
!!profile?.systemPermissions?.manage_users;
|
|
const canViewUser =
|
|
isAdmin ||
|
|
isSelf ||
|
|
canManageCurrentUser ||
|
|
!!profile?.systemPermissions?.users ||
|
|
!!profile?.systemPermissions?.manage_users;
|
|
const watchedStatus = watch("status");
|
|
|
|
const [newSubEmail, setNewSubEmail] = React.useState("");
|
|
const currentSubEmails = (watch("metadata.sub_email") as string[]) || [];
|
|
|
|
const handleAddSubEmail = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter" || e.key === "," || e.key === " ") {
|
|
e.preventDefault();
|
|
const value = newSubEmail.trim().replace(/,/g, "");
|
|
if (value?.includes("@") && !currentSubEmails.includes(value)) {
|
|
setValue("metadata.sub_email", [...currentSubEmails, value], {
|
|
shouldDirty: true,
|
|
});
|
|
setNewSubEmail("");
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRemoveSubEmail = (emailToRemove: string) => {
|
|
setValue(
|
|
"metadata.sub_email",
|
|
currentSubEmails.filter((e) => e !== emailToRemove),
|
|
{ shouldDirty: true },
|
|
);
|
|
};
|
|
|
|
const resetMutation = useMutation({
|
|
mutationFn: ({ password }: { password: string; mode: PasswordResetMode }) =>
|
|
updateUser(userId, { password }),
|
|
onSuccess: (_, { password, mode }) => {
|
|
if (mode === "manual") {
|
|
setGeneratedPassword(null);
|
|
setManualPassword("");
|
|
setManualPasswordConfirm("");
|
|
setIsManualPasswordVisible(false);
|
|
setIsPasswordResetOpen(false);
|
|
} else {
|
|
setGeneratedPassword(password);
|
|
}
|
|
setPasswordResetError(null);
|
|
toast.success(
|
|
t(
|
|
"msg.admin.users.detail.password_generated",
|
|
"사용자 비밀번호가 성공적으로 재설정되었습니다.",
|
|
),
|
|
);
|
|
},
|
|
onError: (err: AxiosError<{ error?: string }>) => {
|
|
const message =
|
|
err.response?.data?.error ||
|
|
t("msg.admin.users.detail.update_error", "수정에 실패했습니다.");
|
|
setPasswordResetError(message);
|
|
toast.error(message);
|
|
},
|
|
});
|
|
|
|
const handleOpenPasswordReset = () => {
|
|
if (isSelf) return;
|
|
setIsPasswordResetOpen(true);
|
|
setGeneratedPassword(null);
|
|
setPasswordResetMode("generated");
|
|
setManualPassword("");
|
|
setManualPasswordConfirm("");
|
|
setIsManualPasswordVisible(false);
|
|
setPasswordResetError(null);
|
|
};
|
|
|
|
const handleClosePasswordReset = () => {
|
|
setIsPasswordResetOpen(false);
|
|
setGeneratedPassword(null);
|
|
setPasswordResetError(null);
|
|
};
|
|
|
|
const handleExecutePasswordReset = () => {
|
|
if (isSelf) return;
|
|
|
|
let newPass = manualPassword;
|
|
|
|
if (passwordResetMode === "manual") {
|
|
const vErr = validateManualPassword(manualPassword, passwordPolicy);
|
|
if (vErr) {
|
|
setPasswordResetError(vErr);
|
|
return;
|
|
}
|
|
if (manualPassword !== manualPasswordConfirm) {
|
|
setPasswordResetError(
|
|
t(
|
|
"msg.userfront.reset.error.mismatch",
|
|
"비밀번호가 일치하지 않습니다.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
} else {
|
|
newPass = generateSecurePassword();
|
|
}
|
|
|
|
resetMutation.mutate({ password: newPass, mode: passwordResetMode });
|
|
};
|
|
|
|
const hanmacFamilyTenantId = React.useMemo(() => {
|
|
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
|
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
|
return envTenantId.trim();
|
|
}
|
|
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
|
}, [tenants]);
|
|
const personalTenant = React.useMemo(
|
|
() => resolvePersonalTenant(tenants),
|
|
[tenants],
|
|
);
|
|
|
|
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
|
import.meta.env.ORGFRONT_URL,
|
|
{
|
|
tenantId:
|
|
userCategory === "hanmac-family" ? hanmacFamilyTenantId : undefined,
|
|
},
|
|
);
|
|
|
|
const applyTenantSelection = React.useCallback(
|
|
async (selection: OrgChartTenantSelection, target: PickerTarget) => {
|
|
setIsResolvingTenant(true);
|
|
try {
|
|
const tenant = await resolveTenantSelection(selection, tenants);
|
|
setAdditionalAppointments((current) =>
|
|
current.map((appointment, index) =>
|
|
index === target.index
|
|
? {
|
|
...appointment,
|
|
tenantId: tenant.id,
|
|
tenantName: tenant.name,
|
|
tenantSlug: tenant.slug,
|
|
}
|
|
: appointment,
|
|
),
|
|
);
|
|
setPickerTarget(null);
|
|
} catch (_) {
|
|
toast.error(
|
|
t(
|
|
"msg.admin.users.detail.tenant_resolve_failed",
|
|
"선택한 테넌트 정보를 불러오지 못했습니다.",
|
|
),
|
|
);
|
|
} finally {
|
|
setIsResolvingTenant(false);
|
|
}
|
|
},
|
|
[tenants],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!pickerTarget) return;
|
|
|
|
const onMessage = (event: MessageEvent) => {
|
|
const selection = parseOrgChartTenantSelection(event.data);
|
|
if (!selection) return;
|
|
void applyTenantSelection(selection, pickerTarget);
|
|
};
|
|
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, [applyTenantSelection, pickerTarget]);
|
|
|
|
const addAppointment = () => {
|
|
setAdditionalAppointments((current) => [
|
|
...current,
|
|
createEmptyAppointment(),
|
|
]);
|
|
};
|
|
|
|
const updateAppointment = (
|
|
index: number,
|
|
patch: Partial<UserAppointment>,
|
|
) => {
|
|
setAdditionalAppointments((current) =>
|
|
current.map((appointment, currentIndex) => {
|
|
if (currentIndex === index) {
|
|
return { ...appointment, ...patch };
|
|
}
|
|
if (patch.isPrimary === true) {
|
|
return { ...appointment, isPrimary: false };
|
|
}
|
|
return appointment;
|
|
}),
|
|
);
|
|
};
|
|
|
|
const removeAppointment = (index: number) => {
|
|
setAdditionalAppointments((current) =>
|
|
current.filter((_, currentIndex) => currentIndex !== index),
|
|
);
|
|
};
|
|
|
|
const _setPrimaryAppointment = (targetIndex: number) => {
|
|
setAdditionalAppointments((current) =>
|
|
current.map((appointment, index) => ({
|
|
...appointment,
|
|
isPrimary: index === targetIndex,
|
|
})),
|
|
);
|
|
};
|
|
|
|
const handleUserCategoryChange = (value: string) => {
|
|
const nextCategory = value as UserCategory;
|
|
setUserCategory(nextCategory);
|
|
if (nextCategory !== "hanmac-family") {
|
|
setAdditionalAppointments([]);
|
|
}
|
|
};
|
|
|
|
const ensurePersonalTenant = async () => {
|
|
return personalTenant;
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (user) {
|
|
const metadata = (user.metadata ?? {}) as Record<string, unknown>;
|
|
const rawAppointments = metadata.additionalAppointments;
|
|
const primaryFromMetadata =
|
|
typeof metadata.primaryTenantId === "string"
|
|
? {
|
|
id: metadata.primaryTenantId,
|
|
name:
|
|
typeof metadata.primaryTenantName === "string"
|
|
? metadata.primaryTenantName
|
|
: user.tenant?.name || user.tenantSlug || "",
|
|
slug: user.tenantSlug,
|
|
}
|
|
: null;
|
|
const fallbackAppointment =
|
|
primaryFromMetadata ??
|
|
(user.tenant
|
|
? {
|
|
id: user.tenant.id,
|
|
name: user.tenant.name,
|
|
slug: user.tenant.slug,
|
|
}
|
|
: null);
|
|
|
|
reset({
|
|
email: user.email || "",
|
|
name: user.name,
|
|
phone: user.phone || "",
|
|
role: user.role,
|
|
status: normalizeUserStatusValue(user.status),
|
|
tenantSlug:
|
|
user.tenantSlug ||
|
|
user.joinedTenants?.find(
|
|
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
|
)?.slug ||
|
|
"",
|
|
department: user.department || "",
|
|
grade: user.grade || "",
|
|
position: user.position || "",
|
|
jobTitle: user.jobTitle || "",
|
|
metadata: {
|
|
...((user.metadata as unknown as Record<
|
|
string,
|
|
Record<string, string | number | boolean>
|
|
>) || {}),
|
|
employee_id: normalizeEmployeeIDMetadataValue(
|
|
user.metadata?.employee_id,
|
|
),
|
|
sub_email: Array.isArray(user.metadata?.sub_email)
|
|
? user.metadata.sub_email
|
|
: typeof user.metadata?.sub_email === "string"
|
|
? user.metadata.sub_email
|
|
.split(/[;,\n\r\t]/)
|
|
.map((e) => e.trim())
|
|
.filter((e) => e.includes("@"))
|
|
: [],
|
|
} as UserFormValues["metadata"],
|
|
});
|
|
const resolvedUserCategory = resolveUserMembershipTenantTab(
|
|
user,
|
|
tenants,
|
|
).id;
|
|
const isUserHanmacFamily = resolvedUserCategory === "hanmac-family";
|
|
setUserCategory(resolvedUserCategory);
|
|
setGlobalCustomClaimRows(
|
|
createGlobalCustomClaimRows(metadata, globalCustomClaimDefinitions),
|
|
);
|
|
const familyFallbackTenants = [
|
|
...(user.joinedTenants ?? []),
|
|
...(user.tenant ? [user.tenant] : []),
|
|
].filter(
|
|
(tenant, index, allTenants) =>
|
|
allTenants.findIndex((item) => item.id === tenant.id) === index &&
|
|
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
|
|
);
|
|
setAdditionalAppointments(
|
|
Array.isArray(rawAppointments)
|
|
? (rawAppointments as UserAppointment[]).map((appointment) => ({
|
|
...appointment,
|
|
isPrimary:
|
|
appointment.isPrimary === true ||
|
|
appointment.tenantId === primaryFromMetadata?.id,
|
|
isOwner: appointment.isOwner === true,
|
|
isAdmin: appointment.isAdmin === true,
|
|
isManager: appointment.isManager === true,
|
|
draftId: createDraftId(),
|
|
}))
|
|
: isUserHanmacFamily
|
|
? familyFallbackTenants.length > 0
|
|
? familyFallbackTenants.map((tenant) => ({
|
|
draftId: createDraftId(),
|
|
tenantId: tenant.id,
|
|
tenantName: tenant.name,
|
|
tenantSlug: tenant.slug,
|
|
isPrimary: tenant.id === fallbackAppointment?.id,
|
|
isOwner:
|
|
metadata.primaryTenantIsOwner === true &&
|
|
tenant.id === fallbackAppointment?.id,
|
|
isAdmin: false,
|
|
isManager: false,
|
|
grade: user.grade,
|
|
jobTitle: user.jobTitle,
|
|
position: user.position,
|
|
}))
|
|
: fallbackAppointment
|
|
? [
|
|
{
|
|
draftId: createDraftId(),
|
|
tenantId: fallbackAppointment.id,
|
|
tenantName: fallbackAppointment.name,
|
|
tenantSlug: fallbackAppointment.slug,
|
|
isPrimary: true,
|
|
isOwner: metadata.primaryTenantIsOwner === true,
|
|
isAdmin: false,
|
|
isManager: false,
|
|
grade: user.grade,
|
|
jobTitle: user.jobTitle,
|
|
position: user.position,
|
|
},
|
|
]
|
|
: []
|
|
: [],
|
|
);
|
|
}
|
|
}, [
|
|
globalCustomClaimDefinitions,
|
|
hanmacFamilyTenantId,
|
|
tenants,
|
|
user,
|
|
reset,
|
|
]);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
queryClient.invalidateQueries({ queryKey: ["user", userId] });
|
|
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
|
},
|
|
onError: (err: AxiosError<{ error?: string }>) => {
|
|
toast.error(
|
|
formatUserPolicyMessage(err.response?.data?.error) ||
|
|
t("err.common.unknown", "오류가 발생했습니다."),
|
|
);
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: () => deleteUser(userId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
toast.success(
|
|
t("msg.admin.users.detail.delete_success", "사용자가 삭제되었습니다."),
|
|
);
|
|
navigate("/users");
|
|
},
|
|
});
|
|
|
|
const onSubmit = async (data: UserFormValues) => {
|
|
const cleanMetadata = Object.fromEntries(
|
|
Object.entries(data.metadata ?? {}).flatMap(([key, value]) => {
|
|
const cleanedValue = cleanMetadataValue(value);
|
|
if (
|
|
cleanedValue === undefined ||
|
|
cleanedValue === null ||
|
|
cleanedValue === ""
|
|
) {
|
|
return [];
|
|
}
|
|
return [[key, cleanedValue]];
|
|
}),
|
|
);
|
|
|
|
const {
|
|
hanmacFamily: _hanmacFamily,
|
|
userType: _userType,
|
|
sub_email: rawSubEmail,
|
|
...safeMetadata
|
|
} = cleanMetadata;
|
|
const subEmail = normalizeSubEmails(rawSubEmail);
|
|
|
|
const metadata: Record<string, unknown> = {
|
|
...safeMetadata,
|
|
...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }),
|
|
};
|
|
const employeeID = String(data.metadata?.employee_id ?? "").trim();
|
|
if (employeeID) {
|
|
metadata.employee_id = employeeID;
|
|
} else {
|
|
delete metadata.employee_id;
|
|
}
|
|
|
|
const payload: UserUpdateRequest = {
|
|
...data,
|
|
metadata,
|
|
};
|
|
if (profileRole !== "super_admin") {
|
|
delete payload.email;
|
|
} else {
|
|
payload.email = data.email.trim();
|
|
}
|
|
payload.role = undefined;
|
|
|
|
if (userCategory === "personal") {
|
|
try {
|
|
const tenant = await ensurePersonalTenant();
|
|
payload.tenantSlug = tenant.slug;
|
|
payload.isPrimaryTenant = true;
|
|
payload.department = undefined;
|
|
payload.grade = undefined;
|
|
payload.position = undefined;
|
|
payload.jobTitle = undefined;
|
|
payload.metadata = {
|
|
...metadata,
|
|
personalTenantId: tenant.id,
|
|
};
|
|
} catch (_) {
|
|
toast.error("Personal 테넌트를 준비하지 못했습니다.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (userCategory === "hanmac-family") {
|
|
const appointments = additionalAppointments
|
|
.filter((appointment) => appointment.tenantId)
|
|
.map((appointment) => ({
|
|
tenantId: appointment.tenantId,
|
|
tenantSlug: appointment.tenantSlug,
|
|
tenantName: appointment.tenantName,
|
|
isPrimary: appointment.isPrimary === true,
|
|
...(appointment.isOwner === true ? { isOwner: true } : {}),
|
|
...(appointment.isAdmin === true ? { isAdmin: true } : {}),
|
|
...(appointment.isManager === true ? { isManager: true } : {}),
|
|
grade: appointment.grade,
|
|
jobTitle: appointment.jobTitle,
|
|
position: appointment.position,
|
|
}));
|
|
|
|
const primary = appointments.find((a) => a.isPrimary);
|
|
if (primary) {
|
|
payload.tenantSlug = primary.tenantSlug;
|
|
payload.isPrimaryTenant = true;
|
|
payload.primaryTenantId = primary.tenantId;
|
|
payload.primaryTenantName = primary.tenantName;
|
|
metadata.primaryTenantId = primary.tenantId;
|
|
metadata.primaryTenantSlug = primary.tenantSlug;
|
|
metadata.primaryTenantName = primary.tenantName;
|
|
} else {
|
|
payload.tenantSlug = undefined;
|
|
}
|
|
|
|
payload.department = undefined;
|
|
payload.grade = undefined;
|
|
payload.position = undefined;
|
|
payload.jobTitle = undefined;
|
|
payload.additionalAppointments = appointments;
|
|
payload.metadata = {
|
|
...metadata,
|
|
additionalAppointments: appointments,
|
|
primaryTenantId: primary?.tenantId,
|
|
primaryTenantName: primary?.tenantName,
|
|
primaryTenantSlug: primary?.tenantSlug,
|
|
};
|
|
payload.tenantSlug = primary?.tenantSlug;
|
|
payload.isPrimaryTenant = primary ? true : undefined;
|
|
payload.primaryTenantId = primary?.tenantId;
|
|
payload.primaryTenantName = primary?.tenantName;
|
|
}
|
|
|
|
mutation.mutate(payload);
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (
|
|
window.confirm(
|
|
t("msg.admin.users.detail.delete_confirm", "삭제하시겠습니까?"),
|
|
)
|
|
) {
|
|
deleteMutation.mutate();
|
|
}
|
|
};
|
|
|
|
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;
|
|
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;
|
|
}, [tenants, user?.joinedTenants, user?.metadata, user?.tenant]);
|
|
const selectableRepresentativeTenants = React.useMemo(
|
|
() =>
|
|
userCategory === "hanmac-family" || userCategory === "personal"
|
|
? []
|
|
: filterTenantsByMembershipRoot(tenants, userCategory),
|
|
[tenants, userCategory],
|
|
);
|
|
const isRepresentativeTenantCategory =
|
|
userCategory !== "hanmac-family" && userCategory !== "personal";
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError || !user) {
|
|
return (
|
|
<div className="rounded-md bg-destructive/15 p-6 text-center mt-6">
|
|
<p className="text-destructive font-medium">
|
|
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
className="mt-4"
|
|
onClick={() => navigate("/users")}
|
|
>
|
|
{t("ui.admin.users.detail.go_list", "목록으로 이동")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (profile && !canViewUser) {
|
|
return (
|
|
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
|
<ShieldAlert size={48} className="text-destructive" />
|
|
<h3 className="text-lg font-bold">
|
|
{t("msg.admin.common.forbidden", "이 작업을 수행할 권한이 없습니다.")}
|
|
</h3>
|
|
<Button onClick={() => navigate("/")}>
|
|
{t("ui.common.go_home", "홈으로 이동")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header with back button and actions */}
|
|
<div className="flex items-center justify-between">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => navigate("/users")}
|
|
size="sm"
|
|
className="hover:bg-muted"
|
|
>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
{t("ui.admin.users.detail.back", "목록으로")}
|
|
</Button>
|
|
{isAdmin && (
|
|
<Button variant="destructive" onClick={handleDelete} size="sm">
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
{t("ui.admin.users.detail.delete", "사용자 삭제")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* User Quick Summary Header */}
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-[var(--color-panel)] border border-border rounded-2xl p-8 shadow-sm">
|
|
<div className="flex items-center gap-6">
|
|
<div className="h-20 w-20 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
|
|
<Users size={40} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-extrabold tracking-tight">
|
|
{user.name}
|
|
</h1>
|
|
<Badge
|
|
variant="outline"
|
|
className="h-6 px-3 bg-blue-500/10 text-blue-600 border-blue-200 font-bold"
|
|
>
|
|
<Building2 size={12} className="mr-1.5" />
|
|
{user.tenant?.name ||
|
|
user.tenantSlug ||
|
|
user.joinedTenants?.find(
|
|
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
|
)?.name ||
|
|
t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}
|
|
</Badge>
|
|
<Badge
|
|
variant={user.status === "active" ? "default" : "secondary"}
|
|
className="h-6 px-3"
|
|
>
|
|
{t(`ui.common.status.${user.status}`, user.status)}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-4 text-muted-foreground text-sm">
|
|
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
|
<Mail size={14} className="text-primary/70" />
|
|
{user.email}
|
|
</div>
|
|
{normalizeSubEmails(user.metadata?.sub_email).length > 0 && (
|
|
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
|
<Mail size={14} className="text-primary/40" />
|
|
<span className="text-[10px] font-bold">
|
|
+{normalizeSubEmails(user.metadata?.sub_email).length}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{user.phone && (
|
|
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
|
<Shield size={14} className="text-primary/70" />
|
|
{user.phone}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-start md:items-end text-[10px] text-muted-foreground gap-1.5 uppercase tracking-widest font-bold opacity-70">
|
|
<p>
|
|
{t("ui.admin.users.detail.created_at", "가입일")}: {user.createdAt}
|
|
</p>
|
|
<p>
|
|
{t("ui.admin.users.detail.updated_at", "최근 수정")}:{" "}
|
|
{user.updatedAt}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={setActiveTab}
|
|
className="w-full space-y-6"
|
|
>
|
|
<TabsList className="bg-muted/50 p-1.5 rounded-xl inline-flex w-full md:w-auto">
|
|
<TabsTrigger
|
|
value="info"
|
|
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
|
>
|
|
<Users size={16} className="mr-2" />
|
|
{t("ui.admin.users.detail.tabs.info", "기본 정보")}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="tenants"
|
|
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
|
>
|
|
<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"
|
|
>
|
|
<Shield size={16} className="mr-2" />
|
|
{t("ui.admin.users.detail.tabs.security", "보안 & 활동")}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<form onSubmit={handleSubmit(onSubmit)}>
|
|
<TabsContent
|
|
value="info"
|
|
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 overflow-hidden">
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<BadgeCheck size={18} className="text-primary" />
|
|
{t("ui.admin.users.detail.edit_title", "프로필 정보")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.users.detail.edit_subtitle",
|
|
"{{email}} 계정의 정보를 수정합니다.",
|
|
{ email: user.email },
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-8 p-8">
|
|
<div className="grid gap-8 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="email"
|
|
className="text-xs font-bold uppercase text-muted-foreground"
|
|
>
|
|
{t("ui.admin.users.detail.form.email", "이메일")}
|
|
</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
disabled={profileRole !== "super_admin"}
|
|
{...register("email", {
|
|
required: t(
|
|
"msg.admin.users.detail.email_required",
|
|
"이메일을 입력하세요.",
|
|
),
|
|
})}
|
|
className={
|
|
profileRole === "super_admin"
|
|
? "h-11 shadow-sm"
|
|
: "bg-muted/50 border-none font-medium h-11"
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="name"
|
|
className="text-xs font-bold uppercase text-muted-foreground"
|
|
>
|
|
{t("ui.admin.users.detail.form.name", "이름")}
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
{...register("name", {
|
|
required: t(
|
|
"msg.admin.users.detail.form.name_required",
|
|
"이름은 필수입니다.",
|
|
),
|
|
})}
|
|
className="h-11 shadow-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="phone"
|
|
className="text-xs font-bold uppercase text-muted-foreground"
|
|
>
|
|
{t("ui.admin.users.detail.form.phone", "연락처")}
|
|
</Label>
|
|
<Input
|
|
id="phone"
|
|
{...register("phone")}
|
|
className="h-11 shadow-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="metadata_employee_id"
|
|
className="text-xs font-bold uppercase text-muted-foreground"
|
|
>
|
|
사번
|
|
</Label>
|
|
<Input
|
|
id="metadata_employee_id"
|
|
maxLength={20}
|
|
{...register("metadata.employee_id", {
|
|
setValueAs: (value) =>
|
|
typeof value === "string" ? value.trim() : value,
|
|
maxLength: {
|
|
value: 20,
|
|
message:
|
|
"Worksmobile 사번은 20자 이하로 입력해야 합니다.",
|
|
},
|
|
})}
|
|
className="h-11 shadow-sm"
|
|
/>
|
|
{errors.metadata?.employee_id && (
|
|
<p className="text-xs text-destructive">
|
|
{String(errors.metadata.employee_id.message)}
|
|
</p>
|
|
)}
|
|
<p className="text-[10px] text-muted-foreground mt-1">
|
|
Worksmobile employeeNumber로 전송됩니다. 1~20자만
|
|
허용됩니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
|
<div className="space-y-2 col-span-full">
|
|
<Label
|
|
htmlFor="sub_email_input"
|
|
className="text-xs font-bold uppercase text-muted-foreground flex items-center gap-2"
|
|
>
|
|
<Mail size={14} />
|
|
{t("ui.admin.users.detail.form.sub_email", "보조 이메일")}
|
|
</Label>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-wrap gap-2 mb-1">
|
|
{currentSubEmails.map((email) => (
|
|
<Badge
|
|
key={email}
|
|
variant="secondary"
|
|
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium"
|
|
>
|
|
{email}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveSubEmail(email)}
|
|
className="text-muted-foreground hover:text-foreground ml-1 rounded-full p-0.5 hover:bg-muted transition-colors"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<div className="relative">
|
|
<Input
|
|
id="sub_email_input"
|
|
value={newSubEmail}
|
|
onChange={(e) => setNewSubEmail(e.target.value)}
|
|
onKeyDown={handleAddSubEmail}
|
|
className="h-11 shadow-sm pr-20"
|
|
placeholder={t(
|
|
"ui.admin.users.detail.form.sub_email_placeholder",
|
|
"추가할 이메일 입력 후 Enter",
|
|
)}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute right-1 top-1 h-9 text-xs font-bold"
|
|
data-testid="add-sub-email-btn"
|
|
onClick={() => {
|
|
const value = newSubEmail.trim().replace(/,/g, "");
|
|
if (
|
|
value?.includes("@") &&
|
|
!currentSubEmails.includes(value)
|
|
) {
|
|
setValue(
|
|
"metadata.sub_email",
|
|
[...currentSubEmails, value],
|
|
{ shouldDirty: true },
|
|
);
|
|
setNewSubEmail("");
|
|
}
|
|
}}
|
|
>
|
|
{t("ui.common.add", "추가")}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground mt-1">
|
|
{t(
|
|
"msg.admin.users.detail.sub_email_help",
|
|
"* 보조 이메일로도 로그인이 가능하며 계정 찾기 등에 활용될 수 있습니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="status"
|
|
className="text-xs font-bold uppercase text-muted-foreground"
|
|
>
|
|
{t("ui.admin.users.detail.form.status", "상태")}
|
|
</Label>
|
|
<Select
|
|
value={normalizeUserStatusValue(watchedStatus || "")}
|
|
onValueChange={(value) =>
|
|
setValue("status", normalizeUserStatusValue(value), {
|
|
shouldDirty: true,
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger id="status" className="h-11 shadow-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{userStatusValues.map((status) => (
|
|
<SelectItem key={status} value={status}>
|
|
{userStatusLabel(status)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs
|
|
value={userCategory}
|
|
onValueChange={handleUserCategoryChange}
|
|
className="space-y-4 pt-6 border-t border-dashed"
|
|
>
|
|
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
|
{USER_MEMBERSHIP_TENANT_TABS.map((tab) => (
|
|
<TabsTrigger
|
|
key={tab.id}
|
|
value={tab.id}
|
|
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
|
>
|
|
{tab.label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{isRepresentativeTenantCategory && (
|
|
<div className="grid gap-8 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="tenantSlug"
|
|
className="text-xs font-bold uppercase text-muted-foreground"
|
|
>
|
|
{t(
|
|
"ui.admin.users.detail.form.tenant_slug",
|
|
"대표 소속 (Tenant Slug)",
|
|
)}
|
|
</Label>
|
|
<div className="relative">
|
|
<select
|
|
id="tenantSlug"
|
|
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
|
|
{...register("tenantSlug")}
|
|
disabled={
|
|
profileRole !== "super_admin" &&
|
|
selectableRepresentativeTenants.length <= 1
|
|
}
|
|
>
|
|
{selectableRepresentativeTenants.map((t) => (
|
|
<option key={t.id} value={t.slug}>
|
|
{t.name} ({t.slug})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<BadgeCheck
|
|
size={14}
|
|
className="absolute right-3 top-[15px] text-primary pointer-events-none"
|
|
/>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground mt-1">
|
|
*{" "}
|
|
{t(
|
|
"msg.admin.users.detail.tenant_slug_help",
|
|
"사용자의 주된 정체성을 결정하는 대표 조직을 지정합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{userCategory === "hanmac-family" && (
|
|
<div className="space-y-4 rounded-md border p-4">
|
|
<div className="space-y-4">
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium">
|
|
{t(
|
|
"ui.admin.users.detail.form.additional_appointments",
|
|
"소속별 직급/직책/직무",
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(
|
|
"msg.admin.users.detail.form.additional_appointments_help",
|
|
"테넌트별 조직장 여부, 직급, 직책, 직무를 입력합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addAppointment}
|
|
data-testid="add-appointment-btn"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t("ui.common.add", "추가")}
|
|
</Button>
|
|
</div>
|
|
|
|
{additionalAppointments.map((appointment, index) => (
|
|
<div
|
|
key={appointment.draftId}
|
|
data-testid={`detail-appointment-row-${index}`}
|
|
className="grid gap-3 rounded-md border p-3 lg:grid-cols-[minmax(280px,1.2fr)_minmax(280px,1fr)_auto] lg:items-end"
|
|
>
|
|
<div
|
|
className="space-y-2"
|
|
data-testid={`detail-appointment-tenant-owner-line-${index}`}
|
|
>
|
|
<Label>
|
|
{t(
|
|
"ui.admin.users.detail.form.appointment_tenant",
|
|
"소속 테넌트",
|
|
)}
|
|
</Label>
|
|
<div
|
|
className="flex items-center justify-between gap-3"
|
|
data-testid={`detail-appointment-tenant-owner-controls-${index}`}
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="min-w-0 max-w-full"
|
|
onClick={() =>
|
|
setPickerTarget({
|
|
kind: "appointment",
|
|
index,
|
|
})
|
|
}
|
|
disabled={isResolvingTenant}
|
|
data-testid={`detail-appointment-tenant-picker-${index}`}
|
|
>
|
|
<Building2 className="mr-2 h-4 w-4 shrink-0" />
|
|
<span className="truncate">
|
|
{appointment.tenantName ||
|
|
t(
|
|
"ui.admin.users.detail.form.pick_tenant",
|
|
"테넌트 선택",
|
|
)}
|
|
</span>
|
|
</Button>
|
|
{appointment.tenantSlug && (
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
{appointment.tenantSlug}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<Switch
|
|
checked={appointment.isPrimary === true}
|
|
onCheckedChange={(checked) =>
|
|
updateAppointment(index, {
|
|
isPrimary: checked === true,
|
|
})
|
|
}
|
|
disabled={appointment.isPrimary === true}
|
|
aria-label={t(
|
|
"ui.admin.users.detail.form.appointment_owner",
|
|
"대표 조직",
|
|
)}
|
|
/>
|
|
{t(
|
|
"ui.admin.users.detail.form.appointment_owner",
|
|
"대표 조직",
|
|
)}
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<Switch
|
|
checked={appointment.isManager === true}
|
|
onCheckedChange={(checked) =>
|
|
updateAppointment(index, {
|
|
isManager: checked === true,
|
|
})
|
|
}
|
|
aria-label={t(
|
|
"ui.admin.users.detail.form.appointment_manager",
|
|
"조직장",
|
|
)}
|
|
/>
|
|
{t(
|
|
"ui.admin.users.detail.form.appointment_manager",
|
|
"조직장",
|
|
)}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="grid gap-3 sm:grid-cols-3"
|
|
data-testid={`detail-appointment-position-line-${index}`}
|
|
>
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor={`detail-appointment-grade-${index}`}
|
|
>
|
|
{t(
|
|
"ui.admin.users.detail.form.grade",
|
|
"직급",
|
|
)}
|
|
</Label>
|
|
<select
|
|
id={`detail-appointment-grade-${index}`}
|
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
value={appointment.grade ?? ""}
|
|
onChange={(event) =>
|
|
updateAppointment(index, {
|
|
grade: event.target.value || "",
|
|
})
|
|
}
|
|
>
|
|
<option value="">없음</option>
|
|
{getTenantGradeOptions(
|
|
appointment,
|
|
tenants,
|
|
).map((grade) => (
|
|
<option key={grade} value={grade}>
|
|
{grade}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor={`detail-appointment-job-title-${index}`}
|
|
>
|
|
{t(
|
|
"ui.admin.users.detail.form.job_title",
|
|
"직무",
|
|
)}
|
|
</Label>
|
|
<Input
|
|
id={`detail-appointment-job-title-${index}`}
|
|
value={appointment.jobTitle ?? ""}
|
|
onChange={(event) =>
|
|
updateAppointment(index, {
|
|
jobTitle: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor={`detail-appointment-position-${index}`}
|
|
>
|
|
{t(
|
|
"ui.admin.users.detail.form.position",
|
|
"직책",
|
|
)}
|
|
</Label>
|
|
<Input
|
|
id={`detail-appointment-position-${index}`}
|
|
value={appointment.position ?? ""}
|
|
onChange={(event) =>
|
|
updateAppointment(index, {
|
|
position: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeAppointment(index)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
<span className="sr-only">
|
|
{t("ui.common.delete", "삭제")}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{userCategory === "personal" && (
|
|
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
|
{personalTenant
|
|
? `Personal (${personalTenant.slug})`
|
|
: "Personal 테넌트로 생성됩니다."}
|
|
</div>
|
|
)}
|
|
|
|
{isRepresentativeTenantCategory && (
|
|
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="department"
|
|
className="text-xs font-bold uppercase text-muted-foreground"
|
|
>
|
|
{t(
|
|
"ui.admin.users.detail.form.department",
|
|
"부서/학과",
|
|
)}
|
|
</Label>
|
|
<Input
|
|
id="department"
|
|
{...register("department")}
|
|
className="h-11 shadow-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="grade"
|
|
className="text-xs font-bold uppercase text-muted-foreground"
|
|
>
|
|
{t("ui.admin.users.detail.form.grade", "직급")}
|
|
</Label>
|
|
<Input
|
|
id="grade"
|
|
{...register("grade")}
|
|
className="h-11 shadow-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="position"
|
|
className="text-xs font-bold uppercase text-muted-foreground"
|
|
>
|
|
{t("ui.admin.users.detail.form.position", "직책")}
|
|
</Label>
|
|
<Input
|
|
id="position"
|
|
{...register("position")}
|
|
className="h-11 shadow-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="jobTitle"
|
|
className="text-xs font-bold uppercase text-muted-foreground"
|
|
>
|
|
{t("ui.admin.users.detail.form.job_title", "직무")}
|
|
</Label>
|
|
<Input
|
|
id="jobTitle"
|
|
{...register("jobTitle")}
|
|
className="h-11 shadow-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{isWritable && (
|
|
<div className="flex justify-end pt-4">
|
|
<Button
|
|
type="submit"
|
|
disabled={mutation.isPending}
|
|
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.save", "저장하기")}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent
|
|
value="tenants"
|
|
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">
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<Building2 size={18} className="text-primary" />
|
|
{t(
|
|
"ui.admin.users.detail.custom_fields.multi_title",
|
|
"테넌트별 상세 프로필",
|
|
)}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.users.detail.tenants_desc",
|
|
"각 테넌트별로 정의된 커스텀 스키마 정보를 관리합니다.",
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-8 p-8">
|
|
{userAffiliatedTenants.length === 0 ? (
|
|
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
|
|
<Building2 size={48} className="mx-auto mb-4 opacity-20" />
|
|
<p className="font-medium">
|
|
{t(
|
|
"msg.admin.users.detail.no_tenants",
|
|
"소속된 테넌트 정보가 없습니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-8">
|
|
{userAffiliatedTenants.map((t) => {
|
|
const tDetail = tenants.find(
|
|
(tenant) => tenant.id === t.id,
|
|
);
|
|
const schema = (tDetail?.config?.userSchema ||
|
|
[]) as UserSchemaField[];
|
|
return (
|
|
<TenantMetadataFields
|
|
key={t.id}
|
|
tenant={t}
|
|
schema={schema}
|
|
register={register}
|
|
errors={errors}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex justify-end pt-4">
|
|
<Button
|
|
type="submit"
|
|
disabled={mutation.isPending}
|
|
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.save_tenants",
|
|
"모든 테넌트 프로필 저장",
|
|
)}
|
|
</span>
|
|
</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 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, 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
|
|
value="security"
|
|
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
|
|
>
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl h-fit">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<Key size={18} className="text-primary" />
|
|
{t("ui.admin.users.detail.password_title", "비밀번호 관리")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.users.detail.security_desc",
|
|
"비밀번호 초기화 및 보안 설정을 관리합니다.",
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="flex items-center justify-between rounded-2xl border bg-muted/20 px-6 py-6 transition-all hover:bg-muted/30">
|
|
<div className="space-y-1">
|
|
<p className="font-bold text-sm">
|
|
{t(
|
|
"ui.admin.users.detail.reset_password_label",
|
|
"비밀번호 초기화",
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(
|
|
"msg.admin.users.detail.reset_password_help",
|
|
"안전한 새 비밀번호로 교체합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleOpenPasswordReset}
|
|
disabled={isSelf}
|
|
className="h-10 rounded-xl px-5 border-primary/20 hover:border-primary/50 hover:bg-primary/5"
|
|
>
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
{t("ui.admin.users.detail.reset_password", "초기화 도구")}
|
|
</Button>
|
|
</div>
|
|
|
|
{isSelf && (
|
|
<div className="rounded-xl bg-blue-500/5 border border-blue-500/10 px-5 py-4 text-sm text-blue-600 flex items-center gap-3">
|
|
<Shield size={18} className="shrink-0" />
|
|
<p className="leading-relaxed">
|
|
{t(
|
|
"msg.admin.users.detail.self_password_reset_blocked",
|
|
"보안을 위해 본인 계정은 사용자 포털에서만 변경 가능합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{isPasswordResetOpen && !generatedPassword && !isSelf && (
|
|
<div className="mt-4 p-6 border rounded-2xl bg-card shadow-sm animate-in zoom-in-95 duration-200">
|
|
<Tabs
|
|
value={passwordResetMode}
|
|
onValueChange={(v) =>
|
|
setPasswordResetMode(v as PasswordResetMode)
|
|
}
|
|
>
|
|
<TabsList className="grid w-full grid-cols-2 mb-6 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
|
<TabsTrigger
|
|
value="auto"
|
|
className="rounded-md data-[state=active]:bg-white data-[state=active]:text-primary data-[state=active]:shadow-sm font-bold py-2 text-gray-500"
|
|
>
|
|
{t("ui.admin.users.detail.reset_auto", "자동 생성")}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="manual"
|
|
className="rounded-md data-[state=active]:bg-white data-[state=active]:text-primary data-[state=active]:shadow-sm font-bold py-2 text-gray-500"
|
|
>
|
|
{t("ui.admin.users.detail.reset_manual", "직접 입력")}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="auto" className="space-y-4">
|
|
<div className="bg-muted/50 p-4 rounded-xl text-xs text-muted-foreground leading-relaxed">
|
|
{t(
|
|
"msg.admin.users.detail.reset_auto_desc",
|
|
"해킹이 어려운 복잡한 임시 비밀번호를 시스템이 즉시 생성합니다.",
|
|
)}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
onClick={() =>
|
|
setManualPassword(generateSecurePassword())
|
|
}
|
|
variant="secondary"
|
|
className="w-full h-11 rounded-xl font-bold"
|
|
>
|
|
{t(
|
|
"ui.admin.users.detail.generate_button",
|
|
"랜덤 비밀번호 생성",
|
|
)}
|
|
</Button>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="manual" className="space-y-5 pt-2">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-bold uppercase">
|
|
{t(
|
|
"ui.admin.users.detail.manual_password",
|
|
"새 비밀번호",
|
|
)}
|
|
</Label>
|
|
<div className="relative">
|
|
<Input
|
|
type={
|
|
isManualPasswordVisible ? "text" : "password"
|
|
}
|
|
value={manualPassword}
|
|
onChange={(e) =>
|
|
setManualPassword(e.target.value)
|
|
}
|
|
className="h-11 rounded-xl shadow-sm pr-12"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent text-muted-foreground"
|
|
onClick={() =>
|
|
setIsManualPasswordVisible(
|
|
!isManualPasswordVisible,
|
|
)
|
|
}
|
|
>
|
|
{isManualPasswordVisible ? (
|
|
<EyeOff size={18} />
|
|
) : (
|
|
<Eye size={18} />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-bold uppercase">
|
|
{t(
|
|
"ui.admin.users.detail.manual_confirm",
|
|
"비밀번호 확인",
|
|
)}
|
|
</Label>
|
|
<Input
|
|
type="password"
|
|
value={manualPasswordConfirm}
|
|
onChange={(e) =>
|
|
setManualPasswordConfirm(e.target.value)
|
|
}
|
|
className="h-11 rounded-xl shadow-sm"
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{passwordResetError && (
|
|
<div className="flex items-center gap-2 text-xs text-destructive mt-4 p-3 bg-destructive/5 rounded-lg border border-destructive/10">
|
|
<Shield size={14} />
|
|
<span className="font-medium">
|
|
{passwordResetError}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-3 mt-8">
|
|
<Button
|
|
variant="ghost"
|
|
type="button"
|
|
onClick={handleClosePasswordReset}
|
|
className="h-11 rounded-xl px-6 font-bold"
|
|
>
|
|
{t("ui.common.cancel", "취소")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={handleExecutePasswordReset}
|
|
disabled={resetMutation.isPending}
|
|
className="h-11 rounded-xl px-8 font-bold shadow-md"
|
|
>
|
|
{resetMutation.isPending && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
{t(
|
|
"ui.admin.users.detail.reset_execute",
|
|
"재설정 완료",
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
)}
|
|
|
|
{generatedPassword && (
|
|
<div className="mt-4 p-8 bg-green-500/10 border border-green-500/20 rounded-2xl space-y-6 animate-in zoom-in-95">
|
|
<div className="flex items-center gap-3 text-green-700 font-extrabold text-lg">
|
|
<BadgeCheck size={28} className="text-green-600" />
|
|
{t(
|
|
"ui.admin.users.detail.password_done",
|
|
"성공적으로 초기화됨",
|
|
)}
|
|
</div>
|
|
<div className="p-5 bg-white border border-green-200 rounded-2xl flex items-center justify-between shadow-sm">
|
|
<code className="text-2xl font-mono tracking-widest text-primary selection:bg-primary selection:text-white">
|
|
{generatedPassword}
|
|
</code>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(generatedPassword);
|
|
toast.success(
|
|
t("msg.common.copied", "복사되었습니다."),
|
|
);
|
|
}}
|
|
className="h-10 px-4 rounded-xl hover:bg-green-50 font-bold"
|
|
>
|
|
<Copy size={16} className="mr-2" />
|
|
{t("ui.common.copy", "복사")}
|
|
</Button>
|
|
</div>
|
|
<Button
|
|
className="w-full h-12 rounded-xl font-bold bg-green-600 hover:bg-green-700 shadow-md"
|
|
type="button"
|
|
onClick={handleClosePasswordReset}
|
|
>
|
|
{t("ui.common.close", "안전하게 도구 닫기")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<History size={18} className="text-primary" />
|
|
{t("ui.admin.users.detail.history_title", "서비스 이용 내역")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.users.detail.history_desc",
|
|
"최근 로그인한 연동 서비스(RP) 목록입니다.",
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-8 pt-2">
|
|
{rpHistoryQuery.isLoading ? (
|
|
<div className="py-12 text-center text-muted-foreground animate-pulse">
|
|
{t("msg.common.loading", "불러오는 중...")}
|
|
</div>
|
|
) : !rpHistoryQuery.data || rpHistoryQuery.data.length === 0 ? (
|
|
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
|
|
<History size={40} className="mx-auto mb-4 opacity-10" />
|
|
<p className="text-sm font-medium">
|
|
{t(
|
|
"msg.admin.users.detail.no_history",
|
|
"아직 이용한 서비스가 없습니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{rpHistoryQuery.data.map((item) => (
|
|
<div
|
|
key={item.client_id || item.client_id}
|
|
className="flex items-center justify-between p-5 rounded-2xl border bg-card hover:border-primary/40 hover:shadow-md transition-all group"
|
|
>
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="font-extrabold text-base group-hover:text-primary transition-colors">
|
|
{item.client_name || item.client_id}
|
|
</span>
|
|
<span className="text-[11px] text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded w-fit">
|
|
{item.client_id}
|
|
</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] font-bold px-2 py-1 rounded-md border-primary/20"
|
|
>
|
|
{item.lastLoginAt}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
<Dialog
|
|
open={pickerTarget !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setPickerTarget(null);
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-[460px] p-4">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("ui.admin.users.detail.form.pick_tenant", "테넌트 선택")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
"msg.admin.users.detail.form.picker_description",
|
|
"org-chart에서 테넌트를 선택하면 사용자 소속에 반영됩니다.",
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<iframe
|
|
title={t("ui.admin.users.detail.form.pick_tenant", "테넌트 선택")}
|
|
src={pickerUrl}
|
|
className="h-[600px] w-full rounded-md border"
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default UserDetailPage;
|