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(() => {
|
||||
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");
|
||||
}
|
||||
}, [auth.isLoading, auth.isAuthenticated, navigate]);
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type TenantAdmin,
|
||||
addTenantAdmin,
|
||||
addTenantOwner,
|
||||
fetchTenantAdmins,
|
||||
@@ -82,14 +83,54 @@ export function TenantAdminsAndOwnersTab() {
|
||||
|
||||
const addOwnerMutation = useMutation({
|
||||
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: () => {
|
||||
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(
|
||||
t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."),
|
||||
);
|
||||
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(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
@@ -99,8 +140,26 @@ export function TenantAdminsAndOwnersTab() {
|
||||
|
||||
const removeOwnerMutation = useMutation({
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] });
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tenant-owners", tenantId],
|
||||
});
|
||||
}, 1000);
|
||||
toast.success(
|
||||
t(
|
||||
"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(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
@@ -118,14 +183,52 @@ export function TenantAdminsAndOwnersTab() {
|
||||
|
||||
const addAdminMutation = useMutation({
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tenant-admins", tenantId],
|
||||
});
|
||||
}, 1000);
|
||||
toast.success(
|
||||
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
|
||||
);
|
||||
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(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
@@ -135,13 +238,37 @@ export function TenantAdminsAndOwnersTab() {
|
||||
|
||||
const removeAdminMutation = useMutation({
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tenant-admins", tenantId],
|
||||
});
|
||||
}, 1000);
|
||||
toast.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(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
|
||||
@@ -34,6 +34,7 @@ type SchemaField = {
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
@@ -96,6 +97,8 @@ export function TenantSchemaPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const rawSchema = tenantQuery.data?.config?.userSchema;
|
||||
const loginIdField = tenantQuery.data?.config?.loginIdField;
|
||||
|
||||
if (Array.isArray(rawSchema)) {
|
||||
setFields(
|
||||
rawSchema.map((field) => ({
|
||||
@@ -115,19 +118,23 @@ export function TenantSchemaPage() {
|
||||
validation:
|
||||
typeof field?.validation === "string" ? field.validation : "",
|
||||
unsigned: Boolean(field?.unsigned),
|
||||
isLoginId: field?.key === loginIdField,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (newFields: SchemaField[]) =>
|
||||
updateTenant(tenantId, {
|
||||
mutationFn: (newFields: SchemaField[]) => {
|
||||
const loginIdField = newFields.find((f) => f.isLoginId)?.key || "";
|
||||
return updateTenant(tenantId, {
|
||||
config: {
|
||||
...tenantQuery.data?.config,
|
||||
userSchema: newFields,
|
||||
loginIdField: loginIdField,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
toast.success(
|
||||
@@ -334,6 +341,26 @@ export function TenantSchemaPage() {
|
||||
)}
|
||||
</span>
|
||||
</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") && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -65,6 +65,7 @@ function UserCreatePage() {
|
||||
} = useForm<UserFormValues>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
loginId: "",
|
||||
password: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
@@ -273,6 +274,26 @@ function UserCreatePage() {
|
||||
)}
|
||||
</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="flex items-center justify-between">
|
||||
<Label htmlFor="password">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -430,6 +430,9 @@ function UserListPage() {
|
||||
"NAME / EMAIL",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.login_id", "LOGIN ID")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
@@ -511,6 +514,11 @@ function UserListPage() {
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono">
|
||||
{user.loginId || "-"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||
|
||||
@@ -348,6 +348,7 @@ export async function deleteApiKey(apiKeyId: string) {
|
||||
export type UserSummary = {
|
||||
id: string;
|
||||
email: string;
|
||||
loginId?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
@@ -372,6 +373,7 @@ export type UserListResponse = {
|
||||
|
||||
export type UserCreateRequest = {
|
||||
email: string;
|
||||
loginId?: string;
|
||||
password?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
@@ -388,6 +390,7 @@ export type UserCreateResponse = UserSummary & {
|
||||
};
|
||||
|
||||
export type UserUpdateRequest = {
|
||||
loginId?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
@@ -402,6 +405,7 @@ export type UserUpdateRequest = {
|
||||
|
||||
export type BulkUserItem = {
|
||||
email: string;
|
||||
loginId?: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
|
||||
@@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
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]
|
||||
field_required = "Required."
|
||||
invalid_format = "Invalid format."
|
||||
name_required = "Name Required"
|
||||
|
||||
[msg.admin.users.detail.security]
|
||||
|
||||
@@ -283,6 +283,7 @@ update_success = "사용자 정보가 수정되었습니다."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "필수입니다."
|
||||
invalid_format = "형식이 올바르지 않습니다."
|
||||
name_required = "이름은 필수입니다."
|
||||
|
||||
[msg.admin.users.detail.security]
|
||||
|
||||
@@ -283,6 +283,7 @@ update_success = ""
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = ""
|
||||
invalid_format = ""
|
||||
name_required = ""
|
||||
|
||||
[msg.admin.users.detail.security]
|
||||
|
||||
@@ -83,7 +83,7 @@ test.describe("Tenant Owners Management", () => {
|
||||
|
||||
test("should list tenant owners", async ({ page }) => {
|
||||
await page.goto("/tenants/tenant-1/permissions");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page.locator(".animate-spin").first()).not.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.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page.locator(".animate-spin").first()).not.toBeVisible();
|
||||
|
||||
await page.click(
|
||||
|
||||
@@ -7,38 +7,64 @@ test.describe("User Schema Dynamic Form", () => {
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
id_token: "fake-id-token",
|
||||
access_token: "fake-token",
|
||||
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,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
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 }
|
||||
)._IS_TEST_MODE = true;
|
||||
});
|
||||
|
||||
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 page.route(/.*\/api\/v1\/.*/, async (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes("/user/me")) {
|
||||
console.log("Mocking ME");
|
||||
console.log("Mocking /user/me");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin",
|
||||
email: "admin@test.com",
|
||||
role: "super_admin",
|
||||
manageableTenants: [],
|
||||
manageableTenants: [
|
||||
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/admin/tenants/t-1")) {
|
||||
console.log("Mocking /admin/tenants/t-1");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "t-1",
|
||||
@@ -65,6 +91,7 @@ test.describe("User Schema Dynamic Form", () => {
|
||||
}
|
||||
|
||||
if (url.includes("/admin/users/u-1")) {
|
||||
console.log("Mocking /admin/users/u-1");
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "u-1",
|
||||
@@ -81,14 +108,38 @@ test.describe("User Schema Dynamic Form", () => {
|
||||
}
|
||||
|
||||
if (url.includes("/admin/tenants")) {
|
||||
console.log("Mocking /admin/tenants");
|
||||
return route.fulfill({
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Mocking default empty list for:", url);
|
||||
return route.fulfill({ json: { items: [], total: 0 } });
|
||||
});
|
||||
});
|
||||
@@ -97,7 +148,6 @@ test.describe("User Schema Dynamic Form", () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/users/u-1");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// 섹션 헤더 확인
|
||||
const header = page
|
||||
@@ -122,7 +172,6 @@ test.describe("User Schema Dynamic Form", () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/users/u-1");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const empIdInput = page.locator('input[id*="emp_id"]');
|
||||
await empIdInput.waitFor({ state: "visible" });
|
||||
|
||||
@@ -549,6 +549,7 @@ func main() {
|
||||
// Signup Routes
|
||||
signup := auth.Group("/signup")
|
||||
signup.Post("/check-email", authHandler.CheckEmail)
|
||||
signup.Post("/check-login-id", authHandler.CheckLoginID)
|
||||
signup.Post("/send-email-code", authHandler.SendSignupEmailCode)
|
||||
signup.Post("/send-sms-code", authHandler.SendSignupSmsCode)
|
||||
signup.Post("/verify-code", authHandler.VerifySignupCode)
|
||||
|
||||
@@ -55,6 +55,7 @@ type VerifySignupCodeRequest struct {
|
||||
|
||||
type SignupRequest struct {
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId,omitempty"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
@@ -70,6 +71,7 @@ type SignupRequest struct {
|
||||
type UserProfileResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"` // 추가
|
||||
@@ -85,10 +87,11 @@ type UserProfileResponse struct {
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Department string `json:"department"`
|
||||
VerificationCode string `json:"verificationCode,omitempty"` // For phone change
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Department string `json:"department"`
|
||||
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.
|
||||
@@ -109,3 +112,8 @@ type PasswordChangeRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
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 {
|
||||
ID string `json:"id" required:"true"`
|
||||
Email string `json:"email" required:"true"`
|
||||
LoginID string `json:"login_id"`
|
||||
Name string `json:"name"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
// Attributes stores custom user attributes.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -34,13 +35,14 @@ func NormalizeRole(role string) string {
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
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:"-"`
|
||||
Name string `gorm:"column:name;not null" json:"name"`
|
||||
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
|
||||
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
|
||||
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"`
|
||||
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||
Department string `gorm:"column:department" json:"department"`
|
||||
@@ -60,3 +62,63 @@ func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// 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
|
||||
func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error {
|
||||
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")
|
||||
}
|
||||
|
||||
// Check Code match
|
||||
if state.Code != req.Code {
|
||||
// Check Code match (Allow magic code 000000 in non-production environments)
|
||||
isMagicCodeAllowed := service.IsDryRunAllowed() && req.Code == "000000"
|
||||
if state.Code != req.Code && !isMagicCodeAllowed {
|
||||
state.FailCount++
|
||||
h.saveSignupState(key, state, signupStateExpiration)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
@@ -451,8 +481,28 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
// grade는 기존 스키마 필수 키이므로 기본값을 설정
|
||||
"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{
|
||||
Email: req.Email,
|
||||
LoginID: finalLoginID,
|
||||
Name: req.Name,
|
||||
PhoneNumber: normalizedPhone,
|
||||
Attributes: attributes,
|
||||
@@ -577,14 +627,31 @@ func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusNotFound, "Tenant not found")
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
res := fiber.Map{
|
||||
"isCentral": false,
|
||||
"id": tenant.ID,
|
||||
"name": tenant.Name,
|
||||
"slug": tenant.Slug,
|
||||
"description": tenant.Description,
|
||||
"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)로 정규화합니다.
|
||||
@@ -3988,8 +4055,8 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
if token != "" {
|
||||
profile, err = h.getKratosProfile(token)
|
||||
if err != nil && h.Hydra != nil {
|
||||
// Fallback to Hydra introspection
|
||||
slog.Debug("Kratos session check failed, trying Hydra", "error", err)
|
||||
// Fallback to Hydra introspection. This is expected for API calls using Bearer tokens.
|
||||
slog.Debug("Kratos cookie session absent, falling back to Hydra token", "error", err.Error())
|
||||
profile, err = h.getHydraProfile(c.Context(), token)
|
||||
}
|
||||
} else if cookie != "" {
|
||||
@@ -4002,9 +4069,11 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
if profile != nil {
|
||||
if isDev && mockRole != "" {
|
||||
normalizedMockRole := domain.NormalizeRole(mockRole)
|
||||
slog.Info("🔑 [AUTH] Overriding real profile role",
|
||||
"email", profile.Email, "originalRole", profile.Role, "overriddenRole", normalizedMockRole)
|
||||
profile.Role = normalizedMockRole
|
||||
if profile.Role != normalizedMockRole {
|
||||
slog.Info("🔑 [AUTH] Overriding real profile role",
|
||||
"email", profile.Email, "originalRole", profile.Role, "overriddenRole", normalizedMockRole)
|
||||
profile.Role = normalizedMockRole
|
||||
}
|
||||
}
|
||||
} else if isDev && mockRole != "" && token == "" && cookie == "" {
|
||||
normalizedMockRole := domain.NormalizeRole(mockRole)
|
||||
@@ -5249,6 +5318,46 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
||||
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 {
|
||||
slog.Error("Failed to update profile in Kratos", "error", err)
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.")
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestVerifySignupCode_Invalid(t *testing.T) {
|
||||
verifyBody := map[string]string{
|
||||
"type": "email",
|
||||
"target": "user@test.com",
|
||||
"code": "000000", // wrong code
|
||||
"code": "222222", // wrong code
|
||||
}
|
||||
body, _ := json.Marshal(verifyBody)
|
||||
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}
|
||||
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, 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)
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
@@ -660,6 +667,13 @@ func (h *TenantHandler) AddOwner(c *fiber.Ctx) error {
|
||||
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 {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
|
||||
@@ -258,6 +258,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
@@ -321,11 +322,21 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
"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
|
||||
if req.CompanyCode != "" && h.TenantService != nil {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
|
||||
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
|
||||
@@ -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{
|
||||
Email: email,
|
||||
LoginID: finalLoginID,
|
||||
Name: name,
|
||||
PhoneNumber: normalizePhoneNumber(req.Phone),
|
||||
Attributes: attributes,
|
||||
@@ -413,6 +430,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
|
||||
type bulkUserItem struct {
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
@@ -456,9 +474,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
|
||||
// Pre-fetch tenant data to avoid redundant DB calls
|
||||
type tenantCacheItem struct {
|
||||
ID string
|
||||
Schema []interface{}
|
||||
Groups []domain.UserGroup
|
||||
ID string
|
||||
Schema []interface{}
|
||||
Groups []domain.UserGroup
|
||||
LoginIDField string
|
||||
}
|
||||
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 {
|
||||
tItem.Schema = s
|
||||
}
|
||||
if lf, ok := tenant.Config["loginIdField"].(string); ok {
|
||||
tItem.LoginIDField = lf
|
||||
}
|
||||
// [Fix] Cache user groups for this tenant to match department
|
||||
if h.UserGroupRepo != 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,
|
||||
}
|
||||
|
||||
// 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
|
||||
for k, v := range item.Metadata {
|
||||
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{
|
||||
Email: email,
|
||||
Email: userEmail,
|
||||
LoginID: finalLoginID,
|
||||
Name: item.Name,
|
||||
PhoneNumber: normalizePhoneNumber(item.Phone),
|
||||
PhoneNumber: userPhone,
|
||||
Attributes: attributes,
|
||||
}, password)
|
||||
if err != nil {
|
||||
@@ -571,6 +613,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
localUser := &domain.User{
|
||||
ID: identityID,
|
||||
Email: email,
|
||||
LoginID: extractTraitString(attributes, "id"),
|
||||
Name: name,
|
||||
Phone: normalizePhoneNumber(item.Phone),
|
||||
Role: role,
|
||||
@@ -1014,6 +1057,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
LoginID *string `json:"loginId"`
|
||||
Password *string `json:"password"`
|
||||
Name *string `json:"name"`
|
||||
Phone *string `json:"phone"`
|
||||
@@ -1113,23 +1157,63 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
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]
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "department": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true,
|
||||
"id": true,
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
|
||||
slog.Info("[UpdateUser] Calling Kratos UpdateIdentity", "userID", userID, "traits", traits, "state", state)
|
||||
|
||||
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
@@ -1284,6 +1368,7 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
||||
user := &domain.User{
|
||||
ID: identity.ID,
|
||||
Email: extractTraitString(traits, "email"),
|
||||
LoginID: extractTraitString(traits, "id"),
|
||||
Name: extractTraitString(traits, "name"),
|
||||
Phone: extractTraitString(traits, "phone_number"),
|
||||
Role: role,
|
||||
@@ -1330,20 +1415,31 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
|
||||
newRole = domain.NormalizeRole(newRole)
|
||||
oldRole = domain.NormalizeRole(oldRole)
|
||||
|
||||
newTID := ""
|
||||
if newTenantID != nil {
|
||||
newTID = *newTenantID
|
||||
}
|
||||
|
||||
if h.KetoOutboxRepo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if oldRole == newRole && oldTenantID == newTID {
|
||||
return // Nothing changed
|
||||
}
|
||||
|
||||
// 1. Handle Role Changes
|
||||
// Remove old roles
|
||||
if oldRole == domain.RoleSuperAdmin {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
// 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{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
} else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
@@ -1356,17 +1452,19 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
|
||||
|
||||
// Add new roles
|
||||
if newRole == domain.RoleSuperAdmin {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
} else if newRole == domain.RoleTenantAdmin && newTenantID != nil {
|
||||
if oldRole != newRole {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
} else if newRole == domain.RoleTenantAdmin && newTID != "" {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *newTenantID,
|
||||
Object: newTID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
@@ -1374,11 +1472,6 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
|
||||
}
|
||||
|
||||
// 2. Handle Tenant Membership (for count)
|
||||
newTID := ""
|
||||
if newTenantID != nil {
|
||||
newTID = *newTenantID
|
||||
}
|
||||
|
||||
if oldTenantID != newTID {
|
||||
// Remove from old tenant
|
||||
if oldTenantID != "" {
|
||||
@@ -1415,6 +1508,57 @@ func extractTraitString(traits map[string]interface{}, key string) string {
|
||||
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 {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
|
||||
@@ -87,6 +87,14 @@ func (m *MockTenantServiceForUser) GetTenantBySlug(ctx context.Context, slug str
|
||||
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 ---
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
return &domain.IDPMetadata{
|
||||
SupportedFields: []string{
|
||||
"id", "email", "name", "phone_number",
|
||||
"id", "login_id", "email", "name", "phone_number",
|
||||
"grade", "department", "affiliationType", "companyCode",
|
||||
},
|
||||
}, nil
|
||||
@@ -64,6 +64,17 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
if existingID != "" {
|
||||
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 != "" {
|
||||
existingPhoneID, err := o.findIdentityID(user.PhoneNumber)
|
||||
if err != nil {
|
||||
@@ -78,6 +89,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
}
|
||||
if user.LoginID != "" {
|
||||
traits["id"] = user.LoginID
|
||||
}
|
||||
if user.PhoneNumber != "" {
|
||||
traits["phone_number"] = user.PhoneNumber
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "ID",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"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_placeholder = "Label Placeholder"
|
||||
required = "Required"
|
||||
is_login_id = "Use as Login ID"
|
||||
type = "Type"
|
||||
type_boolean = "Boolean"
|
||||
type_date = "Date"
|
||||
@@ -1104,6 +1105,8 @@ department = "Department"
|
||||
department_placeholder = "Department Placeholder"
|
||||
email = "Email"
|
||||
email_placeholder = "user@example.com"
|
||||
login_id = "Login ID (Optional)"
|
||||
login_id_placeholder = "Employee ID or ID"
|
||||
job_title = "Job Title"
|
||||
job_title_placeholder = "e.g. Frontend Developer"
|
||||
name = "Name"
|
||||
@@ -1136,6 +1139,8 @@ multi_title = "Per-tenant Profile Management"
|
||||
[ui.admin.users.detail.form]
|
||||
department = "Department"
|
||||
department_placeholder = "Department Placeholder"
|
||||
login_id = "Login ID"
|
||||
login_id_placeholder = "Employee ID or Username"
|
||||
name = "Name"
|
||||
name_placeholder = "Name Placeholder"
|
||||
phone = "Phone number"
|
||||
@@ -1181,6 +1186,7 @@ title = "User Registry"
|
||||
[ui.admin.users.list.table]
|
||||
actions = "ACTIONS"
|
||||
created = "CREATED"
|
||||
login_id = "LOGIN ID"
|
||||
name_email = "NAME / EMAIL"
|
||||
role = "ROLE"
|
||||
status = "STATUS"
|
||||
@@ -1765,3 +1771,83 @@ action = "Go to sign-in"
|
||||
[ui.admin.tenants.profile.form]
|
||||
parent = "Parent Tenant (Optional)"
|
||||
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_placeholder = "예: 사번"
|
||||
required = "필수 여부"
|
||||
is_login_id = "로그인 ID로 사용"
|
||||
type = "타입"
|
||||
type_boolean = "Boolean"
|
||||
type_date = "Date"
|
||||
@@ -1564,6 +1565,8 @@ department = "부서"
|
||||
department_placeholder = "개발팀"
|
||||
email = "이메일"
|
||||
email_placeholder = "user@example.com"
|
||||
login_id = "로그인 ID (선택)"
|
||||
login_id_placeholder = "사번 또는 아이디"
|
||||
job_title = "직무"
|
||||
job_title_placeholder = "프론트엔드 개발"
|
||||
name = "이름"
|
||||
@@ -1590,6 +1593,8 @@ multi_title = "테넌트별 프로필 관리"
|
||||
[ui.admin.users.detail.form]
|
||||
department = "부서"
|
||||
department_placeholder = "개발팀"
|
||||
login_id = "로그인 ID"
|
||||
login_id_placeholder = "사번 또는 아이디"
|
||||
name = "이름"
|
||||
name_placeholder = "홍길동"
|
||||
phone = "전화번호"
|
||||
@@ -1626,6 +1631,7 @@ title = "사용자 레지스트리"
|
||||
[ui.admin.users.list.table]
|
||||
actions = "ACTIONS"
|
||||
created = "CREATED"
|
||||
login_id = "LOGIN ID"
|
||||
name_email = "NAME / EMAIL"
|
||||
role = "ROLE"
|
||||
status = "STATUS"
|
||||
@@ -1725,3 +1731,55 @@ description = "설명"
|
||||
mandatory = "필수"
|
||||
name = "스코프 이름"
|
||||
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 = ""
|
||||
name = ""
|
||||
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(
|
||||
String loginId, {
|
||||
String? method,
|
||||
@@ -880,6 +893,36 @@ class AuthProxyService {
|
||||
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 {
|
||||
final path = type == 'email' ? 'send-email-code' : 'send-sms-code';
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path');
|
||||
@@ -921,6 +964,7 @@ class AuthProxyService {
|
||||
|
||||
static Future<void> signup({
|
||||
required String email,
|
||||
String? loginId,
|
||||
required String password,
|
||||
required String name,
|
||||
required String phone,
|
||||
@@ -936,6 +980,7 @@ class AuthProxyService {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'email': email,
|
||||
if (loginId != null && loginId.isNotEmpty) 'loginId': loginId,
|
||||
'password': password,
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
|
||||
@@ -48,6 +48,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
String? _redirectUrl;
|
||||
String? _loginChallenge;
|
||||
bool _isPasswordCapsLockOn = false;
|
||||
String? _loginIdLabel;
|
||||
|
||||
// QR Login Variables
|
||||
String? _qrImageBase64;
|
||||
@@ -150,6 +151,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (!_verificationOnly) {
|
||||
await _attemptOidcAutoAccept();
|
||||
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에서 이미 쿠키 세션까지 확인하므로
|
||||
// 동일 프레임에서 중복 체크를 피합니다.
|
||||
if (!_hasLoginChallenge) {
|
||||
@@ -1456,9 +1470,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
controller: _passwordLoginIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr(
|
||||
'ui.userfront.login.field.login_id',
|
||||
),
|
||||
labelText:
|
||||
_loginIdLabel ??
|
||||
tr(
|
||||
'ui.userfront.login.field.login_id',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(
|
||||
Icons.person_outline,
|
||||
|
||||
@@ -31,6 +31,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
|
||||
// Controllers
|
||||
final _emailController = TextEditingController();
|
||||
final _loginIdController = TextEditingController();
|
||||
final _emailCodeController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _phoneCodeController = TextEditingController();
|
||||
@@ -58,6 +59,8 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
String? _phoneError;
|
||||
String? _passwordError;
|
||||
String? _confirmPasswordError;
|
||||
String? _loginIdError;
|
||||
String? _loginIdSuccess;
|
||||
|
||||
// Timers
|
||||
Timer? _emailTimer;
|
||||
@@ -98,6 +101,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
_emailTimer?.cancel();
|
||||
_phoneTimer?.cancel();
|
||||
_emailController.dispose();
|
||||
_loginIdController.dispose();
|
||||
_emailCodeController.dispose();
|
||||
_phoneController.dispose();
|
||||
_phoneCodeController.dispose();
|
||||
@@ -311,6 +315,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
try {
|
||||
await AuthProxyService.signup(
|
||||
email: _emailController.text.trim(),
|
||||
loginId: _loginIdController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
name: _nameController.text.trim(),
|
||||
phone: _phoneController.text.trim(),
|
||||
@@ -1421,6 +1426,94 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
),
|
||||
),
|
||||
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(
|
||||
title: tr('ui.userfront.signup.profile.affiliation_type'),
|
||||
description: _isAffiliateEmail
|
||||
|
||||
Reference in New Issue
Block a user