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:
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user