1
0
forked from baron/baron-sso

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

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

View File

@@ -117,7 +117,10 @@ function AppLayout() {
};
useEffect(() => {
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]);

View File

@@ -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", "오류가 발생했습니다."),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -194,6 +194,35 @@ func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"available": true})
}
// 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, "프로필 업데이트에 실패했습니다.")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
# 커스텀 필드 기반 로그인 ID 연동 - DB 설계 문서
## 1. 개요
본 문서는 사용자(User) 정보에 범용 로그인 ID(`login_id`)를 추가하고, 이를 테넌트별 설정에 따라 커스텀 필드와 동기화하기 위한 **데이터베이스(DB) 관점의 설계 변경 사항**을 명세합니다.
## 2. DB 스키마 변경 사항
### 2.1. 대상 테이블: `users`
현재 백엔드(PostgreSQL)의 `users` 테이블에 `login_id` 컬럼을 추가합니다.
| 컬럼명 | 타입 | 제약 조건 | 설명 |
| :--- | :--- | :--- | :--- |
| `login_id` | `VARCHAR(255)` | `NULL` 허용 | 범용 로그인 식별자 (사번, 학번 등) |
#### 인덱스(Index) 설정
단순 `unique`가 아닌, **테넌트 내 고유성**을 보장하기 위해 `tenant_id`와 조합된 복합 유니크 인덱스를 생성합니다.
* **인덱스명**: `idx_tenant_login_id`
* **구성 컬럼**: `(tenant_id, login_id)`
* **효과**:
* 서로 다른 테넌트 간에는 동일한 `login_id`가 존재할 수 있습니다.
* 동일 테넌트 내에서는 중복된 `login_id`를 가질 수 없습니다.
### 2.2. 대상 테이블: `tenants`
`tenants` 테이블의 `config` (JSONB) 컬럼에 매핑 설정을 추가합니다.
* **설정 키**: `loginIdField`
* **설명**: 사용자의 `metadata` (커스텀 필드) 중 어떤 필드의 값을 `login_id`로 동기화할지 결정하는 필드 키 이름입니다.
* **예시**: `"loginIdField": "emp_no"`
## 3. 데이터 흐름 및 동기화
### 3.1. 사용자 생성/수정 (Sync Flow)
1. 사용자 생성/수정 시 전달된 `metadata`와 테넌트의 `config.loginIdField`를 확인합니다.
2. `metadata` 내에 해당 키의 값이 존재하면, 그 값을 `users.login_id` 컬럼에 저장합니다.
3. 동시에 Ory Kratos의 `traits.id` 필드에도 해당 값을 업데이트하여 Kratos를 통한 로그인을 가능하게 합니다.
### 3.2. 대량 업로드 (Bulk Import)
1. CSV/Excel 파일의 컬럼 중 테넌트에서 지정한 커스텀 필드 컬럼(예: 사번)을 파싱합니다.
2. 위의 동기화 로직과 동일하게 `login_id` 컬럼과 Kratos Traits를 업데이트합니다.
## 4. GORM 모델 반영 (Go)
```go
type User struct {
ID string `gorm:"primaryKey;type:uuid"`
Email string `gorm:"uniqueIndex;not null"`
LoginID string `gorm:"column:login_id;uniqueIndex:idx_tenant_login_id"`
TenantID *string `gorm:"column:tenant_id;type:uuid;uniqueIndex:idx_tenant_login_id"`
// ... 기타 필드
}
```

View File

@@ -0,0 +1,47 @@
# 커스텀 필드 기반 로그인 ID 연동 설계 문서 (구 사번 로그인)
## 1. 개요
기존에 고정된 사번(`employee_id`) 필드를 사용하려던 설계를 변경하여, 테넌트별로 지정한 **커스텀 필드**를 실제 로그인 식별자로 사용할 수 있도록 하는 범용적인 로그인 ID 체계를 구축합니다.
## 2. 시스템별 변경 사항
### 2.1. Ory Kratos (인증 서버)
* **Identity Schema**: `traits` 내에 `id` 필드를 추가합니다.
* **Identifier 설정**: `traits.id``credentials.password.identifier`로 설정하여 로그인 시 식별자로 사용할 수 있게 합니다.
* **특징**: 이 필드는 '사번', '학번', 'ID' 등 테넌트의 성격에 따라 다양한 용도로 활용되는 범용 식별자 역할을 합니다.
### 2.2. Backend (baron-sso-backend)
#### 데이터 모델 (`domain.User`)
* `login_id` (string) 컬럼 추가.
* `idx_tenant_login_id` 복합 유니크 인덱스 생성: `(tenant_id, login_id)`.
#### 테넌트 설정 (`domain.Tenant`)
* `Config` 내에 `loginIdField` 설정을 추가합니다.
* 이 설정은 해당 테넌트의 `userSchema` 중 어떤 필드(key)가 로그인 ID로 사용될지를 저장합니다.
#### 동기화 로직 (`UserHandler`)
* **사용자 생성/수정 시**:
1. 테넌트의 `loginIdField` 설정을 조회합니다.
2. 설정된 필드가 있다면 사용자의 `Metadata`에서 해당 값을 추출합니다.
3. 추출된 값을 Kratos의 `traits.id`와 로컬 DB의 `login_id` 컬럼에 동기화합니다.
* **대량 등록 (Bulk Import)**: CSV/JSON 업로드 시에도 동일한 동기화 로직이 적용됩니다.
### 2.3. Frontend (adminfront)
#### 테넌트 스키마 관리 (`TenantSchemaPage`)
* 커스텀 필드 정의 시 "로그인 ID로 사용" 체크박스를 추가합니다.
* 이 체크박스를 선택하면 해당 필드의 `key`가 테넌트 `Config.loginIdField`에 저장됩니다.
#### 사용자 관리 (`UserCreatePage`, `UserDetailPage`)
* 기본 정보 영역에 "로그인 ID" 필드를 노출하여 직접 관리할 수 있게 합니다.
### 2.4. Frontend (userfront)
#### 로그인 페이지 (`LoginScreen`)
* URL의 `companyCode` 또는 도메인을 통해 테넌트를 식별합니다.
* 해당 테넌트에 `loginIdField`가 설정되어 있다면, 로그인 입력란의 라벨을 해당 커스텀 필드의 라벨(예: "사번")로 동적으로 변경합니다.
## 3. 기대 효과
* 테넌트별로 상이한 로그인 식별자 요구사항(사번, 학생번호, 커스텀 ID 등)을 코드 수정 없이 유연하게 수용할 수 있습니다.
* 이메일, 전화번호 외의 추가적인 로그인 수단을 제공하여 사용자 편의성을 높입니다.

View File

@@ -1031,6 +1031,7 @@ key_placeholder = "e.g. employee_id"
label = "Display Label"
label_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 = ""

View File

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

View File

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

View File

@@ -110,6 +110,19 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> getTenantInfo() async {
final url = Uri.parse('$_baseUrl/api/v1/auth/tenant-info');
final response = await http.get(url);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw _error(
'err.userfront.auth_proxy.tenant_info_fetch',
'테넌트 정보를 불러오지 못했습니다.',
);
}
}
static Future<Map<String, dynamic>> initEnchantedLink(
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,

View File

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

View File

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