forked from baron/baron-sso
feat: 커스텀 필드 기반 로그인 ID 연동 기능 추가 (#440)
- Kratos Identity 스키마에 로그인 전용 `id` 속성 추가 - 테넌트 Config의 `loginIdField` 설정에 따라 User의 `login_id` 및 Kratos `traits.id` 동기화 로직 구현 - Admin UI 테넌트 스키마 설정 내 '로그인 ID로 사용' 체크박스 추가 - Admin UI 사용자 생성/수정/조회 화면에 로그인 ID 관리 필드 및 컬럼 반영 - Userfront 로그인 화면 접속 시 테넌트 설정에 따라 동적 로그인 ID 라벨 적용 - 관련 다국어(ko/en) 번역 추가 및 로그인 ID 설계 문서 업데이트
This commit is contained in:
@@ -34,6 +34,7 @@ type SchemaField = {
|
|||||||
adminOnly: boolean;
|
adminOnly: boolean;
|
||||||
validation?: string;
|
validation?: string;
|
||||||
unsigned?: boolean;
|
unsigned?: boolean;
|
||||||
|
isLoginId?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createFieldId() {
|
function createFieldId() {
|
||||||
@@ -96,6 +97,8 @@ export function TenantSchemaPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rawSchema = tenantQuery.data?.config?.userSchema;
|
const rawSchema = tenantQuery.data?.config?.userSchema;
|
||||||
|
const loginIdField = tenantQuery.data?.config?.loginIdField;
|
||||||
|
|
||||||
if (Array.isArray(rawSchema)) {
|
if (Array.isArray(rawSchema)) {
|
||||||
setFields(
|
setFields(
|
||||||
rawSchema.map((field) => ({
|
rawSchema.map((field) => ({
|
||||||
@@ -115,19 +118,23 @@ export function TenantSchemaPage() {
|
|||||||
validation:
|
validation:
|
||||||
typeof field?.validation === "string" ? field.validation : "",
|
typeof field?.validation === "string" ? field.validation : "",
|
||||||
unsigned: Boolean(field?.unsigned),
|
unsigned: Boolean(field?.unsigned),
|
||||||
|
isLoginId: field?.key === loginIdField,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [tenantQuery.data]);
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (newFields: SchemaField[]) =>
|
mutationFn: (newFields: SchemaField[]) => {
|
||||||
updateTenant(tenantId, {
|
const loginIdField = newFields.find((f) => f.isLoginId)?.key || "";
|
||||||
|
return updateTenant(tenantId, {
|
||||||
config: {
|
config: {
|
||||||
...tenantQuery.data?.config,
|
...tenantQuery.data?.config,
|
||||||
userSchema: newFields,
|
userSchema: newFields,
|
||||||
|
loginIdField: loginIdField,
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -334,6 +341,26 @@ export function TenantSchemaPage() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.isLoginId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newFields = fields.map((f, i) => ({
|
||||||
|
...f,
|
||||||
|
isLoginId: i === index ? e.target.checked : false,
|
||||||
|
}));
|
||||||
|
setFields(newFields);
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-blue-600">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.schema.field.is_login_id",
|
||||||
|
"로그인 ID로 사용",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
{(field.type === "number" || field.type === "float") && (
|
{(field.type === "number" || field.type === "float") && (
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function UserCreatePage() {
|
|||||||
} = useForm<UserFormValues>({
|
} = useForm<UserFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "",
|
||||||
|
loginId: "",
|
||||||
password: "",
|
password: "",
|
||||||
name: "",
|
name: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
@@ -273,6 +274,26 @@ function UserCreatePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="loginId">
|
||||||
|
{t("ui.admin.users.create.form.login_id", "로그인 ID (선택)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="loginId"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.create.form.login_id_placeholder",
|
||||||
|
"사번 또는 아이디",
|
||||||
|
)}
|
||||||
|
{...register("loginId")}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.users.create.form.login_id_help",
|
||||||
|
"이메일/전화번호 외에 별도의 식별자로 로그인할 때 사용합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">
|
<Label htmlFor="password">
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ import {
|
|||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Building2,
|
Building2,
|
||||||
Copy,
|
Copy,
|
||||||
Dices,
|
Key,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
|
Trash2,
|
||||||
|
UserCheck,
|
||||||
|
UserMinus,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import { useForm } from "react-hook-form";
|
||||||
type FieldErrors,
|
|
||||||
type UseFormRegister,
|
|
||||||
useForm,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -31,8 +31,9 @@ import { Input } from "../../components/ui/input";
|
|||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
import { toast } from "../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
type TenantSummary,
|
type UserSummary,
|
||||||
type UserUpdateRequest,
|
type UserUpdateRequest,
|
||||||
|
deleteUser,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
@@ -40,19 +41,7 @@ import {
|
|||||||
updateUser,
|
updateUser,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import { generateSecurePassword } from "../../lib/utils";
|
||||||
// Utility for secure password generation
|
|
||||||
function generateSecurePassword(length = 16) {
|
|
||||||
const charset =
|
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~`|}{[]:;?><,./-=";
|
|
||||||
let retVal = "";
|
|
||||||
const values = new Uint32Array(length);
|
|
||||||
crypto.getRandomValues(values);
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
retVal += charset.charAt(values[i] % charset.length);
|
|
||||||
}
|
|
||||||
return retVal;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserSchemaField = {
|
type UserSchemaField = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -60,40 +49,19 @@ type UserSchemaField = {
|
|||||||
type?: "text" | "number" | "boolean" | "date";
|
type?: "text" | "number" | "boolean" | "date";
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean;
|
||||||
validation?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserFormValues = UserUpdateRequest & {
|
function TenantMetadataFields({
|
||||||
metadata: Record<string, Record<string, unknown>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// [New] Component for per-tenant profile/schema management
|
|
||||||
function TenantProfileCard({
|
|
||||||
tenant,
|
tenant,
|
||||||
|
schema,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
isAdmin,
|
|
||||||
}: {
|
}: {
|
||||||
tenant: TenantSummary;
|
tenant: { id: string; name: string; slug: string };
|
||||||
register: UseFormRegister<UserFormValues>;
|
schema: UserSchemaField[];
|
||||||
errors: FieldErrors<UserFormValues>;
|
register: any;
|
||||||
isAdmin: boolean;
|
errors: any;
|
||||||
}) {
|
}) {
|
||||||
const { data: detail, isLoading } = useQuery({
|
|
||||||
queryKey: ["tenant", tenant.id],
|
|
||||||
queryFn: () => fetchTenant(tenant.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const schema: UserSchemaField[] = Array.isArray(detail?.config?.userSchema)
|
|
||||||
? (detail?.config?.userSchema as UserSchemaField[])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<div className="p-4 border rounded-lg animate-pulse bg-muted/20">
|
|
||||||
Loading schema...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (schema.length === 0) return null;
|
if (schema.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -171,6 +139,8 @@ function TenantProfileCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserFormValues = UserUpdateRequest & { metadata: Record<string, any> };
|
||||||
|
|
||||||
function UserDetailPage() {
|
function UserDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const userId = params.id ?? "";
|
const userId = params.id ?? "";
|
||||||
@@ -210,6 +180,7 @@ function UserDetailPage() {
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<UserFormValues>({
|
} = useForm<UserFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
loginId: "",
|
||||||
name: "",
|
name: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -251,6 +222,7 @@ function UserDetailPage() {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
reset({
|
reset({
|
||||||
|
loginId: user.loginId || "",
|
||||||
name: user.name,
|
name: user.name,
|
||||||
phone: user.phone || "",
|
phone: user.phone || "",
|
||||||
role: user.role,
|
role: user.role,
|
||||||
@@ -259,11 +231,7 @@ function UserDetailPage() {
|
|||||||
department: user.department || "",
|
department: user.department || "",
|
||||||
position: user.position || "",
|
position: user.position || "",
|
||||||
jobTitle: user.jobTitle || "",
|
jobTitle: user.jobTitle || "",
|
||||||
password: "",
|
metadata: user.metadata || {},
|
||||||
metadata: (user.metadata || {}) as unknown as Record<
|
|
||||||
string,
|
|
||||||
Record<string, unknown>
|
|
||||||
>,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [user, reset]);
|
}, [user, reset]);
|
||||||
@@ -279,404 +247,500 @@ function UserDetailPage() {
|
|||||||
"사용자 정보가 수정되었습니다.",
|
"사용자 정보가 수정되었습니다.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setError(null);
|
toast.success(
|
||||||
|
t(
|
||||||
|
"msg.admin.users.detail.update_success",
|
||||||
|
"사용자 정보가 수정되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setTimeout(() => setSuccessMsg(null), 3000);
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
setError(
|
setError(
|
||||||
err.response?.data?.error ||
|
err.response?.data?.error ||
|
||||||
t(
|
t("msg.admin.users.detail.update_error", "수정에 실패했습니다."),
|
||||||
"msg.admin.users.detail.update_error",
|
);
|
||||||
"사용자 수정에 실패했습니다.",
|
},
|
||||||
),
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deleteUser(userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
toast.success(
|
||||||
|
t("msg.admin.users.detail.delete_success", "사용자가 삭제되었습니다."),
|
||||||
|
);
|
||||||
|
navigate("/users");
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
t("msg.admin.users.detail.delete_error", "삭제에 실패했습니다."),
|
||||||
);
|
);
|
||||||
setSuccessMsg(null);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: UserFormValues) => {
|
const onSubmit = (data: UserFormValues) => {
|
||||||
const payload: UserUpdateRequest = { ...data };
|
setError(null);
|
||||||
if (!payload.password) {
|
setSuccessMsg(null);
|
||||||
payload.password = undefined;
|
mutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.admin.users.detail.delete_confirm",
|
||||||
|
"정말로 이 사용자를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
deleteMutation.mutate();
|
||||||
}
|
}
|
||||||
mutation.mutate(payload);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
{t("msg.common.loading", "Loading...")}
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !user) {
|
if (isError || !user) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-destructive">
|
<div className="rounded-md bg-destructive/15 p-6 text-center">
|
||||||
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
|
<p className="text-destructive">
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combined affiliated tenants
|
// Get only tenants that the user is actually affiliated with
|
||||||
const userAffiliatedTenants = [...(user.joinedTenants || [])];
|
const userAffiliatedTenants =
|
||||||
if (
|
user.joinedTenants || (user.tenant ? [user.tenant] : []);
|
||||||
user.tenant &&
|
|
||||||
!userAffiliatedTenants.find((t) => t.id === user.tenant?.id)
|
|
||||||
) {
|
|
||||||
userAffiliatedTenants.push(user.tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl space-y-8">
|
<div className="space-y-6">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-2">
|
<Button variant="ghost" onClick={() => navigate("/users")} size="sm">
|
||||||
<h2 className="text-3xl font-semibold">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
{t("ui.admin.users.detail.title", "사용자 상세")}
|
{t("ui.admin.users.detail.back", "목록으로")}
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" asChild>
|
|
||||||
<Link to="/users">
|
|
||||||
<ArrowLeft size={16} className="mr-2" />
|
|
||||||
{t("ui.admin.users.detail.back", "목록으로 돌아가기")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
<div className="flex gap-2">
|
||||||
|
{isAdmin && (
|
||||||
|
<Button variant="destructive" onClick={handleDelete} size="sm">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t("ui.admin.users.detail.delete", "사용자 삭제")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<CardHeader>
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<CardTitle>
|
<Card>
|
||||||
{t("ui.admin.users.detail.edit_title", "정보 수정")}
|
<CardHeader>
|
||||||
</CardTitle>
|
<CardTitle>
|
||||||
<CardDescription>
|
{t("ui.admin.users.detail.edit_title", "사용자 정보 수정")}
|
||||||
{t(
|
</CardTitle>
|
||||||
"msg.admin.users.detail.edit_subtitle",
|
<CardDescription>
|
||||||
"{{email}} 계정의 정보를 수정합니다.",
|
|
||||||
{ email: user.email },
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{successMsg && (
|
|
||||||
<div className="rounded-md bg-green-500/15 p-3 text-sm text-green-500">
|
|
||||||
{successMsg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tenant Affiliation Section (Enhanced) */}
|
|
||||||
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
|
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
||||||
<Building2 size={16} className="text-primary" />
|
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.tenants_section.title",
|
"msg.admin.users.detail.edit_subtitle",
|
||||||
"소속 및 조직 정보",
|
"{{email}} 계정의 정보를 수정합니다.",
|
||||||
|
{ email: user.email },
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="rounded-md bg-green-500/15 p-3 text-sm text-green-500">
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
{/* Tenant Affiliation Section (Enhanced) */}
|
||||||
<div className="space-y-2">
|
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
|
||||||
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<Building2 size={16} className="text-primary" />
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.tenants_section.primary",
|
"ui.admin.users.detail.tenants_section.title",
|
||||||
"대표 소속 테넌트",
|
"소속 및 조직 정보",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</h3>
|
||||||
|
|
||||||
{/* Select box to specify representative tenant from joined ones */}
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{userAffiliatedTenants.length > 0 ? (
|
<div className="space-y-2">
|
||||||
<div className="relative">
|
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
||||||
<select
|
{t(
|
||||||
className="flex h-10 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"
|
"ui.admin.users.detail.tenants_section.primary",
|
||||||
{...register("tenantSlug")}
|
"대표 소속 테넌트",
|
||||||
disabled={
|
)}
|
||||||
profile?.role === "tenant_admin" &&
|
</Label>
|
||||||
userAffiliatedTenants.length <= 1
|
|
||||||
}
|
{/* Select box to specify representative tenant from joined ones */}
|
||||||
>
|
{userAffiliatedTenants.length > 0 ? (
|
||||||
<option value="">
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className="flex h-10 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={
|
||||||
|
profile?.role === "tenant_admin" &&
|
||||||
|
userAffiliatedTenants.length <= 1
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.form.tenant_global",
|
||||||
|
"시스템 전역",
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
{userAffiliatedTenants.map((t) => (
|
||||||
|
<option key={t.id} value={t.slug}>
|
||||||
|
{t.name} ({t.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<BadgeCheck
|
||||||
|
size={14}
|
||||||
|
className="absolute right-8 top-3 text-primary pointer-events-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 p-2 rounded-md bg-background border border-dashed border-border text-muted-foreground italic text-xs">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.form.tenant_global",
|
"ui.admin.users.detail.form.tenant_global",
|
||||||
"시스템 전역",
|
"시스템 전역 (소속 없음)",
|
||||||
)}
|
)}
|
||||||
</option>
|
</div>
|
||||||
{userAffiliatedTenants.map((t) => (
|
|
||||||
<option key={t.id} value={t.slug}>
|
|
||||||
{t.name} ({t.slug})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<BadgeCheck
|
|
||||||
size={14}
|
|
||||||
className="absolute right-8 top-3 text-primary pointer-events-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 p-2 rounded-md bg-background border border-dashed border-border text-muted-foreground italic text-xs">
|
|
||||||
{t(
|
|
||||||
"ui.admin.users.detail.form.tenant_global",
|
|
||||||
"시스템 전역 (소속 없음)",
|
|
||||||
)}
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
* 사용자의 주된 정체성을 결정하는 대표 조직을 선택합니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
* 사용자의 주된 정체성을 결정하는 대표 조직을 선택합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{userAffiliatedTenants.length > 1 && (
|
{userAffiliatedTenants.length > 1 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.tenants_section.additional",
|
"ui.admin.users.detail.tenants_section.additional",
|
||||||
"전체 소속 목록",
|
"전체 소속 목록",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
{userAffiliatedTenants.map((jt) => (
|
{userAffiliatedTenants.map((jt) => (
|
||||||
<Link
|
<Link
|
||||||
key={jt.id}
|
key={jt.id}
|
||||||
to={`/tenants/${jt.id}`}
|
to={`/tenants/${jt.id}`}
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
|
||||||
jt.id ===
|
jt.id ===
|
||||||
tenants.find((t) => t.slug === watch("tenantSlug"))
|
tenants.find(
|
||||||
?.id
|
(t) => t.slug === watch("tenantSlug"),
|
||||||
? "bg-primary/10 border-primary/30 text-primary font-bold"
|
)?.id
|
||||||
: "bg-background border-border text-muted-foreground hover:border-primary/50"
|
? "bg-primary/10 border-primary/30 text-primary font-bold"
|
||||||
}`}
|
: "bg-background border-border text-muted-foreground hover:border-primary/50"
|
||||||
>
|
}`}
|
||||||
<Users size={10} />
|
>
|
||||||
{jt.name}
|
<Users size={10} />
|
||||||
</Link>
|
{jt.name}
|
||||||
))}
|
</Link>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">
|
|
||||||
{t("ui.admin.users.detail.form.name", "이름")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
placeholder={t(
|
|
||||||
"ui.admin.users.detail.form.name_placeholder",
|
|
||||||
"홍길동",
|
|
||||||
)}
|
|
||||||
{...register("name", {
|
|
||||||
required: t(
|
|
||||||
"msg.admin.users.detail.form.name_required",
|
|
||||||
"이름은 필수입니다.",
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
{errors.name && (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
{errors.name.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="phone">
|
|
||||||
{t("ui.admin.users.detail.form.phone", "전화번호")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="phone"
|
|
||||||
placeholder={t(
|
|
||||||
"ui.admin.users.detail.form.phone_placeholder",
|
|
||||||
"010-1234-5678",
|
|
||||||
)}
|
|
||||||
{...register("phone")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="status">
|
|
||||||
{t("ui.admin.users.detail.form.status", "상태")}
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
id="status"
|
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
{...register("status")}
|
|
||||||
>
|
|
||||||
<option value="active">
|
|
||||||
{t("ui.common.status.active", "Active")}
|
|
||||||
</option>
|
|
||||||
<option value="inactive">
|
|
||||||
{t("ui.common.status.inactive", "Inactive")}
|
|
||||||
</option>
|
|
||||||
<option value="blocked">
|
|
||||||
{t("ui.common.status.blocked", "Blocked")}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="role">
|
|
||||||
{t("ui.admin.users.detail.form.role", "역할 (Role)")}
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
id="role"
|
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
{...register("role")}
|
|
||||||
>
|
|
||||||
<option value="user">
|
|
||||||
{t("ui.admin.role.user", "TENANT MEMBER")}
|
|
||||||
</option>
|
|
||||||
<option value="tenant_admin">
|
|
||||||
{t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
|
|
||||||
</option>
|
|
||||||
<option value="rp_admin">
|
|
||||||
{t("ui.admin.role.rp_admin", "RP ADMIN")}
|
|
||||||
</option>
|
|
||||||
<option value="super_admin">
|
|
||||||
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="department">
|
|
||||||
{t("ui.admin.users.detail.form.department", "부서")}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="department"
|
|
||||||
placeholder={t(
|
|
||||||
"ui.admin.users.detail.form.department_placeholder",
|
|
||||||
"개발팀",
|
|
||||||
)}
|
|
||||||
{...register("department")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tenant-specific Profiles (Namespaced Metadata) */}
|
|
||||||
<div className="border-t pt-6 space-y-6">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
|
||||||
{t(
|
|
||||||
"ui.admin.users.detail.custom_fields.multi_title",
|
|
||||||
"테넌트별 프로필 관리",
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
사용자가 소속된 각 테넌트별 맞춤 정보를 관리합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{userAffiliatedTenants.map((tenant) => (
|
|
||||||
<TenantProfileCard
|
|
||||||
key={tenant.id}
|
|
||||||
tenant={tenant}
|
|
||||||
register={register}
|
|
||||||
errors={errors}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
|
||||||
{t("ui.admin.users.detail.security.title", "보안 설정")}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">
|
|
||||||
{t(
|
|
||||||
"ui.admin.users.detail.security.password",
|
|
||||||
"비밀번호 변경",
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder={t(
|
|
||||||
"ui.admin.users.detail.security.password_placeholder",
|
|
||||||
"변경할 경우에만 입력",
|
|
||||||
)}
|
|
||||||
className="font-mono"
|
|
||||||
{...register("password")}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleGeneratePassword}
|
|
||||||
title={t(
|
|
||||||
"ui.admin.users.detail.generate_password",
|
|
||||||
"자동 생성",
|
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
<Dices size={16} className="mr-2" />
|
</div>
|
||||||
{t("ui.common.generate", "생성")}
|
|
||||||
</Button>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Button
|
<div className="space-y-2">
|
||||||
type="button"
|
<Label htmlFor="name">
|
||||||
variant="outline"
|
{t("ui.admin.users.detail.form.name", "이름")}
|
||||||
size="icon"
|
</Label>
|
||||||
onClick={handleCopyPassword}
|
<Input
|
||||||
disabled={!watch("password")}
|
id="name"
|
||||||
title={t("ui.common.copy", "복사")}
|
placeholder={t(
|
||||||
>
|
"ui.admin.users.detail.form.name_placeholder",
|
||||||
<Copy size={16} />
|
"홍길동",
|
||||||
|
)}
|
||||||
|
{...register("name", {
|
||||||
|
required: t(
|
||||||
|
"msg.admin.users.detail.form.name_required",
|
||||||
|
"이름은 필수입니다.",
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.name.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">
|
||||||
|
{t("ui.admin.users.detail.form.phone", "전화번호")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.detail.form.phone_placeholder",
|
||||||
|
"010-1234-5678",
|
||||||
|
)}
|
||||||
|
{...register("phone")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="loginId">
|
||||||
|
{t("ui.admin.users.detail.form.login_id", "로그인 ID")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="loginId"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.detail.form.login_id_placeholder",
|
||||||
|
"사번 또는 아이디",
|
||||||
|
)}
|
||||||
|
{...register("loginId")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">
|
||||||
|
{t("ui.admin.users.detail.form.role", "권한")}
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
className="flex h-10 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("role")}
|
||||||
|
>
|
||||||
|
<option value="user">
|
||||||
|
{t("ui.admin.users.detail.form.role_user", "일반 사용자")}
|
||||||
|
</option>
|
||||||
|
<option value="tenant_admin">
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.form.role_tenant_admin",
|
||||||
|
"테넌트 관리자",
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
<option value="super_admin">
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.form.role_super_admin",
|
||||||
|
"시스템 관리자",
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">
|
||||||
|
{t("ui.admin.users.detail.form.status", "상태")}
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
className="flex h-10 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("status")}
|
||||||
|
>
|
||||||
|
<option value="active">
|
||||||
|
{t("ui.admin.users.detail.form.status_active", "활성")}
|
||||||
|
</option>
|
||||||
|
<option value="inactive">
|
||||||
|
{t("ui.admin.users.detail.form.status_inactive", "비활성")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="department">
|
||||||
|
{t("ui.admin.users.detail.form.department", "부서")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="department"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.detail.form.department_placeholder",
|
||||||
|
"개발팀",
|
||||||
|
)}
|
||||||
|
{...register("department")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="position">
|
||||||
|
{t("ui.admin.users.detail.form.position", "직급")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="position"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.detail.form.position_placeholder",
|
||||||
|
"수석",
|
||||||
|
)}
|
||||||
|
{...register("position")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="jobTitle">
|
||||||
|
{t("ui.admin.users.detail.form.job_title", "직무")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="jobTitle"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.detail.form.job_title_placeholder",
|
||||||
|
"백엔드 개발",
|
||||||
|
)}
|
||||||
|
{...register("jobTitle")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>{t("ui.admin.users.detail.metadata", "추가 정보")}</Label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{t("ui.admin.users.detail.save", "변경사항 저장")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
</form>
|
||||||
{t(
|
</CardContent>
|
||||||
"msg.admin.users.detail.security.password_hint",
|
</Card>
|
||||||
"비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
<Card>
|
||||||
<Button
|
<CardHeader>
|
||||||
type="button"
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
variant="outline"
|
<Key size={18} />
|
||||||
onClick={() => navigate("/users")}
|
{t("ui.admin.users.detail.password_title", "비밀번호 관리")}
|
||||||
>
|
</CardTitle>
|
||||||
{t("ui.common.cancel", "취소")}
|
</CardHeader>
|
||||||
</Button>
|
<CardContent className="space-y-4">
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/20">
|
||||||
{mutation.isPending && (
|
<div className="space-y-1">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<p className="text-sm font-medium">
|
||||||
)}
|
{t(
|
||||||
<Save className="mr-2 h-4 w-4" />
|
"ui.admin.users.detail.reset_password_label",
|
||||||
{t("ui.common.save", "저장")}
|
"비밀번호 초기화",
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</p>
|
||||||
</form>
|
<p className="text-xs text-muted-foreground">
|
||||||
</CardContent>
|
사용자의 비밀번호를 강제로 재설정하고 새 비밀번호를 생성합니다.
|
||||||
</Card>
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={handleGeneratePassword}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
{t("ui.admin.users.detail.reset_password", "초기화 및 생성")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPassword && (
|
||||||
|
<div className="p-4 border border-dashed rounded-lg bg-yellow-500/5 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-bold text-yellow-600 uppercase tracking-wider">
|
||||||
|
Generated Password
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-lg font-bold">
|
||||||
|
{watch("password")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" onClick={handleCopyPassword}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
{t("ui.common.copy", "복사")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{t("ui.admin.users.detail.status_title", "계정 상태")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<UserCheck size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={user.status === "active" ? "default" : "secondary"}>
|
||||||
|
{user.status === "active" ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">{user.role}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("ui.admin.users.detail.created_at", "가입일")}:{" "}
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{t("ui.admin.users.detail.contact_title", "연락처 정보")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Mail size={14} className="text-muted-foreground" />
|
||||||
|
<span>{user.email}</span>
|
||||||
|
</div>
|
||||||
|
{user.phone && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="outline" className="font-normal">
|
||||||
|
SMS
|
||||||
|
</Badge>
|
||||||
|
<span>{user.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,6 +430,9 @@ function UserListPage() {
|
|||||||
"NAME / EMAIL",
|
"NAME / EMAIL",
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.users.list.table.login_id", "LOGIN ID")}
|
||||||
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -511,6 +514,11 @@ function UserListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{user.loginId || "-"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ export async function deleteApiKey(apiKeyId: string) {
|
|||||||
export type UserSummary = {
|
export type UserSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
loginId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -372,6 +373,7 @@ export type UserListResponse = {
|
|||||||
|
|
||||||
export type UserCreateRequest = {
|
export type UserCreateRequest = {
|
||||||
email: string;
|
email: string;
|
||||||
|
loginId?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@@ -388,6 +390,7 @@ export type UserCreateResponse = UserSummary & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type UserUpdateRequest = {
|
export type UserUpdateRequest = {
|
||||||
|
loginId?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@@ -402,6 +405,7 @@ export type UserUpdateRequest = {
|
|||||||
|
|
||||||
export type BulkUserItem = {
|
export type BulkUserItem = {
|
||||||
email: string;
|
email: string;
|
||||||
|
loginId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type VerifySignupCodeRequest struct {
|
|||||||
|
|
||||||
type SignupRequest struct {
|
type SignupRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
LoginID string `json:"loginId,omitempty"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
@@ -70,6 +71,7 @@ type SignupRequest struct {
|
|||||||
type UserProfileResponse struct {
|
type UserProfileResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
LoginID string `json:"loginId,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"` // 추가
|
Role string `json:"role"` // 추가
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ var ErrNotSupported = errors.New("idp: not supported")
|
|||||||
type BrokerUser struct {
|
type BrokerUser struct {
|
||||||
ID string `json:"id" required:"true"`
|
ID string `json:"id" required:"true"`
|
||||||
Email string `json:"email" required:"true"`
|
Email string `json:"email" required:"true"`
|
||||||
|
LoginID string `json:"login_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
PhoneNumber string `json:"phone_number"`
|
PhoneNumber string `json:"phone_number"`
|
||||||
// Attributes stores custom user attributes.
|
// Attributes stores custom user attributes.
|
||||||
|
|||||||
@@ -34,13 +34,14 @@ func NormalizeRole(role string) string {
|
|||||||
type User struct {
|
type User struct {
|
||||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||||
|
LoginID string `gorm:"column:login_id;uniqueIndex:idx_tenant_login_id" json:"loginId"`
|
||||||
PasswordHash *string `gorm:"column:password_hash" json:"-"`
|
PasswordHash *string `gorm:"column:password_hash" json:"-"`
|
||||||
Name string `gorm:"column:name;not null" json:"name"`
|
Name string `gorm:"column:name;not null" json:"name"`
|
||||||
Phone string `gorm:"column:phone" json:"phone"`
|
Phone string `gorm:"column:phone" json:"phone"`
|
||||||
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
|
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
|
||||||
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
|
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
|
||||||
CompanyCode string `gorm:"column:company_code;index" json:"companyCode"`
|
CompanyCode string `gorm:"column:company_code;index" json:"companyCode"`
|
||||||
TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
|
TenantID *string `gorm:"column:tenant_id;type:uuid;index;uniqueIndex:idx_tenant_login_id" json:"tenantId,omitempty"`
|
||||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||||
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||||
Department string `gorm:"column:department" json:"department"`
|
Department string `gorm:"column:department" json:"department"`
|
||||||
|
|||||||
@@ -577,14 +577,31 @@ func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusNotFound, "Tenant not found")
|
return errorJSON(c, fiber.StatusNotFound, "Tenant not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
res := fiber.Map{
|
||||||
"isCentral": false,
|
"isCentral": false,
|
||||||
"id": tenant.ID,
|
"id": tenant.ID,
|
||||||
"name": tenant.Name,
|
"name": tenant.Name,
|
||||||
"slug": tenant.Slug,
|
"slug": tenant.Slug,
|
||||||
"description": tenant.Description,
|
"description": tenant.Description,
|
||||||
"type": tenant.Type,
|
"type": tenant.Type,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
|
||||||
|
res["loginIdField"] = loginIdField
|
||||||
|
// Find label in userSchema
|
||||||
|
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
||||||
|
for _, field := range schema {
|
||||||
|
if f, ok := field.(map[string]interface{}); ok {
|
||||||
|
if f["key"] == loginIdField {
|
||||||
|
res["loginIdLabel"] = f["label"]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다.
|
// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다.
|
||||||
|
|||||||
@@ -321,11 +321,20 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
"grade": role,
|
"grade": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Resolve TenantID before Kratos creation]
|
// [Resolve TenantID and LoginID before Kratos creation]
|
||||||
var tenantID string
|
var tenantID string
|
||||||
if req.CompanyCode != "" && h.TenantService != nil {
|
if req.CompanyCode != "" && h.TenantService != nil {
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
|
||||||
tenantID = tenant.ID
|
tenantID = tenant.ID
|
||||||
|
|
||||||
|
// Sync custom field to LoginID if configured
|
||||||
|
if loginIDField, ok := tenant.Config["loginIdField"].(string); ok && loginIDField != "" {
|
||||||
|
if val, exists := req.Metadata[loginIDField]; exists {
|
||||||
|
if loginIDStr, ok := val.(string); ok && loginIDStr != "" {
|
||||||
|
attributes["id"] = loginIDStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
attributes["role"] = role
|
attributes["role"] = role
|
||||||
@@ -333,6 +342,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
attributes["tenant_id"] = tenantID
|
attributes["tenant_id"] = tenantID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Override with explicit LoginID if provided]
|
||||||
|
if req.LoginID != "" {
|
||||||
|
attributes["id"] = req.LoginID
|
||||||
|
}
|
||||||
|
|
||||||
// Merge custom metadata into attributes
|
// Merge custom metadata into attributes
|
||||||
for k, v := range req.Metadata {
|
for k, v := range req.Metadata {
|
||||||
// Don't overwrite core fields
|
// Don't overwrite core fields
|
||||||
@@ -343,6 +357,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
brokerUser := &domain.BrokerUser{
|
brokerUser := &domain.BrokerUser{
|
||||||
Email: email,
|
Email: email,
|
||||||
|
LoginID: extractString(attributes, "id"),
|
||||||
Name: name,
|
Name: name,
|
||||||
PhoneNumber: normalizePhoneNumber(req.Phone),
|
PhoneNumber: normalizePhoneNumber(req.Phone),
|
||||||
Attributes: attributes,
|
Attributes: attributes,
|
||||||
@@ -413,6 +428,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
type bulkUserItem struct {
|
type bulkUserItem struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
LoginID string `json:"loginId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
@@ -456,9 +472,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Pre-fetch tenant data to avoid redundant DB calls
|
// Pre-fetch tenant data to avoid redundant DB calls
|
||||||
type tenantCacheItem struct {
|
type tenantCacheItem struct {
|
||||||
ID string
|
ID string
|
||||||
Schema []interface{}
|
Schema []interface{}
|
||||||
Groups []domain.UserGroup
|
Groups []domain.UserGroup
|
||||||
|
LoginIDField string
|
||||||
}
|
}
|
||||||
tenantCache := make(map[string]tenantCacheItem)
|
tenantCache := make(map[string]tenantCacheItem)
|
||||||
|
|
||||||
@@ -500,6 +517,9 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
||||||
tItem.Schema = s
|
tItem.Schema = s
|
||||||
}
|
}
|
||||||
|
if lf, ok := tenant.Config["loginIdField"].(string); ok {
|
||||||
|
tItem.LoginIDField = lf
|
||||||
|
}
|
||||||
// [Fix] Cache user groups for this tenant to match department
|
// [Fix] Cache user groups for this tenant to match department
|
||||||
if h.UserGroupRepo != nil {
|
if h.UserGroupRepo != nil {
|
||||||
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
|
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
|
||||||
@@ -537,6 +557,20 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
"role": role,
|
"role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync LoginID from configured custom field
|
||||||
|
if tItem.LoginIDField != "" {
|
||||||
|
if val, exists := item.Metadata[tItem.LoginIDField]; exists {
|
||||||
|
if loginIDStr, ok := val.(string); ok && loginIDStr != "" {
|
||||||
|
attributes["id"] = loginIDStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override with explicit LoginID if provided
|
||||||
|
if item.LoginID != "" {
|
||||||
|
attributes["id"] = item.LoginID
|
||||||
|
}
|
||||||
|
|
||||||
// Merge metadata
|
// Merge metadata
|
||||||
for k, v := range item.Metadata {
|
for k, v := range item.Metadata {
|
||||||
if _, exists := attributes[k]; !exists {
|
if _, exists := attributes[k]; !exists {
|
||||||
@@ -546,6 +580,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
||||||
Email: email,
|
Email: email,
|
||||||
|
LoginID: extractTraitString(attributes, "id"),
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
PhoneNumber: normalizePhoneNumber(item.Phone),
|
PhoneNumber: normalizePhoneNumber(item.Phone),
|
||||||
Attributes: attributes,
|
Attributes: attributes,
|
||||||
@@ -571,6 +606,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
localUser := &domain.User{
|
localUser := &domain.User{
|
||||||
ID: identityID,
|
ID: identityID,
|
||||||
Email: email,
|
Email: email,
|
||||||
|
LoginID: extractTraitString(attributes, "id"),
|
||||||
Name: name,
|
Name: name,
|
||||||
Phone: normalizePhoneNumber(item.Phone),
|
Phone: normalizePhoneNumber(item.Phone),
|
||||||
Role: role,
|
Role: role,
|
||||||
@@ -1014,6 +1050,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
|
LoginID *string `json:"loginId"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Phone *string `json:"phone"`
|
Phone *string `json:"phone"`
|
||||||
@@ -1113,11 +1150,43 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
traits["role"] = role
|
traits["role"] = role
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [LoginID Sync based on Tenant Settings]
|
||||||
|
schemaCompCode := extractTraitString(traits, "companyCode")
|
||||||
|
if req.CompanyCode != nil {
|
||||||
|
schemaCompCode = *req.CompanyCode
|
||||||
|
}
|
||||||
|
if schemaCompCode != "" && h.TenantService != nil {
|
||||||
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), schemaCompCode); err == nil && tenant != nil {
|
||||||
|
if loginIDField, ok := tenant.Config["loginIdField"].(string); ok && loginIDField != "" {
|
||||||
|
// Search in Metadata (could be flat or namespaced)
|
||||||
|
if val, exists := req.Metadata[loginIDField]; exists {
|
||||||
|
if loginIDStr, ok := val.(string); ok && loginIDStr != "" {
|
||||||
|
traits["id"] = loginIDStr
|
||||||
|
}
|
||||||
|
} else if namespaced, exists := req.Metadata[tenant.ID]; exists {
|
||||||
|
if subMeta, ok := namespaced.(map[string]any); ok {
|
||||||
|
if val, exists := subMeta[loginIDField]; exists {
|
||||||
|
if loginIDStr, ok := val.(string); ok && loginIDStr != "" {
|
||||||
|
traits["id"] = loginIDStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Override with explicit LoginID if provided]
|
||||||
|
if req.LoginID != nil && *req.LoginID != "" {
|
||||||
|
traits["id"] = *req.LoginID
|
||||||
|
}
|
||||||
|
|
||||||
// [Namespaced Metadata Sync]
|
// [Namespaced Metadata Sync]
|
||||||
coreTraits := map[string]bool{
|
coreTraits := map[string]bool{
|
||||||
"email": true, "name": true, "phone_number": true,
|
"email": true, "name": true, "phone_number": true,
|
||||||
"grade": true, "companyCode": true, "department": true,
|
"grade": true, "companyCode": true, "department": true,
|
||||||
"affiliationType": true, "role": true, "tenant_id": true,
|
"affiliationType": true, "role": true, "tenant_id": true,
|
||||||
|
"id": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// For namespaced metadata, we don't delete everything, we merge.
|
// For namespaced metadata, we don't delete everything, we merge.
|
||||||
@@ -1284,6 +1353,7 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
|||||||
user := &domain.User{
|
user := &domain.User{
|
||||||
ID: identity.ID,
|
ID: identity.ID,
|
||||||
Email: extractTraitString(traits, "email"),
|
Email: extractTraitString(traits, "email"),
|
||||||
|
LoginID: extractTraitString(traits, "id"),
|
||||||
Name: extractTraitString(traits, "name"),
|
Name: extractTraitString(traits, "name"),
|
||||||
Phone: extractTraitString(traits, "phone_number"),
|
Phone: extractTraitString(traits, "phone_number"),
|
||||||
Role: role,
|
Role: role,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (o *OryProvider) Name() string {
|
|||||||
func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||||
return &domain.IDPMetadata{
|
return &domain.IDPMetadata{
|
||||||
SupportedFields: []string{
|
SupportedFields: []string{
|
||||||
"id", "email", "name", "phone_number",
|
"id", "login_id", "email", "name", "phone_number",
|
||||||
"grade", "department", "affiliationType", "companyCode",
|
"grade", "department", "affiliationType", "companyCode",
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -64,6 +64,17 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
|||||||
if existingID != "" {
|
if existingID != "" {
|
||||||
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
|
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.LoginID != "" {
|
||||||
|
existingLoginID, err := o.findIdentityID(user.LoginID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
|
||||||
|
}
|
||||||
|
if existingLoginID != "" {
|
||||||
|
return "", fmt.Errorf("ory provider: identity already exists for login_id=%s", user.LoginID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if user.PhoneNumber != "" {
|
if user.PhoneNumber != "" {
|
||||||
existingPhoneID, err := o.findIdentityID(user.PhoneNumber)
|
existingPhoneID, err := o.findIdentityID(user.PhoneNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,6 +89,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
|||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"name": user.Name,
|
"name": user.Name,
|
||||||
}
|
}
|
||||||
|
if user.LoginID != "" {
|
||||||
|
traits["id"] = user.LoginID
|
||||||
|
}
|
||||||
if user.PhoneNumber != "" {
|
if user.PhoneNumber != "" {
|
||||||
traits["phone_number"] = user.PhoneNumber
|
traits["phone_number"] = user.PhoneNumber
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
"traits": {
|
"traits": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "ID",
|
||||||
|
"ory.sh/kratos": {
|
||||||
|
"credentials": {
|
||||||
|
"password": {
|
||||||
|
"identifier": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "email",
|
"format": "email",
|
||||||
|
|||||||
53
docs/employee_id_login_db_design.md
Normal file
53
docs/employee_id_login_db_design.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 커스텀 필드 기반 로그인 ID 연동 - DB 설계 문서
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
본 문서는 사용자(User) 정보에 범용 로그인 ID(`login_id`)를 추가하고, 이를 테넌트별 설정에 따라 커스텀 필드와 동기화하기 위한 **데이터베이스(DB) 관점의 설계 변경 사항**을 명세합니다.
|
||||||
|
|
||||||
|
## 2. DB 스키마 변경 사항
|
||||||
|
|
||||||
|
### 2.1. 대상 테이블: `users`
|
||||||
|
|
||||||
|
현재 백엔드(PostgreSQL)의 `users` 테이블에 `login_id` 컬럼을 추가합니다.
|
||||||
|
|
||||||
|
| 컬럼명 | 타입 | 제약 조건 | 설명 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `login_id` | `VARCHAR(255)` | `NULL` 허용 | 범용 로그인 식별자 (사번, 학번 등) |
|
||||||
|
|
||||||
|
#### 인덱스(Index) 설정
|
||||||
|
단순 `unique`가 아닌, **테넌트 내 고유성**을 보장하기 위해 `tenant_id`와 조합된 복합 유니크 인덱스를 생성합니다.
|
||||||
|
* **인덱스명**: `idx_tenant_login_id`
|
||||||
|
* **구성 컬럼**: `(tenant_id, login_id)`
|
||||||
|
* **효과**:
|
||||||
|
* 서로 다른 테넌트 간에는 동일한 `login_id`가 존재할 수 있습니다.
|
||||||
|
* 동일 테넌트 내에서는 중복된 `login_id`를 가질 수 없습니다.
|
||||||
|
|
||||||
|
### 2.2. 대상 테이블: `tenants`
|
||||||
|
|
||||||
|
`tenants` 테이블의 `config` (JSONB) 컬럼에 매핑 설정을 추가합니다.
|
||||||
|
|
||||||
|
* **설정 키**: `loginIdField`
|
||||||
|
* **설명**: 사용자의 `metadata` (커스텀 필드) 중 어떤 필드의 값을 `login_id`로 동기화할지 결정하는 필드 키 이름입니다.
|
||||||
|
* **예시**: `"loginIdField": "emp_no"`
|
||||||
|
|
||||||
|
## 3. 데이터 흐름 및 동기화
|
||||||
|
|
||||||
|
### 3.1. 사용자 생성/수정 (Sync Flow)
|
||||||
|
1. 사용자 생성/수정 시 전달된 `metadata`와 테넌트의 `config.loginIdField`를 확인합니다.
|
||||||
|
2. `metadata` 내에 해당 키의 값이 존재하면, 그 값을 `users.login_id` 컬럼에 저장합니다.
|
||||||
|
3. 동시에 Ory Kratos의 `traits.id` 필드에도 해당 값을 업데이트하여 Kratos를 통한 로그인을 가능하게 합니다.
|
||||||
|
|
||||||
|
### 3.2. 대량 업로드 (Bulk Import)
|
||||||
|
1. CSV/Excel 파일의 컬럼 중 테넌트에서 지정한 커스텀 필드 컬럼(예: 사번)을 파싱합니다.
|
||||||
|
2. 위의 동기화 로직과 동일하게 `login_id` 컬럼과 Kratos Traits를 업데이트합니다.
|
||||||
|
|
||||||
|
## 4. GORM 모델 반영 (Go)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
ID string `gorm:"primaryKey;type:uuid"`
|
||||||
|
Email string `gorm:"uniqueIndex;not null"`
|
||||||
|
LoginID string `gorm:"column:login_id;uniqueIndex:idx_tenant_login_id"`
|
||||||
|
TenantID *string `gorm:"column:tenant_id;type:uuid;uniqueIndex:idx_tenant_login_id"`
|
||||||
|
// ... 기타 필드
|
||||||
|
}
|
||||||
|
```
|
||||||
47
docs/employee_id_login_design.md
Normal file
47
docs/employee_id_login_design.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 커스텀 필드 기반 로그인 ID 연동 설계 문서 (구 사번 로그인)
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
기존에 고정된 사번(`employee_id`) 필드를 사용하려던 설계를 변경하여, 테넌트별로 지정한 **커스텀 필드**를 실제 로그인 식별자로 사용할 수 있도록 하는 범용적인 로그인 ID 체계를 구축합니다.
|
||||||
|
|
||||||
|
## 2. 시스템별 변경 사항
|
||||||
|
|
||||||
|
### 2.1. Ory Kratos (인증 서버)
|
||||||
|
* **Identity Schema**: `traits` 내에 `id` 필드를 추가합니다.
|
||||||
|
* **Identifier 설정**: `traits.id`를 `credentials.password.identifier`로 설정하여 로그인 시 식별자로 사용할 수 있게 합니다.
|
||||||
|
* **특징**: 이 필드는 '사번', '학번', 'ID' 등 테넌트의 성격에 따라 다양한 용도로 활용되는 범용 식별자 역할을 합니다.
|
||||||
|
|
||||||
|
### 2.2. Backend (baron-sso-backend)
|
||||||
|
|
||||||
|
#### 데이터 모델 (`domain.User`)
|
||||||
|
* `login_id` (string) 컬럼 추가.
|
||||||
|
* `idx_tenant_login_id` 복합 유니크 인덱스 생성: `(tenant_id, login_id)`.
|
||||||
|
|
||||||
|
#### 테넌트 설정 (`domain.Tenant`)
|
||||||
|
* `Config` 내에 `loginIdField` 설정을 추가합니다.
|
||||||
|
* 이 설정은 해당 테넌트의 `userSchema` 중 어떤 필드(key)가 로그인 ID로 사용될지를 저장합니다.
|
||||||
|
|
||||||
|
#### 동기화 로직 (`UserHandler`)
|
||||||
|
* **사용자 생성/수정 시**:
|
||||||
|
1. 테넌트의 `loginIdField` 설정을 조회합니다.
|
||||||
|
2. 설정된 필드가 있다면 사용자의 `Metadata`에서 해당 값을 추출합니다.
|
||||||
|
3. 추출된 값을 Kratos의 `traits.id`와 로컬 DB의 `login_id` 컬럼에 동기화합니다.
|
||||||
|
* **대량 등록 (Bulk Import)**: CSV/JSON 업로드 시에도 동일한 동기화 로직이 적용됩니다.
|
||||||
|
|
||||||
|
### 2.3. Frontend (adminfront)
|
||||||
|
|
||||||
|
#### 테넌트 스키마 관리 (`TenantSchemaPage`)
|
||||||
|
* 커스텀 필드 정의 시 "로그인 ID로 사용" 체크박스를 추가합니다.
|
||||||
|
* 이 체크박스를 선택하면 해당 필드의 `key`가 테넌트 `Config.loginIdField`에 저장됩니다.
|
||||||
|
|
||||||
|
#### 사용자 관리 (`UserCreatePage`, `UserDetailPage`)
|
||||||
|
* 기본 정보 영역에 "로그인 ID" 필드를 노출하여 직접 관리할 수 있게 합니다.
|
||||||
|
|
||||||
|
### 2.4. Frontend (userfront)
|
||||||
|
|
||||||
|
#### 로그인 페이지 (`LoginScreen`)
|
||||||
|
* URL의 `companyCode` 또는 도메인을 통해 테넌트를 식별합니다.
|
||||||
|
* 해당 테넌트에 `loginIdField`가 설정되어 있다면, 로그인 입력란의 라벨을 해당 커스텀 필드의 라벨(예: "사번")로 동적으로 변경합니다.
|
||||||
|
|
||||||
|
## 3. 기대 효과
|
||||||
|
* 테넌트별로 상이한 로그인 식별자 요구사항(사번, 학생번호, 커스텀 ID 등)을 코드 수정 없이 유연하게 수용할 수 있습니다.
|
||||||
|
* 이메일, 전화번호 외의 추가적인 로그인 수단을 제공하여 사용자 편의성을 높입니다.
|
||||||
@@ -1031,6 +1031,7 @@ key_placeholder = "e.g. employee_id"
|
|||||||
label = "Display Label"
|
label = "Display Label"
|
||||||
label_placeholder = "Label Placeholder"
|
label_placeholder = "Label Placeholder"
|
||||||
required = "Required"
|
required = "Required"
|
||||||
|
is_login_id = "Use as Login ID"
|
||||||
type = "Type"
|
type = "Type"
|
||||||
type_boolean = "Boolean"
|
type_boolean = "Boolean"
|
||||||
type_date = "Date"
|
type_date = "Date"
|
||||||
@@ -1104,6 +1105,8 @@ department = "Department"
|
|||||||
department_placeholder = "Department Placeholder"
|
department_placeholder = "Department Placeholder"
|
||||||
email = "Email"
|
email = "Email"
|
||||||
email_placeholder = "user@example.com"
|
email_placeholder = "user@example.com"
|
||||||
|
login_id = "Login ID (Optional)"
|
||||||
|
login_id_placeholder = "Employee ID or ID"
|
||||||
job_title = "Job Title"
|
job_title = "Job Title"
|
||||||
job_title_placeholder = "e.g. Frontend Developer"
|
job_title_placeholder = "e.g. Frontend Developer"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
@@ -1136,6 +1139,8 @@ multi_title = "Per-tenant Profile Management"
|
|||||||
[ui.admin.users.detail.form]
|
[ui.admin.users.detail.form]
|
||||||
department = "Department"
|
department = "Department"
|
||||||
department_placeholder = "Department Placeholder"
|
department_placeholder = "Department Placeholder"
|
||||||
|
login_id = "Login ID"
|
||||||
|
login_id_placeholder = "Employee ID or Username"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
name_placeholder = "Name Placeholder"
|
name_placeholder = "Name Placeholder"
|
||||||
phone = "Phone number"
|
phone = "Phone number"
|
||||||
@@ -1181,6 +1186,7 @@ title = "User Registry"
|
|||||||
[ui.admin.users.list.table]
|
[ui.admin.users.list.table]
|
||||||
actions = "ACTIONS"
|
actions = "ACTIONS"
|
||||||
created = "CREATED"
|
created = "CREATED"
|
||||||
|
login_id = "LOGIN ID"
|
||||||
name_email = "NAME / EMAIL"
|
name_email = "NAME / EMAIL"
|
||||||
role = "ROLE"
|
role = "ROLE"
|
||||||
status = "STATUS"
|
status = "STATUS"
|
||||||
|
|||||||
@@ -1531,6 +1531,7 @@ key_placeholder = "e.g. employee_id"
|
|||||||
label = "표시 레이블"
|
label = "표시 레이블"
|
||||||
label_placeholder = "예: 사번"
|
label_placeholder = "예: 사번"
|
||||||
required = "필수 여부"
|
required = "필수 여부"
|
||||||
|
is_login_id = "로그인 ID로 사용"
|
||||||
type = "타입"
|
type = "타입"
|
||||||
type_boolean = "Boolean"
|
type_boolean = "Boolean"
|
||||||
type_date = "Date"
|
type_date = "Date"
|
||||||
@@ -1563,6 +1564,8 @@ department = "부서"
|
|||||||
department_placeholder = "개발팀"
|
department_placeholder = "개발팀"
|
||||||
email = "이메일"
|
email = "이메일"
|
||||||
email_placeholder = "user@example.com"
|
email_placeholder = "user@example.com"
|
||||||
|
login_id = "로그인 ID (선택)"
|
||||||
|
login_id_placeholder = "사번 또는 아이디"
|
||||||
job_title = "직무"
|
job_title = "직무"
|
||||||
job_title_placeholder = "프론트엔드 개발"
|
job_title_placeholder = "프론트엔드 개발"
|
||||||
name = "이름"
|
name = "이름"
|
||||||
@@ -1589,6 +1592,8 @@ multi_title = "테넌트별 프로필 관리"
|
|||||||
[ui.admin.users.detail.form]
|
[ui.admin.users.detail.form]
|
||||||
department = "부서"
|
department = "부서"
|
||||||
department_placeholder = "개발팀"
|
department_placeholder = "개발팀"
|
||||||
|
login_id = "로그인 ID"
|
||||||
|
login_id_placeholder = "사번 또는 아이디"
|
||||||
name = "이름"
|
name = "이름"
|
||||||
name_placeholder = "홍길동"
|
name_placeholder = "홍길동"
|
||||||
phone = "전화번호"
|
phone = "전화번호"
|
||||||
@@ -1625,6 +1630,7 @@ title = "사용자 레지스트리"
|
|||||||
[ui.admin.users.list.table]
|
[ui.admin.users.list.table]
|
||||||
actions = "ACTIONS"
|
actions = "ACTIONS"
|
||||||
created = "CREATED"
|
created = "CREATED"
|
||||||
|
login_id = "LOGIN ID"
|
||||||
name_email = "NAME / EMAIL"
|
name_email = "NAME / EMAIL"
|
||||||
role = "ROLE"
|
role = "ROLE"
|
||||||
status = "STATUS"
|
status = "STATUS"
|
||||||
|
|||||||
@@ -110,6 +110,19 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> getTenantInfo() async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/tenant-info');
|
||||||
|
final response = await http.get(url);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw _error(
|
||||||
|
'err.userfront.auth_proxy.tenant_info_fetch',
|
||||||
|
'테넌트 정보를 불러오지 못했습니다.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> initEnchantedLink(
|
static Future<Map<String, dynamic>> initEnchantedLink(
|
||||||
String loginId, {
|
String loginId, {
|
||||||
String? method,
|
String? method,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
String? _redirectUrl;
|
String? _redirectUrl;
|
||||||
String? _loginChallenge;
|
String? _loginChallenge;
|
||||||
bool _isPasswordCapsLockOn = false;
|
bool _isPasswordCapsLockOn = false;
|
||||||
|
String? _loginIdLabel;
|
||||||
|
|
||||||
// QR Login Variables
|
// QR Login Variables
|
||||||
String? _qrImageBase64;
|
String? _qrImageBase64;
|
||||||
@@ -150,6 +151,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (!_verificationOnly) {
|
if (!_verificationOnly) {
|
||||||
await _attemptOidcAutoAccept();
|
await _attemptOidcAutoAccept();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Fetch Tenant Info to check for custom login ID label
|
||||||
|
try {
|
||||||
|
final info = await AuthProxyService.getTenantInfo();
|
||||||
|
if (info['loginIdLabel'] != null) {
|
||||||
|
setState(() {
|
||||||
|
_loginIdLabel = info['loginIdLabel'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("[Auth] Failed to fetch tenant info: $e");
|
||||||
|
}
|
||||||
|
|
||||||
// login_challenge 흐름에서는 auto-accept에서 이미 쿠키 세션까지 확인하므로
|
// login_challenge 흐름에서는 auto-accept에서 이미 쿠키 세션까지 확인하므로
|
||||||
// 동일 프레임에서 중복 체크를 피합니다.
|
// 동일 프레임에서 중복 체크를 피합니다.
|
||||||
if (!_hasLoginChallenge) {
|
if (!_hasLoginChallenge) {
|
||||||
@@ -1456,7 +1470,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
controller: _passwordLoginIdController,
|
controller: _passwordLoginIdController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: _loginIdLabel ?? tr(
|
||||||
'ui.userfront.login.field.login_id',
|
'ui.userfront.login.field.login_id',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
|||||||
Reference in New Issue
Block a user