1
0
forked from baron/baron-sso

Merge pull request 'temp-branch' (#461) from temp-branch into dev

Reviewed-on: baron/baron-sso#461
This commit is contained in:
2026-03-27 19:02:42 +09:00
34 changed files with 1959 additions and 478 deletions

View File

@@ -117,7 +117,10 @@ function AppLayout() {
}; };
useEffect(() => { useEffect(() => {
if (!auth.isLoading && !auth.isAuthenticated) { const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
if (!auth.isLoading && !auth.isAuthenticated && !isTest) {
navigate("/login"); navigate("/login");
} }
}, [auth.isLoading, auth.isAuthenticated, navigate]); }, [auth.isLoading, auth.isAuthenticated, navigate]);

View File

@@ -40,6 +40,7 @@ import {
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast"; import { toast } from "../../../components/ui/use-toast";
import { import {
type TenantAdmin,
addTenantAdmin, addTenantAdmin,
addTenantOwner, addTenantOwner,
fetchTenantAdmins, fetchTenantAdmins,
@@ -82,14 +83,54 @@ export function TenantAdminsAndOwnersTab() {
const addOwnerMutation = useMutation({ const addOwnerMutation = useMutation({
mutationFn: (userId: string) => addTenantOwner(tenantId, userId), mutationFn: (userId: string) => addTenantOwner(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-owners", tenantId],
});
const previousOwners = queryClient.getQueryData<TenantAdmin[]>([
"tenant-owners",
tenantId,
]);
// Optimistically add to the list to prevent immediate double clicks
const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) {
queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId],
(old) => {
if (!old)
return [
{ id: userId, name: addedUser.name, email: addedUser.email },
];
if (old.some((o) => o.id === userId)) return old;
return [
...old,
{ id: userId, name: addedUser.name, email: addedUser.email },
];
},
);
}
return { previousOwners };
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] }); // Delay invalidation slightly to give the backend outbox time to process
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-owners", tenantId],
});
}, 1000);
toast.success( toast.success(
t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."), t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."),
); );
setSearchTerm(""); setSearchTerm("");
}, },
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>, userId, context) => {
if (context?.previousOwners) {
queryClient.setQueryData(
["tenant-owners", tenantId],
context.previousOwners,
);
}
toast.error( toast.error(
err.response?.data?.error || err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."), t("msg.common.error", "오류가 발생했습니다."),
@@ -99,8 +140,26 @@ export function TenantAdminsAndOwnersTab() {
const removeOwnerMutation = useMutation({ const removeOwnerMutation = useMutation({
mutationFn: (userId: string) => removeTenantOwner(tenantId, userId), mutationFn: (userId: string) => removeTenantOwner(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-owners", tenantId],
});
const previousOwners = queryClient.getQueryData<TenantAdmin[]>([
"tenant-owners",
tenantId,
]);
queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId],
(old) => (old ? old.filter((o) => o.id !== userId) : []),
);
return { previousOwners };
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] }); setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-owners", tenantId],
});
}, 1000);
toast.success( toast.success(
t( t(
"msg.admin.tenants.owners.remove_success", "msg.admin.tenants.owners.remove_success",
@@ -108,7 +167,13 @@ export function TenantAdminsAndOwnersTab() {
), ),
); );
}, },
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>, userId, context) => {
if (context?.previousOwners) {
queryClient.setQueryData(
["tenant-owners", tenantId],
context.previousOwners,
);
}
toast.error( toast.error(
err.response?.data?.error || err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."), t("msg.common.error", "오류가 발생했습니다."),
@@ -118,14 +183,52 @@ export function TenantAdminsAndOwnersTab() {
const addAdminMutation = useMutation({ const addAdminMutation = useMutation({
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId), mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-admins", tenantId],
});
const previousAdmins = queryClient.getQueryData<TenantAdmin[]>([
"tenant-admins",
tenantId,
]);
const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) {
queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId],
(old) => {
if (!old)
return [
{ id: userId, name: addedUser.name, email: addedUser.email },
];
if (old.some((a) => a.id === userId)) return old;
return [
...old,
{ id: userId, name: addedUser.name, email: addedUser.email },
];
},
);
}
return { previousAdmins };
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-admins", tenantId],
});
}, 1000);
toast.success( toast.success(
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."), t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
); );
setSearchTerm(""); setSearchTerm("");
}, },
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>, userId, context) => {
if (context?.previousAdmins) {
queryClient.setQueryData(
["tenant-admins", tenantId],
context.previousAdmins,
);
}
toast.error( toast.error(
err.response?.data?.error || err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."), t("msg.common.error", "오류가 발생했습니다."),
@@ -135,13 +238,37 @@ export function TenantAdminsAndOwnersTab() {
const removeAdminMutation = useMutation({ const removeAdminMutation = useMutation({
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId), mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-admins", tenantId],
});
const previousAdmins = queryClient.getQueryData<TenantAdmin[]>([
"tenant-admins",
tenantId,
]);
queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId],
(old) => (old ? old.filter((a) => a.id !== userId) : []),
);
return { previousAdmins };
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-admins", tenantId],
});
}, 1000);
toast.success( toast.success(
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."), t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
); );
}, },
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>, userId, context) => {
if (context?.previousAdmins) {
queryClient.setQueryData(
["tenant-admins", tenantId],
context.previousAdmins,
);
}
toast.error( toast.error(
err.response?.data?.error || err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."), t("msg.common.error", "오류가 발생했습니다."),

View File

@@ -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

View File

@@ -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">

View File

@@ -5,11 +5,13 @@ import {
BadgeCheck, BadgeCheck,
Building2, Building2,
Copy, Copy,
Dices, Key,
Eye,
EyeOff,
Loader2, Loader2,
Mail,
RefreshCw,
Save, Save,
Trash2,
UserCheck,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import * as React from "react"; import * as React from "react";
@@ -19,6 +21,7 @@ import {
useForm, useForm,
} from "react-hook-form"; } 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,28 +34,16 @@ 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,
fetchTenants, fetchTenants,
fetchUser, fetchUser,
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;
@@ -63,37 +54,21 @@ type UserSchemaField = {
validation?: string; validation?: string;
}; };
type UserFormValues = UserUpdateRequest & { type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
metadata: Record<string, Record<string, unknown>>; metadata: Record<string, Record<string, string | number | boolean>>;
}; };
// [New] Component for per-tenant profile/schema management function TenantMetadataFields({
function TenantProfileCard({
tenant, tenant,
schema,
register, register,
errors, errors,
isAdmin,
}: { }: {
tenant: TenantSummary; tenant: { id: string; name: string; slug: string };
schema: UserSchemaField[];
register: UseFormRegister<UserFormValues>; register: UseFormRegister<UserFormValues>;
errors: FieldErrors<UserFormValues>; errors: FieldErrors<UserFormValues>;
isAdmin: boolean;
}) { }) {
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 (
@@ -138,13 +113,22 @@ function TenantProfileCard({
className={ className={
field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm" field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"
} }
{...register(`metadata.${tenant.id}.${field.key}`, { {...register(`metadata.${tenant.id}.${field.key}` as const, {
required: field.required required: field.required
? t( ? t(
"msg.admin.users.detail.form.field_required", "msg.admin.users.detail.form.field_required",
"필수입니다.", "필수입니다.",
) )
: false, : false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.detail.form.invalid_format",
"형식이 올바르지 않습니다.",
),
}
: undefined,
})} })}
/> />
{( {(
@@ -178,7 +162,10 @@ function UserDetailPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [successMsg, setSuccessMsg] = React.useState<string | null>(null); const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
const [showPassword, setShowPassword] = React.useState(false); const [isPasswordResetOpen, setIsPasswordResetOpen] = React.useState(false);
const [generatedPassword, setGeneratedPassword] = React.useState<
string | null
>(null);
const { data: profile } = useQuery({ const { data: profile } = useQuery({
queryKey: ["me"], queryKey: ["me"],
@@ -206,10 +193,10 @@ function UserDetailPage() {
handleSubmit, handleSubmit,
reset, reset,
watch, watch,
setValue,
formState: { errors }, formState: { errors },
} = useForm<UserFormValues>({ } = useForm<UserFormValues>({
defaultValues: { defaultValues: {
loginId: "",
name: "", name: "",
phone: "", phone: "",
role: "user", role: "user",
@@ -218,7 +205,6 @@ function UserDetailPage() {
department: "", department: "",
position: "", position: "",
jobTitle: "", jobTitle: "",
password: "",
metadata: {}, metadata: {},
}, },
}); });
@@ -226,22 +212,38 @@ function UserDetailPage() {
const isAdmin = const isAdmin =
profile?.role === "super_admin" || profile?.role === "tenant_admin"; profile?.role === "super_admin" || profile?.role === "tenant_admin";
const handleGeneratePassword = () => { const resetPasswordMutation = useMutation({
const newPass = generateSecurePassword(); mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
setValue("password", newPass); onSuccess: (_, newPass) => {
setShowPassword(true); setGeneratedPassword(newPass);
toast.success( toast.success(
t( t(
"msg.admin.users.detail.password_generated", "msg.admin.users.detail.password_generated",
"안전한 비밀번호가 생성되었습니다.", "사용자 비밀번호가 성공적으로 재설정되었습니다.",
), ),
); );
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.users.detail.update_error", "수정에 실패했습니다."),
);
},
});
const handleGeneratePassword = () => {
setIsPasswordResetOpen(true);
setGeneratedPassword(null);
};
const confirmGeneratePassword = () => {
const newPass = generateSecurePassword();
resetPasswordMutation.mutate(newPass);
}; };
const handleCopyPassword = () => { const handleCopyPassword = () => {
const pass = watch("password"); if (generatedPassword) {
if (pass) { navigator.clipboard.writeText(generatedPassword);
navigator.clipboard.writeText(pass);
toast.success( toast.success(
t("msg.common.copied_to_clipboard", "클립보드에 복사되었습니다."), t("msg.common.copied_to_clipboard", "클립보드에 복사되었습니다."),
); );
@@ -251,6 +253,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 +262,11 @@ function UserDetailPage() {
department: user.department || "", department: user.department || "",
position: user.position || "", position: user.position || "",
jobTitle: user.jobTitle || "", jobTitle: user.jobTitle || "",
password: "", metadata:
metadata: (user.metadata || {}) as unknown as Record< (user.metadata as unknown as Record<
string, string,
Record<string, unknown> Record<string, string | number | boolean>
>, >) || {},
}); });
} }
}, [user, reset]); }, [user, reset]);
@@ -279,73 +282,126 @@ 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;
// Filter out undefined/null/empty strings from metadata
const cleanMetadata = Object.fromEntries(
Object.entries(data.metadata).map(([tenantId, fields]) => {
const cleanFields = Object.fromEntries(
Object.entries(fields).filter(
([_, v]) => v !== undefined && v !== null && v !== "",
),
);
return [tenantId, cleanFields];
}),
);
mutation.mutate({
...data,
metadata: cleanMetadata as Record<string, unknown>,
});
};
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">
<p className="text-destructive">
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")} {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>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{t("ui.admin.users.detail.edit_title", "정보 수정")} {t("ui.admin.users.detail.edit_title", "사용자 정보 수정")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t( {t(
@@ -424,7 +480,8 @@ function UserDetailPage() {
</div> </div>
)} )}
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
* . *
.
</p> </p>
</div> </div>
@@ -443,8 +500,9 @@ function UserDetailPage() {
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"),
)?.id
? "bg-primary/10 border-primary/30 text-primary font-bold" ? "bg-primary/10 border-primary/30 text-primary font-bold"
: "bg-background border-border text-muted-foreground hover:border-primary/50" : "bg-background border-border text-muted-foreground hover:border-primary/50"
}`} }`}
@@ -499,63 +557,78 @@ function UserDetailPage() {
</div> </div>
</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="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"> <div className="space-y-2">
<Label htmlFor="status"> <Label htmlFor="status">
{t("ui.admin.users.detail.form.status", "상태")} {t("ui.admin.users.detail.form.status", "상태")}
</Label> </Label>
<div className="relative">
<select <select
id="status" 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" 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")} {...register("status")}
> >
<option value="active"> <option value="active">
{t("ui.common.status.active", "Active")} {t("ui.admin.users.detail.form.status_active", "활성")}
</option> </option>
<option value="inactive"> <option value="inactive">
{t("ui.common.status.inactive", "Inactive")} {t(
</option> "ui.admin.users.detail.form.status_inactive",
<option value="blocked"> "비활성",
{t("ui.common.status.blocked", "Blocked")} )}
</option> </option>
</select> </select>
</div> </div>
</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="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="department"> <Label htmlFor="department">
{t("ui.admin.users.detail.form.department", "부서")} {t("ui.admin.users.detail.form.department", "부서")}
</Label> </Label>
<Input <Input
id="department" id="department"
placeholder={t( placeholder={t(
@@ -565,118 +638,217 @@ function UserDetailPage() {
{...register("department")} {...register("department")}
/> />
</div> </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>
{/* Tenant-specific Profiles (Namespaced Metadata) */} <div className="space-y-2">
<div className="border-t pt-6 space-y-6"> <Label htmlFor="jobTitle">
<div className="flex flex-col gap-1"> {t("ui.admin.users.detail.form.job_title", "직무")}
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider"> </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( {t(
"ui.admin.users.detail.custom_fields.multi_title", "ui.admin.users.detail.custom_fields.multi_title",
"테넌트별 프로필 관리", "테넌트별 프로필 관리",
)} )}
</h3> </Label>
<p className="text-[11px] text-muted-foreground">
.
</p>
</div> </div>
<div className="grid gap-4"> <div className="grid gap-4">
{userAffiliatedTenants.map((tenant) => ( {userAffiliatedTenants.map((t) => {
<TenantProfileCard const tDetail = tenants.find(
key={tenant.id} (tenant) => tenant.id === t.id,
tenant={tenant} );
const schema = (tDetail?.config?.userSchema ||
[]) as UserSchemaField[];
return (
<TenantMetadataFields
key={t.id}
tenant={t}
schema={schema}
register={register} register={register}
errors={errors} errors={errors}
isAdmin={isAdmin}
/> />
))} );
})}
</div> </div>
</div> </div>
<div className="border-t pt-4"> <div className="flex justify-end 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",
"자동 생성",
)}
>
<Dices size={16} className="mr-2" />
{t("ui.common.generate", "생성")}
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyPassword}
disabled={!watch("password")}
title={t("ui.common.copy", "복사")}
>
<Copy size={16} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.users.detail.security.password_hint",
"비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
)}
</p>
</div>
</div>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => navigate("/users")}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && ( {mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
)} )}
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
{t("ui.common.save", "저장")} {t("ui.admin.users.detail.save", "변경사항 저장")}
</Button> </Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Key size={18} />
{t("ui.admin.users.detail.password_title", "비밀번호 관리")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/20">
<div className="space-y-1">
<p className="text-sm font-medium">
{t(
"ui.admin.users.detail.reset_password_label",
"비밀번호 초기화",
)}
</p>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<Button variant="outline" onClick={handleGeneratePassword}>
<RefreshCw className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.reset_password", "초기화 및 생성")}
</Button>
</div>
{isPasswordResetOpen && !generatedPassword && (
<div className="p-4 border rounded-lg bg-destructive/5 space-y-4">
<p className="text-sm">
{t(
"msg.admin.users.detail.reset_password_confirm",
"정말로 이 사용자의 비밀번호를 초기화하시겠습니까? 기존 비밀번호로는 즉시 로그인할 수 없게 됩니다.",
)}
</p>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setIsPasswordResetOpen(false)}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={confirmGeneratePassword}
disabled={resetPasswordMutation.isPending}
>
{resetPasswordMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t(
"ui.admin.users.detail.reset_password",
"초기화 및 생성",
)}
</Button>
</div>
</div>
)}
{generatedPassword && (
<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">
{generatedPassword}
</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>
); );
} }

