forked from baron/baron-sso
Implement tenant import and RP auto login policies
This commit is contained in:
@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
|
||||
import { Building2, Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -17,6 +16,11 @@ import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import {
|
||||
formatDomainConflictMessage,
|
||||
type ServerDomainConflict,
|
||||
} from "../utils/domainTags";
|
||||
|
||||
function TenantCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -26,7 +30,10 @@ function TenantCreatePage() {
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 1000 }],
|
||||
@@ -34,7 +41,7 @@ function TenantCreatePage() {
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (overrideForceDomains?: string[]) =>
|
||||
createTenant({
|
||||
name,
|
||||
type,
|
||||
@@ -42,14 +49,34 @@ function TenantCreatePage() {
|
||||
parentId: parentId || undefined,
|
||||
description: description || undefined,
|
||||
status,
|
||||
domains: domains
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== ""),
|
||||
domains,
|
||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
navigate("/tenants");
|
||||
},
|
||||
onError: (
|
||||
err: AxiosError<{
|
||||
code?: string;
|
||||
error?: string;
|
||||
conflicts?: ServerDomainConflict[];
|
||||
}>,
|
||||
) => {
|
||||
const conflicts = err.response?.data?.conflicts ?? [];
|
||||
if (
|
||||
err.response?.data?.code === "tenant_domain_conflict" &&
|
||||
conflicts.length > 0
|
||||
) {
|
||||
const nextForceDomains = Array.from(
|
||||
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
|
||||
);
|
||||
const message = conflicts.map(formatDomainConflictMessage).join("\n");
|
||||
if (window.confirm(message)) {
|
||||
setForceDomainConflicts(nextForceDomains);
|
||||
mutation.mutate(nextForceDomains);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||
@@ -195,11 +222,13 @@ function TenantCreatePage() {
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
<DomainTagInput
|
||||
id="tenant-domains"
|
||||
name="domains"
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
onChange={setDomains}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.domains_placeholder",
|
||||
"example.com, example.kr",
|
||||
@@ -268,7 +297,7 @@ function TenantCreatePage() {
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
onClick={() => mutation.mutate(undefined)}
|
||||
disabled={mutation.isPending || name.trim() === ""}
|
||||
>
|
||||
{t("ui.common.create", "생성")}
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
type TenantImportResolution,
|
||||
type TenantImportPreviewRow,
|
||||
buildTenantImportPreview,
|
||||
parseTenantCSV,
|
||||
@@ -58,7 +59,7 @@ import {
|
||||
} from "../utils/tenantCsvImport";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n";
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -72,6 +73,9 @@ function TenantListPage() {
|
||||
const [selectedMatches, setSelectedMatches] = React.useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [selectedCreateSlugs, setSelectedCreateSlugs] = React.useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
@@ -117,7 +121,7 @@ function TenantListPage() {
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: exportTenantsCSV,
|
||||
mutationFn: (includeIds: boolean) => exportTenantsCSV(includeIds),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -265,16 +269,44 @@ function TenantListPage() {
|
||||
setPreviewRows(preview);
|
||||
setSelectedMatches(
|
||||
Object.fromEntries(
|
||||
preview
|
||||
.filter((row) => row.defaultTenantId)
|
||||
.map((row) => [row.row.rowNumber, row.defaultTenantId]),
|
||||
preview.map((row) => [
|
||||
row.row.rowNumber,
|
||||
row.defaultTenantId || "__create__",
|
||||
]),
|
||||
),
|
||||
);
|
||||
setSelectedCreateSlugs(
|
||||
Object.fromEntries(
|
||||
preview.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
|
||||
),
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
const csv = serializeTenantImportCSV(previewRows, selectedMatches);
|
||||
const resolutions: Record<number, TenantImportResolution> =
|
||||
Object.fromEntries(
|
||||
previewRows.map((preview) => {
|
||||
const selected = selectedMatches[preview.row.rowNumber] ?? "";
|
||||
if (selected && selected !== "__create__") {
|
||||
return [
|
||||
preview.row.rowNumber,
|
||||
{ mode: "existing", tenantId: selected },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
preview.row.rowNumber,
|
||||
{
|
||||
mode: "create",
|
||||
slug:
|
||||
selectedCreateSlugs[preview.row.rowNumber] ||
|
||||
preview.defaultCreateSlug,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
const csv = serializeTenantImportCSV(previewRows, resolutions);
|
||||
const file = new File([csv], "tenants.csv", { type: "text/csv" });
|
||||
importMutation.mutate(file);
|
||||
};
|
||||
@@ -343,12 +375,21 @@ function TenantListPage() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => exportMutation.mutate()}
|
||||
onClick={() => exportMutation.mutate(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-btn"
|
||||
>
|
||||
<Download size={16} />
|
||||
{t("ui.admin.tenants.export", "내보내기")}
|
||||
{t("ui.admin.tenants.export_without_ids", "UUID 제외 내보내기")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => exportMutation.mutate(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-with-ids-btn"
|
||||
>
|
||||
<Download size={16} />
|
||||
{t("ui.admin.tenants.export_with_ids", "UUID 포함")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -622,19 +663,41 @@ function TenantListPage() {
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{preview.row.slug}
|
||||
{preview.conflicts.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{preview.conflicts.map((conflict) => (
|
||||
<Badge
|
||||
key={conflict}
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{conflict === "external_tenant_id"
|
||||
? t(
|
||||
"ui.admin.tenants.import_preview.external_id",
|
||||
"외부 ID",
|
||||
)
|
||||
: conflict === "slug_exists"
|
||||
? t(
|
||||
"ui.admin.tenants.import_preview.slug_exists",
|
||||
"slug 충돌",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.import_preview.parent_unresolved",
|
||||
"부모 확인 필요",
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{preview.row.tenantId ? (
|
||||
<Badge variant="outline">
|
||||
{t(
|
||||
"ui.admin.tenants.import_preview.fixed_id",
|
||||
"ID 지정됨",
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={selectedMatches[preview.row.rowNumber] ?? ""}
|
||||
value={
|
||||
selectedMatches[preview.row.rowNumber] ??
|
||||
"__create__"
|
||||
}
|
||||
data-testid={`tenant-import-match-select-${preview.row.rowNumber}`}
|
||||
onChange={(event) =>
|
||||
setSelectedMatches((prev) => ({
|
||||
@@ -643,10 +706,10 @@ function TenantListPage() {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
<option value="__create__">
|
||||
{t(
|
||||
"ui.admin.tenants.import_preview.create_new",
|
||||
"신규 생성",
|
||||
"ui.admin.tenants.import_preview.create_new_reset",
|
||||
"신규 생성 (ID/slug 재설정)",
|
||||
)}
|
||||
</option>
|
||||
{preview.candidates.map((candidate) => (
|
||||
@@ -658,7 +721,22 @@ function TenantListPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{(selectedMatches[preview.row.rowNumber] ??
|
||||
"__create__") === "__create__" && (
|
||||
<Input
|
||||
value={
|
||||
selectedCreateSlugs[preview.row.rowNumber] ?? ""
|
||||
}
|
||||
data-testid={`tenant-import-create-slug-${preview.row.rowNumber}`}
|
||||
onChange={(event) =>
|
||||
setSelectedCreateSlugs((prev) => ({
|
||||
...prev,
|
||||
[preview.row.rowNumber]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{preview.candidates.length > 0 ? (
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import {
|
||||
formatDomainConflictMessage,
|
||||
type ServerDomainConflict,
|
||||
} from "../utils/domainTags";
|
||||
|
||||
export function TenantProfilePage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
@@ -53,7 +58,10 @@ export function TenantProfilePage() {
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [parentId, setParentId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,13 +71,14 @@ export function TenantProfilePage() {
|
||||
setSlug(tenantQuery.data.slug);
|
||||
setDescription(tenantQuery.data.description ?? "");
|
||||
setStatus(tenantQuery.data.status);
|
||||
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||
setDomains(tenantQuery.data.domains ?? []);
|
||||
setForceDomainConflicts([]);
|
||||
setParentId(tenantQuery.data.parentId ?? "");
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (overrideForceDomains?: string[]) =>
|
||||
updateTenant(tenantId, {
|
||||
name,
|
||||
type,
|
||||
@@ -77,17 +86,36 @@ export function TenantProfilePage() {
|
||||
description: description || undefined,
|
||||
status,
|
||||
parentId: parentId || undefined,
|
||||
domains: domains
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== ""),
|
||||
domains,
|
||||
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
onError: (
|
||||
err: AxiosError<{
|
||||
code?: string;
|
||||
error?: string;
|
||||
conflicts?: ServerDomainConflict[];
|
||||
}>,
|
||||
) => {
|
||||
const conflicts = err.response?.data?.conflicts ?? [];
|
||||
if (
|
||||
err.response?.data?.code === "tenant_domain_conflict" &&
|
||||
conflicts.length > 0
|
||||
) {
|
||||
const nextForceDomains = Array.from(
|
||||
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
|
||||
);
|
||||
const message = conflicts.map(formatDomainConflictMessage).join("\n");
|
||||
if (window.confirm(message)) {
|
||||
setForceDomainConflicts(nextForceDomains);
|
||||
updateMutation.mutate(nextForceDomains);
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("err.common.unknown", "오류가 발생했습니다."),
|
||||
@@ -257,9 +285,14 @@ export function TenantProfilePage() {
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
<DomainTagInput
|
||||
id="tenant-domains"
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
onChange={setDomains}
|
||||
tenants={parentQuery.data?.items ?? []}
|
||||
currentTenantId={tenantId}
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder="example.com, example.kr"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -322,7 +355,7 @@ export function TenantProfilePage() {
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
onClick={() => updateMutation.mutate(undefined)}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
tenantQuery.isLoading ||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
|
||||
|
||||
describe("TenantSchemaPage schema field helpers", () => {
|
||||
it("creates text fields without varchar maxLength policy", () => {
|
||||
const field = createSchemaField();
|
||||
|
||||
expect(field.type).toBe("text");
|
||||
expect("maxLength" in field).toBe(false);
|
||||
expect(field.indexed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not add maxLength to legacy text schema fields", () => {
|
||||
const field = normalizeSchemaField({
|
||||
key: "emp_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
});
|
||||
|
||||
expect("maxLength" in field).toBe(false);
|
||||
});
|
||||
|
||||
it("forces indexed when a field can be used as login ID", () => {
|
||||
const field = normalizeSchemaField({
|
||||
key: "emp_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
indexed: false,
|
||||
isLoginId: true,
|
||||
});
|
||||
|
||||
expect(field.indexed).toBe(true);
|
||||
expect(field.isLoginId).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type SchemaFieldType =
|
||||
export type SchemaFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
@@ -25,7 +25,7 @@ type SchemaFieldType =
|
||||
| "float"
|
||||
| "datetime";
|
||||
|
||||
type SchemaField = {
|
||||
export type SchemaField = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -35,6 +35,7 @@ type SchemaField = {
|
||||
validation?: string;
|
||||
unsigned?: boolean;
|
||||
isLoginId?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
@@ -44,6 +45,54 @@ function createFieldId() {
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
|
||||
return (
|
||||
value === "text" ||
|
||||
value === "number" ||
|
||||
value === "boolean" ||
|
||||
value === "date" ||
|
||||
value === "float" ||
|
||||
value === "datetime"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaField(field: unknown): SchemaField {
|
||||
const source =
|
||||
typeof field === "object" && field !== null
|
||||
? (field as Record<string, unknown>)
|
||||
: {};
|
||||
const type = isSchemaFieldType(source.type) ? source.type : "text";
|
||||
const isLoginId = Boolean(source.isLoginId);
|
||||
|
||||
return {
|
||||
id: typeof source.id === "string" ? source.id : createFieldId(),
|
||||
key: typeof source.key === "string" ? source.key : "",
|
||||
label: typeof source.label === "string" ? source.label : "",
|
||||
type,
|
||||
required: Boolean(source.required),
|
||||
adminOnly: Boolean(source.adminOnly),
|
||||
validation:
|
||||
typeof source.validation === "string" ? source.validation : "",
|
||||
unsigned: Boolean(source.unsigned),
|
||||
isLoginId,
|
||||
indexed: isLoginId || Boolean(source.indexed),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSchemaField(): SchemaField {
|
||||
return {
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
indexed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -71,27 +120,7 @@ export function TenantSchemaPage() {
|
||||
const rawSchema = tenantQuery.data?.config?.userSchema;
|
||||
|
||||
if (Array.isArray(rawSchema)) {
|
||||
setFields(
|
||||
rawSchema.map((field) => ({
|
||||
id: typeof field?.id === "string" ? field.id : createFieldId(),
|
||||
key: typeof field?.key === "string" ? field.key : "",
|
||||
label: typeof field?.label === "string" ? field.label : "",
|
||||
type:
|
||||
field?.type === "number" ||
|
||||
field?.type === "boolean" ||
|
||||
field?.type === "date" ||
|
||||
field?.type === "float" ||
|
||||
field?.type === "datetime"
|
||||
? field.type
|
||||
: "text",
|
||||
required: Boolean(field?.required),
|
||||
adminOnly: Boolean(field?.adminOnly),
|
||||
validation:
|
||||
typeof field?.validation === "string" ? field.validation : "",
|
||||
unsigned: Boolean(field?.unsigned),
|
||||
isLoginId: Boolean(field?.isLoginId),
|
||||
})),
|
||||
);
|
||||
setFields(rawSchema.map(normalizeSchemaField));
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
@@ -158,19 +187,7 @@ export function TenantSchemaPage() {
|
||||
}
|
||||
|
||||
const addField = () => {
|
||||
setFields([
|
||||
...fields,
|
||||
{
|
||||
id: createFieldId(),
|
||||
key: "",
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
unsigned: false,
|
||||
},
|
||||
]);
|
||||
setFields([...fields, createSchemaField()]);
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
@@ -261,16 +278,15 @@ export function TenantSchemaPage() {
|
||||
value={field.type}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
if (
|
||||
nextType === "text" ||
|
||||
nextType === "number" ||
|
||||
nextType === "boolean" ||
|
||||
nextType === "date" ||
|
||||
nextType === "float" ||
|
||||
nextType === "datetime"
|
||||
) {
|
||||
if (isSchemaFieldType(nextType)) {
|
||||
updateField(index, {
|
||||
type: nextType as SchemaFieldType,
|
||||
type: nextType,
|
||||
isLoginId:
|
||||
nextType === "text" ? field.isLoginId : false,
|
||||
indexed:
|
||||
nextType === "text"
|
||||
? field.indexed || field.isLoginId || false
|
||||
: field.indexed,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -351,7 +367,11 @@ export function TenantSchemaPage() {
|
||||
type="checkbox"
|
||||
checked={field.isLoginId || false}
|
||||
onChange={(e) =>
|
||||
updateField(index, { isLoginId: e.target.checked })
|
||||
updateField(index, {
|
||||
isLoginId: e.target.checked,
|
||||
indexed: e.target.checked ? true : field.indexed,
|
||||
type: e.target.checked ? "text" : field.type,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
@@ -362,6 +382,23 @@ export function TenantSchemaPage() {
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.indexed || field.isLoginId || false}
|
||||
disabled={field.isLoginId}
|
||||
onChange={(e) =>
|
||||
updateField(index, { indexed: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.indexed",
|
||||
"검색 인덱스 필요",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{(field.type === "number" || field.type === "float") && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user