1
0
forked from baron/baron-sso

feat: implement multi-identifier architecture (Issue #496)

- Database: Add user_login_ids table for 1:N identifier mapping and remove legacy login_id column
- Kratos: Update identity schema to use custom_login_ids array instead of a single id trait
- Backend: Implement syncCustomLoginIDs to collect isLoginId fields across tenant schemas
- Backend: Add backtracking logic to auto-assign session tenant based on used login identifier
- Backend: Add 409 Conflict exception handling for Create/Update operations
- AdminFront: Refactor UserDetailPage to a tabbed grid layout (Info, Tenants, Security)
- AdminFront: Show '로그인 ID' badge on tenant schema fields used for authentication
- UserFront: Remove legacy optional 'Login ID' input from signup flow
- Tests: Add multi-identifier repository tests and update handler tests
This commit is contained in:
2026-04-02 16:07:33 +09:00
parent 71a006cd7b
commit b582c82c6f
25 changed files with 1154 additions and 1160 deletions

View File

@@ -97,7 +97,6 @@ export function TenantSchemaPage() {
useEffect(() => {
const rawSchema = tenantQuery.data?.config?.userSchema;
const loginIdField = tenantQuery.data?.config?.loginIdField;
if (Array.isArray(rawSchema)) {
setFields(
@@ -118,7 +117,7 @@ export function TenantSchemaPage() {
validation:
typeof field?.validation === "string" ? field.validation : "",
unsigned: Boolean(field?.unsigned),
isLoginId: field?.key === loginIdField,
isLoginId: Boolean(field?.isLoginId),
})),
);
}
@@ -126,13 +125,13 @@ export function TenantSchemaPage() {
const updateMutation = useMutation({
mutationFn: (newFields: SchemaField[]) => {
const loginIdField = newFields.find((f) => f.isLoginId)?.key || "";
// Remove legacy loginIdField, keep isLoginId natively in userSchema
const newConfig = { ...tenantQuery.data?.config };
delete newConfig.loginIdField;
newConfig.userSchema = newFields;
return updateTenant(tenantId, {
config: {
...tenantQuery.data?.config,
userSchema: newFields,
loginIdField: loginIdField,
},
config: newConfig,
});
},
onSuccess: () => {
@@ -344,14 +343,10 @@ export function TenantSchemaPage() {
<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);
}}
checked={field.isLoginId || false}
onChange={(e) =>
updateField(index, { isLoginId: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium text-blue-600">

View File

@@ -31,6 +31,7 @@ type UserSchemaField = {
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
};
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
@@ -65,7 +66,6 @@ function UserCreatePage() {
} = useForm<UserFormValues>({
defaultValues: {
email: "",
loginId: "",
password: "",
name: "",
phone: "",
@@ -274,26 +274,6 @@ 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">
@@ -470,6 +450,11 @@ function UserCreatePage() {
Admin Only
</span>
)}
{field.isLoginId && (
<span className="ml-2 text-[10px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
{t("ui.admin.users.create.form.is_login_id", "로그인 ID")}
</span>
)}
</Label>
<Input

File diff suppressed because it is too large Load Diff

View File

@@ -430,9 +430,6 @@ 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>
@@ -514,11 +511,6 @@ 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

@@ -549,6 +549,20 @@ export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`);
}
export type UserRpHistoryItem = {
client_id: string;
client_name: string;
lastLoginAt: string;
status: string;
};
export async function fetchUserRpHistory(userId: string) {
const { data } = await apiClient.get<UserRpHistoryItem[]>(
`/v1/admin/users/${userId}/rp-history`,
);
return data;
}
export type UserProfileResponse = {
id: string;
email: string;