View File

@@ -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)}

View File

@@ -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;

View File

@@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function generateSecurePassword(length = 16): string {
const charset =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~`|}{[]:;?><,./-=";
let password = "";
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
const values = new Uint32Array(length);
crypto.getRandomValues(values);
for (let i = 0; i < length; i++) {
password += charset[values[i] % charset.length];
}
} else {
// Fallback for older environments
for (let i = 0; i < length; i++) {
password += charset[Math.floor(Math.random() * charset.length)];
}
}
return password;
}

View File

@@ -283,6 +283,7 @@ update_success = "Update Success"
[msg.admin.users.detail.form] [msg.admin.users.detail.form]
field_required = "Required." field_required = "Required."
invalid_format = "Invalid format."
name_required = "Name Required" name_required = "Name Required"
[msg.admin.users.detail.security] [msg.admin.users.detail.security]

View File

@@ -283,6 +283,7 @@ update_success = "사용자 정보가 수정되었습니다."
[msg.admin.users.detail.form] [msg.admin.users.detail.form]
field_required = "필수입니다." field_required = "필수입니다."
invalid_format = "형식이 올바르지 않습니다."
name_required = "이름은 필수입니다." name_required = "이름은 필수입니다."
[msg.admin.users.detail.security] [msg.admin.users.detail.security]

View File

@@ -283,6 +283,7 @@ update_success = ""
[msg.admin.users.detail.form] [msg.admin.users.detail.form]
field_required = "" field_required = ""
invalid_format = ""
name_required = "" name_required = ""
[msg.admin.users.detail.security] [msg.admin.users.detail.security]

View File

@@ -83,7 +83,7 @@ test.describe("Tenant Owners Management", () => {
test("should list tenant owners", async ({ page }) => { test("should list tenant owners", async ({ page }) => {
await page.goto("/tenants/tenant-1/permissions"); await page.goto("/tenants/tenant-1/permissions");
await page.waitForLoadState("networkidle");
await expect(page.locator(".animate-spin").first()).not.toBeVisible(); await expect(page.locator(".animate-spin").first()).not.toBeVisible();
await expect(page.getByText(/테넌트 소유자|Tenant Owners/)).toBeVisible(); await expect(page.getByText(/테넌트 소유자|Tenant Owners/)).toBeVisible();
@@ -107,7 +107,7 @@ test.describe("Tenant Owners Management", () => {
); );
await page.goto("/tenants/tenant-1/permissions"); await page.goto("/tenants/tenant-1/permissions");
await page.waitForLoadState("networkidle");
await expect(page.locator(".animate-spin").first()).not.toBeVisible(); await expect(page.locator(".animate-spin").first()).not.toBeVisible();
await page.click( await page.click(

View File

@@ -7,38 +7,64 @@ test.describe("User Schema Dynamic Form", () => {
const client_id = "adminfront"; const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`; const key = `oidc.user:${authority}:${client_id}`;
const authData = { const authData = {
id_token: "fake-id-token",
access_token: "fake-token", access_token: "fake-token",
token_type: "Bearer", token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" }, scope: "openid profile email",
profile: {
sub: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 36000, expires_at: Math.floor(Date.now() / 1000) + 36000,
}; };
window.localStorage.setItem(key, JSON.stringify(authData)); window.localStorage.setItem(key, JSON.stringify(authData));
window.localStorage.setItem("admin_session", "fake-token"); window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("locale", "ko"); window.localStorage.setItem("locale", "ko");
// Mock oidc state to prevent redirection if the library checks it
window.localStorage.setItem("oidc.state", "dummy");
( (
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true; )._IS_TEST_MODE = true;
}); });
await page.route("**/oidc/**", async (route) => { await page.route("**/oidc/**", async (route) => {
if (route.request().url().includes("/.well-known/openid-configuration")) {
return route.fulfill({
json: {
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
jwks_uri: "http://localhost:5000/oidc/jwks",
},
});
}
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
}); });
await page.route(/.*\/api\/v1\/.*/, async (route) => { await page.route(/.*\/api\/v1\/.*/, async (route) => {
const url = route.request().url(); const url = route.request().url();
if (url.includes("/user/me")) { if (url.includes("/user/me")) {
console.log("Mocking ME"); console.log("Mocking /user/me");
return route.fulfill({ return route.fulfill({
json: { json: {
id: "admin-user", id: "admin-user",
name: "Admin", name: "Admin",
email: "admin@test.com",
role: "super_admin", role: "super_admin",
manageableTenants: [], manageableTenants: [
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" },
],
}, },
}); });
} }
if (url.includes("/admin/tenants/t-1")) { if (url.includes("/admin/tenants/t-1")) {
console.log("Mocking /admin/tenants/t-1");
return route.fulfill({ return route.fulfill({
json: { json: {
id: "t-1", id: "t-1",
@@ -65,6 +91,7 @@ test.describe("User Schema Dynamic Form", () => {
} }
if (url.includes("/admin/users/u-1")) { if (url.includes("/admin/users/u-1")) {
console.log("Mocking /admin/users/u-1");
return route.fulfill({ return route.fulfill({
json: { json: {
id: "u-1", id: "u-1",
@@ -81,14 +108,38 @@ test.describe("User Schema Dynamic Form", () => {
} }
if (url.includes("/admin/tenants")) { if (url.includes("/admin/tenants")) {
console.log("Mocking /admin/tenants");
return route.fulfill({ return route.fulfill({
json: { json: {
items: [{ id: "t-1", slug: "test-tenant", name: "Test Tenant" }], items: [
{
id: "t-1",
slug: "test-tenant",
name: "Test Tenant",
config: {
userSchema: [
{
key: "emp_id",
label: "Employee ID",
required: true,
validation: "^E[0-9]{3}$",
},
{
key: "salary",
label: "Salary",
adminOnly: true,
type: "number",
},
],
},
},
],
total: 1, total: 1,
}, },
}); });
} }
console.log("Mocking default empty list for:", url);
return route.fulfill({ json: { items: [], total: 0 } }); return route.fulfill({ json: { items: [], total: 0 } });
}); });
}); });
@@ -97,7 +148,6 @@ test.describe("User Schema Dynamic Form", () => {
page, page,
}) => { }) => {
await page.goto("/users/u-1"); await page.goto("/users/u-1");
await page.waitForLoadState("networkidle");
// 섹션 헤더 확인 // 섹션 헤더 확인
const header = page const header = page
@@ -122,7 +172,6 @@ test.describe("User Schema Dynamic Form", () => {
page, page,
}) => { }) => {
await page.goto("/users/u-1"); await page.goto("/users/u-1");
await page.waitForLoadState("networkidle");
const empIdInput = page.locator('input[id*="emp_id"]'); const empIdInput = page.locator('input[id*="emp_id"]');
await empIdInput.waitFor({ state: "visible" }); await empIdInput.waitFor({ state: "visible" });

View File

@@ -549,6 +549,7 @@ func main() {
// Signup Routes // Signup Routes
signup := auth.Group("/signup") signup := auth.Group("/signup")
signup.Post("/check-email", authHandler.CheckEmail) signup.Post("/check-email", authHandler.CheckEmail)
signup.Post("/check-login-id", authHandler.CheckLoginID)
signup.Post("/send-email-code", authHandler.SendSignupEmailCode) signup.Post("/send-email-code", authHandler.SendSignupEmailCode)
signup.Post("/send-sms-code", authHandler.SendSignupSmsCode) signup.Post("/send-sms-code", authHandler.SendSignupSmsCode)
signup.Post("/verify-code", authHandler.VerifySignupCode) signup.Post("/verify-code", authHandler.VerifySignupCode)

View File

@@ -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"` // 추가
@@ -89,6 +91,7 @@ type UpdateUserRequest struct {
Phone string `json:"phone"` Phone string `json:"phone"`
Department string `json:"department"` Department string `json:"department"`
VerificationCode string `json:"verificationCode,omitempty"` // For phone change VerificationCode string `json:"verificationCode,omitempty"` // For phone change
Metadata map[string]any `json:"metadata,omitempty"`
} }
// PasswordResetInitiateRequest is the request body for initiating a password reset. // PasswordResetInitiateRequest is the request body for initiating a password reset.
@@ -109,3 +112,8 @@ type PasswordChangeRequest struct {
CurrentPassword string `json:"currentPassword"` CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"` NewPassword string `json:"newPassword"`
} }
type CheckLoginIDRequest struct {
LoginID string `json:"loginId"`
CompanyCode string `json:"companyCode,omitempty"`
}

View File

@@ -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.

View File

@@ -1,6 +1,7 @@
package domain package domain
import ( import (
"fmt"
"strings" "strings"
"time" "time"
@@ -34,13 +35,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"`
@@ -60,3 +62,63 @@ func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
} }
return return
} }
// ValidateLoginID checks if the loginID violates any collision, length, or security rules.
func ValidateLoginID(loginID, email, phone string) error {
loginID = strings.TrimSpace(loginID)
if loginID == "" {
return nil
}
if len(loginID) < 4 || len(loginID) > 30 {
return fmt.Errorf("ID must be between 4 and 30 characters")
}
if strings.Contains(loginID, "@") {
return fmt.Errorf("ID cannot be an email format")
}
if email != "" && strings.EqualFold(loginID, email) {
return fmt.Errorf("ID cannot be the same as the email address")
}
if phone != "" {
normalizedPhone := strings.ReplaceAll(phone, "-", "")
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
if strings.HasPrefix(normalizedPhone, "010") {
normalizedPhone = "+82" + normalizedPhone[1:]
} else if strings.HasPrefix(normalizedPhone, "82") {
normalizedPhone = "+" + normalizedPhone
}
if loginID == phone || loginID == normalizedPhone {
return fmt.Errorf("ID cannot be the same as the phone number")
}
}
isPureNumber := true
loginIDDigits := strings.ReplaceAll(loginID, "-", "")
loginIDDigits = strings.ReplaceAll(loginIDDigits, " ", "")
for _, c := range loginIDDigits {
if (c < '0' || c > '9') && c != '+' {
isPureNumber = false
break
}
}
if isPureNumber && len(loginIDDigits) >= 10 && len(loginIDDigits) <= 12 {
if strings.HasPrefix(loginIDDigits, "010") || strings.HasPrefix(loginIDDigits, "82") || strings.HasPrefix(loginIDDigits, "+82") {
return fmt.Errorf("ID cannot be a phone number format")
}
}
reserved := []string{"admin", "system", "root", "master", "superuser", "guest", "operator"}
lowerID := strings.ToLower(loginID)
for _, r := range reserved {
if lowerID == r {
return fmt.Errorf("reserved ID cannot be used")
}
}
return nil
}

View File

@@ -0,0 +1,40 @@
package domain
import (
"testing"
)
func TestValidateLoginID(t *testing.T) {
tests := []struct {
name string
loginID string
email string
phone string
wantErr bool
}{
{"Empty", "", "test@email.com", "01012345678", false},
{"Valid alphanumeric", "user123", "test@email.com", "01012345678", false},
{"Too short", "us", "test@email.com", "01012345678", true},
{"Too long", "thisisaverylongloginidthatiswayoverthirtycharacters", "test@email.com", "01012345678", true},
{"Email format", "user@domain.com", "test@email.com", "01012345678", true},
{"Exact email match", "Test@Email.Com", "test@email.com", "01012345678", true},
{"Phone number match", "010-1234-5678", "test@email.com", "01012345678", true},
{"Phone number match +82", "+821012345678", "test@email.com", "01012345678", true},
{"Phone number match digits", "01012345678", "test@email.com", "01012345678", true},
{"Phone format (11 digits)", "01098765432", "test@email.com", "01012345678", true},
{"Valid pure digits (employee ID)", "20230001", "test@email.com", "01012345678", false},
{"Valid pure digits long", "123456789", "test@email.com", "01012345678", false},
{"Valid pure digits 10 chars", "1234567890", "test@email.com", "01012345678", false},
{"Reserved word admin", "ADMIN", "test@email.com", "01012345678", true},
{"Reserved word root", "root", "test@email.com", "01012345678", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateLoginID(tt.loginID, tt.email, tt.phone)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateLoginID() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@@ -194,6 +194,35 @@ func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"available": true}) return c.JSON(fiber.Map{"available": true})
} }
// CheckLoginID - 로그인 ID 사용 가능 여부를 확인합니다.
func (h *AuthHandler) CheckLoginID(c *fiber.Ctx) error {
var req domain.CheckLoginIDRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "Invalid request")
}
if h.IdpProvider == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
}
// Basic validation via our ValidateLoginID helper (without email/phone since we just check format & collision with reserved words)
if err := domain.ValidateLoginID(req.LoginID, "", ""); err != nil {
return c.JSON(fiber.Map{"available": false, "message": err.Error()})
}
// We don't prepend companyCode to Kratos lookup if traits.id is unique globally
// Assuming Kratos traits.id handles unique constraints per tenant or globally based on schema
exists, err := h.IdpProvider.UserExists(req.LoginID)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
}
if exists {
return c.JSON(fiber.Map{"available": false, "message": "ID already registered"})
}
return c.JSON(fiber.Map{"available": true})
}
// SendSignupEmailCode - Sends verification code to email // SendSignupEmailCode - Sends verification code to email
func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error {
var req domain.SendSignupCodeRequest var req domain.SendSignupCodeRequest
@@ -329,8 +358,9 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts") return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts")
} }
// Check Code match // Check Code match (Allow magic code 000000 in non-production environments)
if state.Code != req.Code { isMagicCodeAllowed := service.IsDryRunAllowed() && req.Code == "000000"
if state.Code != req.Code && !isMagicCodeAllowed {
state.FailCount++ state.FailCount++
h.saveSignupState(key, state, signupStateExpiration) h.saveSignupState(key, state, signupStateExpiration)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
@@ -451,8 +481,28 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
// grade는 기존 스키마 필수 키이므로 기본값을 설정 // grade는 기존 스키마 필수 키이므로 기본값을 설정
"grade": "member", "grade": "member",
} }
if req.LoginID != "" {
attributes["id"] = req.LoginID
}
// Sync custom field to LoginID if configured
if tenantID != nil && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenant(c.Context(), *tenantID); err == nil && tenant != nil {
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
syncLoginID(attributes, req.Metadata, *tenantID, loginIdField)
}
}
}
finalLoginID := extractTraitString(attributes, "id")
if err := domain.ValidateLoginID(finalLoginID, req.Email, normalizedPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
brokerUser := &domain.BrokerUser{ brokerUser := &domain.BrokerUser{
Email: req.Email, Email: req.Email,
LoginID: finalLoginID,
Name: req.Name, Name: req.Name,
PhoneNumber: normalizedPhone, PhoneNumber: normalizedPhone,
Attributes: attributes, Attributes: attributes,
@@ -577,14 +627,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)로 정규화합니다.
@@ -3988,8 +4055,8 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
if token != "" { if token != "" {
profile, err = h.getKratosProfile(token) profile, err = h.getKratosProfile(token)
if err != nil && h.Hydra != nil { if err != nil && h.Hydra != nil {
// Fallback to Hydra introspection // Fallback to Hydra introspection. This is expected for API calls using Bearer tokens.
slog.Debug("Kratos session check failed, trying Hydra", "error", err) slog.Debug("Kratos cookie session absent, falling back to Hydra token", "error", err.Error())
profile, err = h.getHydraProfile(c.Context(), token) profile, err = h.getHydraProfile(c.Context(), token)
} }
} else if cookie != "" { } else if cookie != "" {
@@ -4002,10 +4069,12 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
if profile != nil { if profile != nil {
if isDev && mockRole != "" { if isDev && mockRole != "" {
normalizedMockRole := domain.NormalizeRole(mockRole) normalizedMockRole := domain.NormalizeRole(mockRole)
if profile.Role != normalizedMockRole {
slog.Info("🔑 [AUTH] Overriding real profile role", slog.Info("🔑 [AUTH] Overriding real profile role",
"email", profile.Email, "originalRole", profile.Role, "overriddenRole", normalizedMockRole) "email", profile.Email, "originalRole", profile.Role, "overriddenRole", normalizedMockRole)
profile.Role = normalizedMockRole profile.Role = normalizedMockRole
} }
}
} else if isDev && mockRole != "" && token == "" && cookie == "" { } else if isDev && mockRole != "" && token == "" && cookie == "" {
normalizedMockRole := domain.NormalizeRole(mockRole) normalizedMockRole := domain.NormalizeRole(mockRole)
slog.Info("🔑 [AUTH] Using full Mock Auth (no session)", "role", mockRole) slog.Info("🔑 [AUTH] Using full Mock Auth (no session)", "role", mockRole)
@@ -5249,6 +5318,46 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
traits["department"] = req.Department traits["department"] = req.Department
} }
// Merge custom metadata into traits
if len(req.Metadata) > 0 {
for k, v := range req.Metadata {
// Do not overwrite core fields
if _, isCore := map[string]bool{"email": true, "phone_number": true, "name": true, "department": true, "grade": true, "companyCode": true, "affiliationType": true, "id": true, "role": true, "tenant_id": true}[k]; !isCore {
// [Fix] Support merging namespaced metadata maps
if incomingMap, ok := v.(map[string]any); ok {
if existingMap, ok := traits[k].(map[string]interface{}); ok {
for subK, subV := range incomingMap {
existingMap[subK] = subV
}
traits[k] = existingMap
} else {
traits[k] = incomingMap
}
} else {
traits[k] = v
}
}
}
}
// [LoginID Sync based on Tenant Settings]
// Perform sync AFTER metadata merge to ensure traits contains current values
syncCompCode := extractTraitString(traits, "companyCode")
if syncCompCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), syncCompCode); err == nil && tenant != nil {
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
syncLoginID(traits, req.Metadata, tenant.ID, loginIdField)
}
}
}
finalLoginID := extractTraitString(traits, "id")
userEmail := extractTraitString(traits, "email")
userPhone := extractTraitString(traits, "phone")
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
if err := h.updateKratosIdentity(identityID, traits); err != nil { if err := h.updateKratosIdentity(identityID, traits); err != nil {
slog.Error("Failed to update profile in Kratos", "error", err) slog.Error("Failed to update profile in Kratos", "error", err)
return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.") return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.")

View File

@@ -99,7 +99,7 @@ func TestVerifySignupCode_Invalid(t *testing.T) {
verifyBody := map[string]string{ verifyBody := map[string]string{
"type": "email", "type": "email",
"target": "user@test.com", "target": "user@test.com",
"code": "000000", // wrong code "code": "222222", // wrong code
} }
body, _ := json.Marshal(verifyBody) body, _ := json.Marshal(verifyBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body))

View File

@@ -136,6 +136,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
validTenant := &domain.Tenant{ID: "t1", Slug: "valid-slug", Status: domain.TenantStatusActive} validTenant := &domain.Tenant{ID: "t1", Slug: "valid-slug", Status: domain.TenantStatusActive}
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil) mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil)
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "valid-slug").Return(validTenant, nil) mockTenantSvc.On("GetTenantBySlug", mock.Anything, "valid-slug").Return(validTenant, nil)
mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil)
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil) mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil)
mockRedis.On("Delete", mock.Anything).Return(nil) mockRedis.On("Delete", mock.Anything).Return(nil)

