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:
@@ -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]);
|
||||||
|
|||||||
@@ -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", "오류가 발생했습니다."),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type SchemaField = {
|
|||||||
adminOnly: boolean;
|
adminOnly: boolean;
|
||||||
validation?: string;
|
validation?: string;
|
||||||
unsigned?: boolean;
|
unsigned?: boolean;
|
||||||
|
isLoginId?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createFieldId() {
|
function createFieldId() {
|
||||||
@@ -96,6 +97,8 @@ export function TenantSchemaPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rawSchema = tenantQuery.data?.config?.userSchema;
|
const rawSchema = tenantQuery.data?.config?.userSchema;
|
||||||
|
const loginIdField = tenantQuery.data?.config?.loginIdField;
|
||||||
|
|
||||||
if (Array.isArray(rawSchema)) {
|
if (Array.isArray(rawSchema)) {
|
||||||
setFields(
|
setFields(
|
||||||
rawSchema.map((field) => ({
|
rawSchema.map((field) => ({
|
||||||
@@ -115,19 +118,23 @@ export function TenantSchemaPage() {
|
|||||||
validation:
|
validation:
|
||||||
typeof field?.validation === "string" ? field.validation : "",
|
typeof field?.validation === "string" ? field.validation : "",
|
||||||
unsigned: Boolean(field?.unsigned),
|
unsigned: Boolean(field?.unsigned),
|
||||||
|
isLoginId: field?.key === loginIdField,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [tenantQuery.data]);
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (newFields: SchemaField[]) =>
|
mutationFn: (newFields: SchemaField[]) => {
|
||||||
updateTenant(tenantId, {
|
const loginIdField = newFields.find((f) => f.isLoginId)?.key || "";
|
||||||
|
return updateTenant(tenantId, {
|
||||||
config: {
|
config: {
|
||||||
...tenantQuery.data?.config,
|
...tenantQuery.data?.config,
|
||||||
userSchema: newFields,
|
userSchema: newFields,
|
||||||
|
loginIdField: loginIdField,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -334,6 +341,26 @@ export function TenantSchemaPage() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.isLoginId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newFields = fields.map((f, i) => ({
|
||||||
|
...f,
|
||||||
|
isLoginId: i === index ? e.target.checked : false,
|
||||||
|
}));
|
||||||
|
setFields(newFields);
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-blue-600">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.schema.field.is_login_id",
|
||||||
|
"로그인 ID로 사용",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
{(field.type === "number" || field.type === "float") && (
|
{(field.type === "number" || field.type === "float") && (
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function UserCreatePage() {
|
|||||||
} = useForm<UserFormValues>({
|
} = useForm<UserFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "",
|
||||||
|
loginId: "",
|
||||||
password: "",
|
password: "",
|
||||||
name: "",
|
name: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
@@ -273,6 +274,26 @@ function UserCreatePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="loginId">
|
||||||
|
{t("ui.admin.users.create.form.login_id", "로그인 ID (선택)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="loginId"
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.create.form.login_id_placeholder",
|
||||||
|
"사번 또는 아이디",
|
||||||
|
)}
|
||||||
|
{...register("loginId")}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.users.create.form.login_id_help",
|
||||||
|
"이메일/전화번호 외에 별도의 식별자로 로그인할 때 사용합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">
|
<Label htmlFor="password">
|
||||||
|
|||||||
@@ -5,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,6 +430,9 @@ function UserListPage() {
|
|||||||
"NAME / EMAIL",
|
"NAME / EMAIL",
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.users.list.table.login_id", "LOGIN ID")}
|
||||||
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -511,6 +514,11 @@ function UserListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{user.loginId || "-"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ export async function deleteApiKey(apiKeyId: string) {
|
|||||||
export type UserSummary = {
|
export type UserSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
loginId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -372,6 +373,7 @@ export type UserListResponse = {
|
|||||||
|
|
||||||
export type UserCreateRequest = {
|
export type UserCreateRequest = {
|
||||||
email: string;
|
email: string;
|
||||||
|
loginId?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@@ -388,6 +390,7 @@ export type UserCreateResponse = UserSummary & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type UserUpdateRequest = {
|
export type UserUpdateRequest = {
|
||||||
|
loginId?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@@ -402,6 +405,7 @@ export type UserUpdateRequest = {
|
|||||||
|
|
||||||
export type BulkUserItem = {
|
export type BulkUserItem = {
|
||||||
email: string;
|
email: string;
|
||||||
|
loginId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
40
backend/internal/domain/user_validate_test.go
Normal file
40
backend/internal/domain/user_validate_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "프로필 업데이트에 실패했습니다.")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (o *OryProvider) Name() string {
|
|||||||
func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||||
return &domain.IDPMetadata{
|
return &domain.IDPMetadata{
|
||||||
SupportedFields: []string{
|
SupportedFields: []string{
|
||||||
"id", "email", "name", "phone_number",
|
"id", "login_id", "email", "name", "phone_number",
|
||||||
"grade", "department", "affiliationType", "companyCode",
|
"grade", "department", "affiliationType", "companyCode",
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -64,6 +64,17 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
|||||||
if existingID != "" {
|
if existingID != "" {
|
||||||
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
|
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.LoginID != "" {
|
||||||
|
existingLoginID, err := o.findIdentityID(user.LoginID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
|
||||||
|
}
|
||||||
|
if existingLoginID != "" {
|
||||||
|
return "", fmt.Errorf("ory provider: identity already exists for login_id=%s", user.LoginID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if user.PhoneNumber != "" {
|
if user.PhoneNumber != "" {
|
||||||
existingPhoneID, err := o.findIdentityID(user.PhoneNumber)
|
existingPhoneID, err := o.findIdentityID(user.PhoneNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,6 +89,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
|||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"name": user.Name,
|
"name": user.Name,
|
||||||
}
|
}
|
||||||
|
if user.LoginID != "" {
|
||||||
|
traits["id"] = user.LoginID
|
||||||
|
}
|
||||||
if user.PhoneNumber != "" {
|
if user.PhoneNumber != "" {
|
||||||
traits["phone_number"] = user.PhoneNumber
|
traits["phone_number"] = user.PhoneNumber
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
"traits": {
|
"traits": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "ID",
|
||||||
|
"ory.sh/kratos": {
|
||||||
|
"credentials": {
|
||||||
|
"password": {
|
||||||
|
"identifier": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "email",
|
"format": "email",
|
||||||
|
|||||||
53
docs/employee_id_login_db_design.md
Normal file
53
docs/employee_id_login_db_design.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 커스텀 필드 기반 로그인 ID 연동 - DB 설계 문서
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
본 문서는 사용자(User) 정보에 범용 로그인 ID(`login_id`)를 추가하고, 이를 테넌트별 설정에 따라 커스텀 필드와 동기화하기 위한 **데이터베이스(DB) 관점의 설계 변경 사항**을 명세합니다.
|
||||||
|
|
||||||
|
## 2. DB 스키마 변경 사항
|
||||||
|
|
||||||
|
### 2.1. 대상 테이블: `users`
|
||||||
|
|
||||||
|
현재 백엔드(PostgreSQL)의 `users` 테이블에 `login_id` 컬럼을 추가합니다.
|
||||||
|
|
||||||
|
| 컬럼명 | 타입 | 제약 조건 | 설명 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `login_id` | `VARCHAR(255)` | `NULL` 허용 | 범용 로그인 식별자 (사번, 학번 등) |
|
||||||
|
|
||||||
|
#### 인덱스(Index) 설정
|
||||||
|
단순 `unique`가 아닌, **테넌트 내 고유성**을 보장하기 위해 `tenant_id`와 조합된 복합 유니크 인덱스를 생성합니다.
|
||||||
|
* **인덱스명**: `idx_tenant_login_id`
|
||||||
|
* **구성 컬럼**: `(tenant_id, login_id)`
|
||||||
|
* **효과**:
|
||||||
|
* 서로 다른 테넌트 간에는 동일한 `login_id`가 존재할 수 있습니다.
|
||||||
|
* 동일 테넌트 내에서는 중복된 `login_id`를 가질 수 없습니다.
|
||||||
|
|
||||||
|
### 2.2. 대상 테이블: `tenants`
|
||||||
|
|
||||||
|
`tenants` 테이블의 `config` (JSONB) 컬럼에 매핑 설정을 추가합니다.
|
||||||
|
|
||||||
|
* **설정 키**: `loginIdField`
|
||||||
|
* **설명**: 사용자의 `metadata` (커스텀 필드) 중 어떤 필드의 값을 `login_id`로 동기화할지 결정하는 필드 키 이름입니다.
|
||||||
|
* **예시**: `"loginIdField": "emp_no"`
|
||||||
|
|
||||||
|
## 3. 데이터 흐름 및 동기화
|
||||||
|
|
||||||
|
### 3.1. 사용자 생성/수정 (Sync Flow)
|
||||||
|
1. 사용자 생성/수정 시 전달된 `metadata`와 테넌트의 `config.loginIdField`를 확인합니다.
|
||||||
|
2. `metadata` 내에 해당 키의 값이 존재하면, 그 값을 `users.login_id` 컬럼에 저장합니다.
|
||||||
|
3. 동시에 Ory Kratos의 `traits.id` 필드에도 해당 값을 업데이트하여 Kratos를 통한 로그인을 가능하게 합니다.
|
||||||
|
|
||||||
|
### 3.2. 대량 업로드 (Bulk Import)
|
||||||
|
1. CSV/Excel 파일의 컬럼 중 테넌트에서 지정한 커스텀 필드 컬럼(예: 사번)을 파싱합니다.
|
||||||
|
2. 위의 동기화 로직과 동일하게 `login_id` 컬럼과 Kratos Traits를 업데이트합니다.
|
||||||
|
|
||||||
|
## 4. GORM 모델 반영 (Go)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
ID string `gorm:"primaryKey;type:uuid"`
|
||||||
|
Email string `gorm:"uniqueIndex;not null"`
|
||||||
|
LoginID string `gorm:"column:login_id;uniqueIndex:idx_tenant_login_id"`
|
||||||
|
TenantID *string `gorm:"column:tenant_id;type:uuid;uniqueIndex:idx_tenant_login_id"`
|
||||||
|
// ... 기타 필드
|
||||||
|
}
|
||||||
|
```
|
||||||
47
docs/employee_id_login_design.md
Normal file
47
docs/employee_id_login_design.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 커스텀 필드 기반 로그인 ID 연동 설계 문서 (구 사번 로그인)
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
기존에 고정된 사번(`employee_id`) 필드를 사용하려던 설계를 변경하여, 테넌트별로 지정한 **커스텀 필드**를 실제 로그인 식별자로 사용할 수 있도록 하는 범용적인 로그인 ID 체계를 구축합니다.
|
||||||
|
|
||||||
|
## 2. 시스템별 변경 사항
|
||||||
|
|
||||||
|
### 2.1. Ory Kratos (인증 서버)
|
||||||
|
* **Identity Schema**: `traits` 내에 `id` 필드를 추가합니다.
|
||||||
|
* **Identifier 설정**: `traits.id`를 `credentials.password.identifier`로 설정하여 로그인 시 식별자로 사용할 수 있게 합니다.
|
||||||
|
* **특징**: 이 필드는 '사번', '학번', 'ID' 등 테넌트의 성격에 따라 다양한 용도로 활용되는 범용 식별자 역할을 합니다.
|
||||||
|
|
||||||
|
### 2.2. Backend (baron-sso-backend)
|
||||||
|
|
||||||
|
#### 데이터 모델 (`domain.User`)
|
||||||
|
* `login_id` (string) 컬럼 추가.
|
||||||
|
* `idx_tenant_login_id` 복합 유니크 인덱스 생성: `(tenant_id, login_id)`.
|
||||||
|
|
||||||
|
#### 테넌트 설정 (`domain.Tenant`)
|
||||||
|
* `Config` 내에 `loginIdField` 설정을 추가합니다.
|
||||||
|
* 이 설정은 해당 테넌트의 `userSchema` 중 어떤 필드(key)가 로그인 ID로 사용될지를 저장합니다.
|
||||||
|
|
||||||
|
#### 동기화 로직 (`UserHandler`)
|
||||||
|
* **사용자 생성/수정 시**:
|
||||||
|
1. 테넌트의 `loginIdField` 설정을 조회합니다.
|
||||||
|
2. 설정된 필드가 있다면 사용자의 `Metadata`에서 해당 값을 추출합니다.
|
||||||
|
3. 추출된 값을 Kratos의 `traits.id`와 로컬 DB의 `login_id` 컬럼에 동기화합니다.
|
||||||
|
* **대량 등록 (Bulk Import)**: CSV/JSON 업로드 시에도 동일한 동기화 로직이 적용됩니다.
|
||||||
|
|
||||||
|
### 2.3. Frontend (adminfront)
|
||||||
|
|
||||||
|
#### 테넌트 스키마 관리 (`TenantSchemaPage`)
|
||||||
|
* 커스텀 필드 정의 시 "로그인 ID로 사용" 체크박스를 추가합니다.
|
||||||
|
* 이 체크박스를 선택하면 해당 필드의 `key`가 테넌트 `Config.loginIdField`에 저장됩니다.
|
||||||
|
|
||||||
|
#### 사용자 관리 (`UserCreatePage`, `UserDetailPage`)
|
||||||
|
* 기본 정보 영역에 "로그인 ID" 필드를 노출하여 직접 관리할 수 있게 합니다.
|
||||||
|
|
||||||
|
### 2.4. Frontend (userfront)
|
||||||
|
|
||||||
|
#### 로그인 페이지 (`LoginScreen`)
|
||||||
|
* URL의 `companyCode` 또는 도메인을 통해 테넌트를 식별합니다.
|
||||||
|
* 해당 테넌트에 `loginIdField`가 설정되어 있다면, 로그인 입력란의 라벨을 해당 커스텀 필드의 라벨(예: "사번")로 동적으로 변경합니다.
|
||||||
|
|
||||||
|
## 3. 기대 효과
|
||||||
|
* 테넌트별로 상이한 로그인 식별자 요구사항(사번, 학생번호, 커스텀 ID 등)을 코드 수정 없이 유연하게 수용할 수 있습니다.
|
||||||
|
* 이메일, 전화번호 외의 추가적인 로그인 수단을 제공하여 사용자 편의성을 높입니다.
|
||||||
@@ -1031,6 +1031,7 @@ key_placeholder = "e.g. employee_id"
|
|||||||
label = "Display Label"
|
label = "Display Label"
|
||||||
label_placeholder = "Label Placeholder"
|
label_placeholder = "Label Placeholder"
|
||||||
required = "Required"
|
required = "Required"
|
||||||
|
is_login_id = "Use as Login ID"
|
||||||
type = "Type"
|
type = "Type"
|
||||||
type_boolean = "Boolean"
|
type_boolean = "Boolean"
|
||||||
type_date = "Date"
|
type_date = "Date"
|
||||||
@@ -1104,6 +1105,8 @@ department = "Department"
|
|||||||
department_placeholder = "Department Placeholder"
|
department_placeholder = "Department Placeholder"
|
||||||
email = "Email"
|
email = "Email"
|
||||||
email_placeholder = "user@example.com"
|
email_placeholder = "user@example.com"
|
||||||
|
login_id = "Login ID (Optional)"
|
||||||
|
login_id_placeholder = "Employee ID or ID"
|
||||||
job_title = "Job Title"
|
job_title = "Job Title"
|
||||||
job_title_placeholder = "e.g. Frontend Developer"
|
job_title_placeholder = "e.g. Frontend Developer"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
@@ -1136,6 +1139,8 @@ multi_title = "Per-tenant Profile Management"
|
|||||||
[ui.admin.users.detail.form]
|
[ui.admin.users.detail.form]
|
||||||
department = "Department"
|
department = "Department"
|
||||||
department_placeholder = "Department Placeholder"
|
department_placeholder = "Department Placeholder"
|
||||||
|
login_id = "Login ID"
|
||||||
|
login_id_placeholder = "Employee ID or Username"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
name_placeholder = "Name Placeholder"
|
name_placeholder = "Name Placeholder"
|
||||||
phone = "Phone number"
|
phone = "Phone number"
|
||||||
@@ -1181,6 +1186,7 @@ title = "User Registry"
|
|||||||
[ui.admin.users.list.table]
|
[ui.admin.users.list.table]
|
||||||
actions = "ACTIONS"
|
actions = "ACTIONS"
|
||||||
created = "CREATED"
|
created = "CREATED"
|
||||||
|
login_id = "LOGIN ID"
|
||||||
name_email = "NAME / EMAIL"
|
name_email = "NAME / EMAIL"
|
||||||
role = "ROLE"
|
role = "ROLE"
|
||||||
status = "STATUS"
|
status = "STATUS"
|
||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user