View File

@@ -529,6 +529,13 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
} }
if h.Keto != nil {
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "User:"+userID)
if err == nil && len(relations) > 0 {
return errorJSON(c, fiber.StatusConflict, "이미 관리자로 등록된 사용자입니다.")
}
}
if h.KetoOutbox != nil { if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant", Namespace: "Tenant",
@@ -660,6 +667,13 @@ func (h *TenantHandler) AddOwner(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
} }
if h.Keto != nil {
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "User:"+userID)
if err == nil && len(relations) > 0 {
return errorJSON(c, fiber.StatusConflict, "이미 소유자로 등록된 사용자입니다.")
}
}
if h.KetoOutbox != nil { if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant", Namespace: "Tenant",

View File

@@ -258,6 +258,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
var req struct { var req struct {
Email string `json:"email"` Email string `json:"email"`
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"`
@@ -321,11 +322,21 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
"grade": role, "grade": role,
} }
// [Resolve TenantID before Kratos creation] // [Override with explicit LoginID if provided]
if req.LoginID != "" {
attributes["id"] = req.LoginID
}
// [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 != "" {
syncLoginID(attributes, req.Metadata, tenantID, loginIdField)
}
} }
} }
attributes["role"] = role attributes["role"] = role
@@ -341,8 +352,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
} }
} }
finalLoginID := extractTraitString(attributes, "id")
if err := domain.ValidateLoginID(finalLoginID, email, normalizePhoneNumber(req.Phone)); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
brokerUser := &domain.BrokerUser{ brokerUser := &domain.BrokerUser{
Email: email, Email: email,
LoginID: finalLoginID,
Name: name, Name: name,
PhoneNumber: normalizePhoneNumber(req.Phone), PhoneNumber: normalizePhoneNumber(req.Phone),
Attributes: attributes, Attributes: attributes,
@@ -413,6 +430,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"`
@@ -459,6 +477,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
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 +519,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 +559,16 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
"role": role, "role": role,
} }
// Override with explicit LoginID if provided
if item.LoginID != "" {
attributes["id"] = item.LoginID
}
// Sync LoginID from configured custom field (overrides explicit LoginID)
if tItem.LoginIDField != "" {
syncLoginID(attributes, item.Metadata, tItem.ID, tItem.LoginIDField)
}
// 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 {
@@ -544,10 +576,20 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
} }
} }
finalLoginID := extractTraitString(attributes, "id")
userEmail := email
userPhone := normalizePhoneNumber(item.Phone)
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
continue
}
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{ identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
Email: email, Email: userEmail,
LoginID: finalLoginID,
Name: item.Name, Name: item.Name,
PhoneNumber: normalizePhoneNumber(item.Phone), PhoneNumber: userPhone,
Attributes: attributes, Attributes: attributes,
}, password) }, password)
if err != nil { if err != nil {
@@ -571,6 +613,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 +1057,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,23 +1157,63 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["role"] = role traits["role"] = role
} }
// [Override with explicit LoginID if provided]
// This is done FIRST so that if a custom loginIdField is configured in the tenant,
// the metadata sync below will override this explicit value, preventing the UI's
// pre-filled explicit loginId from clobbering the updated custom field.
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.
// But we should remove legacy flat traits that are not in the new req.Metadata if we want strict sync.
// For now, let's just merge.
for k, v := range req.Metadata { for k, v := range req.Metadata {
if !coreTraits[k] { if !coreTraits[k] {
traits[k] = v // Ensure we are merging maps (tenant namespaces) correctly, not overwriting with slices
if incomingMap, ok := v.(map[string]any); ok {
if existingMap, ok := traits[k].(map[string]interface{}); ok {
for subK, subV := range incomingMap {
existingMap[subK] = subV
}
traits[k] = existingMap
} else {
traits[k] = incomingMap // New namespace
}
} else {
traits[k] = v // Fallback for flat metadata
}
} }
} }
// [LoginID Sync based on Tenant Settings]
// Perform sync AFTER metadata merge to ensure traits contains current values
syncCompCode := extractTraitString(traits, "companyCode")
if syncCompCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), syncCompCode); err == nil && tenant != nil {
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
syncLoginID(traits, req.Metadata, tenant.ID, loginIdField)
}
}
}
finalLoginID := extractTraitString(traits, "id")
userEmail := extractTraitString(traits, "email")
userPhone := extractTraitString(traits, "phone")
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
state := normalizeKratosState(req.Status) state := normalizeKratosState(req.Status)
slog.Info("[UpdateUser] Calling Kratos UpdateIdentity", "userID", userID, "traits", traits, "state", state)
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state) updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -1284,6 +1368,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,
@@ -1330,13 +1415,23 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
newRole = domain.NormalizeRole(newRole) newRole = domain.NormalizeRole(newRole)
oldRole = domain.NormalizeRole(oldRole) oldRole = domain.NormalizeRole(oldRole)
newTID := ""
if newTenantID != nil {
newTID = *newTenantID
}
if h.KetoOutboxRepo == nil { if h.KetoOutboxRepo == nil {
return return
} }
if oldRole == newRole && oldTenantID == newTID {
return // Nothing changed
}
// 1. Handle Role Changes // 1. Handle Role Changes
// Remove old roles
if oldRole == domain.RoleSuperAdmin { if oldRole == domain.RoleSuperAdmin {
// Only remove super_admin if the role actually changed (tenant change doesn't matter for global roles)
if oldRole != newRole {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System", Namespace: "System",
Object: "global", Object: "global",
@@ -1344,6 +1439,7 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
Subject: "User:" + userID, Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete, Action: domain.KetoOutboxActionDelete,
}) })
}
} else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" { } else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant", Namespace: "Tenant",
@@ -1356,6 +1452,7 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
// Add new roles // Add new roles
if newRole == domain.RoleSuperAdmin { if newRole == domain.RoleSuperAdmin {
if oldRole != newRole {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System", Namespace: "System",
Object: "global", Object: "global",
@@ -1363,10 +1460,11 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
Subject: "User:" + userID, Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate, Action: domain.KetoOutboxActionCreate,
}) })
} else if newRole == domain.RoleTenantAdmin && newTenantID != nil { }
} else if newRole == domain.RoleTenantAdmin && newTID != "" {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant", Namespace: "Tenant",
Object: *newTenantID, Object: newTID,
Relation: "admins", Relation: "admins",
Subject: "User:" + userID, Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate, Action: domain.KetoOutboxActionCreate,
@@ -1374,11 +1472,6 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
} }
// 2. Handle Tenant Membership (for count) // 2. Handle Tenant Membership (for count)
newTID := ""
if newTenantID != nil {
newTID = *newTenantID
}
if oldTenantID != newTID { if oldTenantID != newTID {
// Remove from old tenant // Remove from old tenant
if oldTenantID != "" { if oldTenantID != "" {
@@ -1415,6 +1508,57 @@ func extractTraitString(traits map[string]interface{}, key string) string {
return "" return ""
} }
// syncLoginID ensures that the 'id' trait (used as Kratos identifier) is in sync with the configured custom field.
func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) {
if loginIDField == "" || loginIDField == "id" {
return
}
var loginID string
// 1. Check incoming metadata (flat)
if val, ok := metadata[loginIDField].(string); ok && val != "" {
loginID = val
}
// 2. Check incoming metadata (namespaced by tenant ID)
if loginID == "" && tenantID != "" {
if namespaced, ok := metadata[tenantID].(map[string]any); ok {
if val, ok := namespaced[loginIDField].(string); ok && val != "" {
loginID = val
}
} else if namespaced, ok := metadata[tenantID].(map[string]interface{}); ok {
if val, ok := namespaced[loginIDField].(string); ok && val != "" {
loginID = val
}
}
}
// 3. Check merged traits (which includes existing metadata)
if loginID == "" {
// Existing trait (flat)
if val, ok := traits[loginIDField].(string); ok && val != "" {
loginID = val
} else if tenantID != "" {
// Existing trait (namespaced)
if namespaced, ok := traits[tenantID].(map[string]interface{}); ok {
if val, ok := namespaced[loginIDField].(string); ok && val != "" {
loginID = val
}
} else if namespaced, ok := traits[tenantID].(map[string]any); ok {
if val, ok := namespaced[loginIDField].(string); ok && val != "" {
loginID = val
}
}
}
}
if loginID != "" {
slog.Info("Syncing LoginID from custom field", "field", loginIDField, "value", loginID, "tenantID", tenantID)
traits["id"] = loginID
}
}
func formatTime(value time.Time) string { func formatTime(value time.Time) string {
if value.IsZero() { if value.IsZero() {
return "" return ""

View File

@@ -87,6 +87,14 @@ func (m *MockTenantServiceForUser) GetTenantBySlug(ctx context.Context, slug str
return args.Get(0).(*domain.Tenant), args.Error(1) return args.Get(0).(*domain.Tenant), args.Error(1)
} }
func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
args := m.Called(ctx, userID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.Tenant), args.Error(1)
}
// --- Tests --- // --- Tests ---
func TestUserHandler_BulkCreateUsers(t *testing.T) { func TestUserHandler_BulkCreateUsers(t *testing.T) {
@@ -353,3 +361,194 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
assert.Contains(t, result["error"].(string), "field salary is admin only") assert.Contains(t, result["error"].(string), "field salary is admin only")
}) })
} }
func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
tenantID := "t-123"
userID := "u-1"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "user@test.com",
"companyCode": "test-tenant",
"tenant_id": tenantID,
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"loginIdField": "emp_no",
"userSchema": []interface{}{
map[string]interface{}{"key": "emp_no", "label": "Employee No"},
},
},
}, nil) // Allow multiple calls for validation and sync
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
// Expect traits to include 'id' synced from 'emp_no'
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["id"] == "E1001"
}), mock.Anything).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"id": "E1001",
"email": "user@test.com",
},
}, nil).Once()
payload := map[string]interface{}{
"metadata": map[string]interface{}{
tenantID: map[string]interface{}{
"emp_no": "E1001",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
mockKratos.AssertExpectations(t)
})
t.Run("Success - Sync LoginID from existing traits when not in metadata", func(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
tenantID := "t-123"
userID := "u-2"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "user2@test.com",
"companyCode": "test-tenant",
"tenant_id": tenantID,
"id": "old-id",
tenantID: map[string]interface{}{
"emp_no": "E2002",
},
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"loginIdField": "emp_no",
},
}, nil)
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
// Even if metadata is empty, it should sync from existing traits
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["id"] == "E2002"
}), mock.Anything).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"id": "E2002",
},
}, nil).Once()
payload := map[string]interface{}{
"name": "New Name",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
mockKratos.AssertExpectations(t)
})
}
func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users", h.CreateUser)
tenantID := "t-123"
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: tenantID,
Slug: "test-tenant",
Config: domain.JSONMap{
"loginIdField": "emp_no",
"userSchema": []interface{}{
map[string]interface{}{"key": "emp_no", "label": "Employee No"},
},
},
}, nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
// Expect OryProvider.CreateUser to be called with attributes["id"] synced from metadata
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.LoginID == "E1001" && user.Attributes["id"] == "E1001"
}), mock.Anything).Return("u-1", nil).Once()
// Mock GetIdentity after creation
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
ID: "u-1",
Traits: map[string]interface{}{
"id": "E1001",
"email": "new@test.com",
"companyCode": "test-tenant",
},
}, nil).Once()
// Mock ListManageableTenants for mapIdentitySummary
mockTenant.On("ListManageableTenants", mock.Anything, "u-1").Return([]domain.Tenant{}, nil).Once()
payload := map[string]interface{}{
"email": "new@test.com",
"name": "New User",
"companyCode": "test-tenant",
"metadata": map[string]interface{}{
tenantID: map[string]interface{}{
"emp_no": "E1001",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 201, resp.StatusCode)
mockOry.AssertExpectations(t)
})
}

View File

@@ -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
} }

View File

@@ -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",

View 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"`
// ... 기타 필드
}
```

View 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 등)을 코드 수정 없이 유연하게 수용할 수 있습니다.
* 이메일, 전화번호 외의 추가적인 로그인 수단을 제공하여 사용자 편의성을 높입니다.

View File

@@ -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"
@@ -1765,3 +1771,83 @@ action = "Go to sign-in"
[ui.admin.tenants.profile.form] [ui.admin.tenants.profile.form]
parent = "Parent Tenant (Optional)" parent = "Parent Tenant (Optional)"
parent_help = "Select a parent tenant if this is a subsidiary or sub-organization." parent_help = "Select a parent tenant if this is a subsidiary or sub-organization."
[msg.admin.users.create.form]
login_id_help = ""
[msg.admin.users.detail]
delete_confirm = ""
delete_error = ""
delete_success = ""
reset_password_confirm = ""
[msg.admin.users.detail.form]
invalid_format = ""
[ui.admin.tenants.schema.field]
is_login_id = ""
[ui.admin.users.create.form]
login_id = ""
login_id_placeholder = ""
[ui.admin.users.detail]
contact_title = ""
created_at = ""
delete = ""
go_list = ""
password_title = ""
reset_password = ""
reset_password_label = ""
save = ""
status_title = ""
[ui.admin.users.detail.form]
job_title = ""
job_title_placeholder = ""
login_id = ""
login_id_placeholder = ""
position = ""
position_placeholder = ""
role_super_admin = ""
role_tenant_admin = ""
role_user = ""
status_active = ""
status_inactive = ""
[ui.admin.users.list.table]
login_id = ""
[msg.admin.users.create.form]
login_id_help = ""
[msg.admin.users.detail]
delete_confirm = ""
delete_error = ""
delete_success = ""
reset_password_confirm = ""
[msg.admin.users.detail.form]
invalid_format = ""
[ui.admin.users.detail]
contact_title = ""
created_at = ""
delete = ""
go_list = ""
password_title = ""
reset_password = ""
reset_password_label = ""
save = ""
status_title = ""
[ui.admin.users.detail.form]
job_title = ""
job_title_placeholder = ""
position = ""
position_placeholder = ""
role_super_admin = ""
role_tenant_admin = ""
role_user = ""
status_active = ""
status_inactive = ""

View File

@@ -1532,6 +1532,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"
@@ -1564,6 +1565,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 = "이름"
@@ -1590,6 +1593,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 = "전화번호"
@@ -1626,6 +1631,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"
@@ -1725,3 +1731,55 @@ description = "설명"
mandatory = "필수" mandatory = "필수"
name = "스코프 이름" name = "스코프 이름"
delete = "삭제" delete = "삭제"
login_id_help = ""
reset_password_confirm = ""
invalid_format = ""
contact_title = ""
password_title = ""
reset_password = ""
reset_password_label = ""
status_title = ""
role_super_admin = ""
role_tenant_admin = ""
role_user = ""
status_active = ""
status_inactive = ""
[msg.admin.users.create.form]
login_id_help = ""
[msg.admin.users.detail]
delete_confirm = ""
delete_error = ""
delete_success = ""
reset_password_confirm = ""
[msg.admin.users.detail.form]
invalid_format = ""
[ui.admin.users.detail]
contact_title = ""
created_at = ""
delete = ""
go_list = ""
password_title = ""
reset_password = ""
reset_password_label = ""
save = ""
status_title = ""
[ui.admin.users.detail.form]
job_title = ""
job_title_placeholder = ""
position = ""
position_placeholder = ""
role_super_admin = ""
role_tenant_admin = ""
role_user = ""
status_active = ""
status_inactive = ""

View File

@@ -1725,3 +1725,49 @@ description = ""
mandatory = "" mandatory = ""
name = "" name = ""
delete = "" delete = ""
[msg.admin.users.create.form]
login_id_help = ""
[msg.admin.users.detail]
delete_confirm = ""
delete_error = ""
delete_success = ""
reset_password_confirm = ""
[msg.admin.users.detail.form]
invalid_format = ""
[ui.admin.tenants.schema.field]
is_login_id = ""
[ui.admin.users.create.form]
login_id = ""
login_id_placeholder = ""
[ui.admin.users.detail]
contact_title = ""
created_at = ""
delete = ""
go_list = ""
password_title = ""
reset_password = ""
reset_password_label = ""
save = ""
status_title = ""
[ui.admin.users.detail.form]
job_title = ""
job_title_placeholder = ""
login_id = ""
login_id_placeholder = ""
position = ""
position_placeholder = ""
role_super_admin = ""
role_tenant_admin = ""
role_user = ""
status_active = ""
status_inactive = ""
[ui.admin.users.list.table]
login_id = ""

View File

@@ -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,
@@ -880,6 +893,36 @@ class AuthProxyService {
return false; return false;
} }
static Future<Map<String, dynamic>> checkLoginIDAvailability(
String loginId, {
String? companyCode,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/check-login-id');
final bodyData = {'loginId': loginId};
if (companyCode != null && companyCode.isNotEmpty) {
bodyData['companyCode'] = companyCode;
}
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(bodyData),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return {
'available': data['available'] ?? false,
'message': data['message'],
};
} else {
final data = jsonDecode(response.body);
return {
'available': false,
'message': data['message'] ?? 'Failed to check ID',
};
}
}
static Future<void> sendSignupCode(String target, String type) async { static Future<void> sendSignupCode(String target, String type) async {
final path = type == 'email' ? 'send-email-code' : 'send-sms-code'; final path = type == 'email' ? 'send-email-code' : 'send-sms-code';
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path'); final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path');
@@ -921,6 +964,7 @@ class AuthProxyService {
static Future<void> signup({ static Future<void> signup({
required String email, required String email,
String? loginId,
required String password, required String password,
required String name, required String name,
required String phone, required String phone,
@@ -936,6 +980,7 @@ class AuthProxyService {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({
'email': email, 'email': email,
if (loginId != null && loginId.isNotEmpty) 'loginId': loginId,
'password': password, 'password': password,
'name': name, 'name': name,
'phone': phone, 'phone': phone,

View File

@@ -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,9 @@ 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(),

View File

@@ -31,6 +31,7 @@ class _SignupScreenState extends State<SignupScreen> {
// Controllers // Controllers
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _loginIdController = TextEditingController();
final _emailCodeController = TextEditingController(); final _emailCodeController = TextEditingController();
final _phoneController = TextEditingController(); final _phoneController = TextEditingController();
final _phoneCodeController = TextEditingController(); final _phoneCodeController = TextEditingController();
@@ -58,6 +59,8 @@ class _SignupScreenState extends State<SignupScreen> {
String? _phoneError; String? _phoneError;
String? _passwordError; String? _passwordError;
String? _confirmPasswordError; String? _confirmPasswordError;
String? _loginIdError;
String? _loginIdSuccess;
// Timers // Timers
Timer? _emailTimer; Timer? _emailTimer;
@@ -98,6 +101,7 @@ class _SignupScreenState extends State<SignupScreen> {
_emailTimer?.cancel(); _emailTimer?.cancel();
_phoneTimer?.cancel(); _phoneTimer?.cancel();
_emailController.dispose(); _emailController.dispose();
_loginIdController.dispose();
_emailCodeController.dispose(); _emailCodeController.dispose();
_phoneController.dispose(); _phoneController.dispose();
_phoneCodeController.dispose(); _phoneCodeController.dispose();
@@ -311,6 +315,7 @@ class _SignupScreenState extends State<SignupScreen> {
try { try {
await AuthProxyService.signup( await AuthProxyService.signup(
email: _emailController.text.trim(), email: _emailController.text.trim(),
loginId: _loginIdController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
name: _nameController.text.trim(), name: _nameController.text.trim(),
phone: _phoneController.text.trim(), phone: _phoneController.text.trim(),
@@ -1421,6 +1426,94 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
_buildProfileFieldGroup(
title: '로그인 ID (선택)',
description: '이메일/전화번호 외에 별도의 식별자로 로그인할 때 사용합니다.',
isDesktop: isDesktop,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _loginIdController,
onChanged: (val) {
setState(() {
_loginIdError = null;
_loginIdSuccess = null;
});
},
decoration: InputDecoration(
labelText: '사번 또는 아이디',
border: const OutlineInputBorder(),
errorText: _loginIdError,
suffixIcon: TextButton(
onPressed: _isLoading
? null
: () async {
final loginId = _loginIdController.text
.trim();
if (loginId.isEmpty) {
setState(
() => _loginIdError = 'ID를 입력해주세요.',
);
return;
}
setState(() {
_isLoading = true;
_loginIdError = null;
_loginIdSuccess = null;
});
try {
final result =
await AuthProxyService.checkLoginIDAvailability(
loginId,
companyCode:
_affiliationType ==
'AFFILIATE'
? _companyCode
: null,
);
setState(() {
if (result['available'] == true) {
_loginIdSuccess = '사용 가능한 ID입니다.';
} else {
_loginIdError =
result['message'] ??
'사용할 수 없는 ID입니다.';
}
});
} catch (e) {
setState(
() => _loginIdError = e
.toString()
.replaceAll('Exception: ', ''),
);
} finally {
if (mounted)
setState(() => _isLoading = false);
}
},
child: const Text('중복 확인'),
),
),
),
if (_loginIdSuccess != null)
Padding(
padding: const EdgeInsets.only(
top: 8.0,
left: 12.0,
),
child: Text(
_loginIdSuccess!,
style: const TextStyle(
color: Colors.green,
fontSize: 12,
),
),
),
],
),
),
const SizedBox(height: 18),
_buildProfileFieldGroup( _buildProfileFieldGroup(
title: tr('ui.userfront.signup.profile.affiliation_type'), title: tr('ui.userfront.signup.profile.affiliation_type'),
description: _isAffiliateEmail description: _isAffiliateEmail