forked from baron/baron-sso
린트 적용
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type * as React from "react";
|
||||||
import { fetchMe } from "../../lib/adminApi";
|
import { fetchMe } from "../../lib/adminApi";
|
||||||
|
|
||||||
interface RoleGuardProps {
|
interface RoleGuardProps {
|
||||||
@@ -10,13 +10,17 @@ interface RoleGuardProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* RoleGuard conditionally renders children based on the current user's role.
|
* RoleGuard conditionally renders children based on the current user's role.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* <RoleGuard roles={['super_admin']}>
|
* <RoleGuard roles={['super_admin']}>
|
||||||
* <button>System Only Action</button>
|
* <button>System Only Action</button>
|
||||||
* </RoleGuard>
|
* </RoleGuard>
|
||||||
*/
|
*/
|
||||||
export function RoleGuard({ children, roles, fallback = null }: RoleGuardProps) {
|
export function RoleGuard({
|
||||||
|
children,
|
||||||
|
roles,
|
||||||
|
fallback = null,
|
||||||
|
}: RoleGuardProps) {
|
||||||
const { data: profile, isLoading } = useQuery({
|
const { data: profile, isLoading } = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function AppLayout() {
|
|||||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||||
|
|
||||||
// Filter out restricted items for non-super admins
|
// Filter out restricted items for non-super admins
|
||||||
const filteredItems = items.filter(item => {
|
const filteredItems = items.filter((item) => {
|
||||||
if (item.to === "/api-keys") return isSuperAdmin;
|
if (item.to === "/api-keys") return isSuperAdmin;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
|
||||||
import PermissionChecker from "./components/PermissionChecker";
|
import PermissionChecker from "./components/PermissionChecker";
|
||||||
|
|
||||||
function GlobalOverviewPage() {
|
function GlobalOverviewPage() {
|
||||||
@@ -54,7 +54,9 @@ function GlobalOverviewPage() {
|
|||||||
<RoleGuard roles={["super_admin"]}>
|
<RoleGuard roles={["super_admin"]}>
|
||||||
<Card className="bg-[var(--color-panel)]">
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardDescription>{t("ui.admin.overview.summary.total_tenants", "Total Tenants")}</CardDescription>
|
<CardDescription>
|
||||||
|
{t("ui.admin.overview.summary.total_tenants", "Total Tenants")}
|
||||||
|
</CardDescription>
|
||||||
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
</div>
|
</div>
|
||||||
@@ -62,13 +64,18 @@ function GlobalOverviewPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-semibold">-</div>
|
<div className="text-2xl font-semibold">-</div>
|
||||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||||
{t("msg.admin.overview.summary.total_tenants", "Tenant-aware core")}
|
{t(
|
||||||
|
"msg.admin.overview.summary.total_tenants",
|
||||||
|
"Tenant-aware core",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-[var(--color-panel)]">
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardDescription>{t("ui.admin.overview.summary.oidc_clients", "OIDC Clients")}</CardDescription>
|
<CardDescription>
|
||||||
|
{t("ui.admin.overview.summary.oidc_clients", "OIDC Clients")}
|
||||||
|
</CardDescription>
|
||||||
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||||
<ShieldCheck size={16} />
|
<ShieldCheck size={16} />
|
||||||
</div>
|
</div>
|
||||||
@@ -81,10 +88,15 @@ function GlobalOverviewPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
|
|
||||||
<Card className="bg-[var(--color-panel)]">
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardDescription>{t("ui.admin.overview.summary.audit_events_24h", "Audit Events (24h)")}</CardDescription>
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"ui.admin.overview.summary.audit_events_24h",
|
||||||
|
"Audit Events (24h)",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||||
<Activity size={16} />
|
<Activity size={16} />
|
||||||
</div>
|
</div>
|
||||||
@@ -92,14 +104,19 @@ function GlobalOverviewPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-semibold">-</div>
|
<div className="text-2xl font-semibold">-</div>
|
||||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||||
{t("msg.admin.overview.summary.audit_events_24h", "ClickHouse stream")}
|
{t(
|
||||||
|
"msg.admin.overview.summary.audit_events_24h",
|
||||||
|
"ClickHouse stream",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-[var(--color-panel)]">
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardDescription>{t("ui.admin.overview.summary.policy_gate", "Policy Gate")}</CardDescription>
|
<CardDescription>
|
||||||
|
{t("ui.admin.overview.summary.policy_gate", "Policy Gate")}
|
||||||
|
</CardDescription>
|
||||||
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||||
<Database size={16} />
|
<Database size={16} />
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +124,10 @@ function GlobalOverviewPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-semibold">Planned</div>
|
<div className="text-2xl font-semibold">Planned</div>
|
||||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||||
{t("msg.admin.overview.summary.policy_gate", "Keto + Admin checks")}
|
{t(
|
||||||
|
"msg.admin.overview.summary.policy_gate",
|
||||||
|
"Keto + Admin checks",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AxiosError } from "axios";
|
|||||||
import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +28,6 @@ import {
|
|||||||
fetchTenants,
|
fetchTenants,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
|
||||||
|
|
||||||
function TenantListPage() {
|
function TenantListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -41,7 +41,10 @@ function TenantListPage() {
|
|||||||
if (profile?.role === "tenant_admin") {
|
if (profile?.role === "tenant_admin") {
|
||||||
const manageableCount = profile.manageableTenants?.length ?? 0;
|
const manageableCount = profile.manageableTenants?.length ?? 0;
|
||||||
// If only 1 in array, OR array is empty but we have a primary tenantId
|
// If only 1 in array, OR array is empty but we have a primary tenantId
|
||||||
if ((manageableCount === 1 || manageableCount === 0) && profile.tenantId) {
|
if (
|
||||||
|
(manageableCount === 1 || manageableCount === 0) &&
|
||||||
|
profile.tenantId
|
||||||
|
) {
|
||||||
navigate(`/tenants/${profile.tenantId}`, { replace: true });
|
navigate(`/tenants/${profile.tenantId}`, { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +53,10 @@ function TenantListPage() {
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["tenants", { limit: 1000, offset: 0 }],
|
queryKey: ["tenants", { limit: 1000, offset: 0 }],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchTenants(1000, 0),
|
||||||
enabled: profile?.role === "super_admin" || (profile?.role === "tenant_admin" && (profile.manageableTenants?.length ?? 0) > 1),
|
enabled:
|
||||||
|
profile?.role === "super_admin" ||
|
||||||
|
(profile?.role === "tenant_admin" &&
|
||||||
|
(profile.manageableTenants?.length ?? 0) > 1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -60,17 +66,28 @@ function TenantListPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (profile && profile.role !== "super_admin" && profile.role !== "tenant_admin") {
|
if (
|
||||||
|
profile &&
|
||||||
|
profile.role !== "super_admin" &&
|
||||||
|
profile.role !== "tenant_admin"
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||||
<h3 className="text-xl font-bold">{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}</h3>
|
<h3 className="text-xl font-bold">
|
||||||
<Button onClick={() => navigate("/")}>{t("ui.common.go_home", "홈으로 이동")}</Button>
|
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
|
||||||
|
</h3>
|
||||||
|
<Button onClick={() => navigate("/")}>
|
||||||
|
{t("ui.common.go_home", "홈으로 이동")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// While redirecting (only if exactly one manageable tenant)
|
// While redirecting (only if exactly one manageable tenant)
|
||||||
if (profile?.role === "tenant_admin" && (profile.manageableTenants?.length ?? 0) <= 1) {
|
if (
|
||||||
|
profile?.role === "tenant_admin" &&
|
||||||
|
(profile.manageableTenants?.length ?? 0) <= 1
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export function TenantSchemaPage() {
|
|||||||
: "text",
|
: "text",
|
||||||
required: Boolean(field?.required),
|
required: Boolean(field?.required),
|
||||||
adminOnly: Boolean(field?.adminOnly),
|
adminOnly: Boolean(field?.adminOnly),
|
||||||
validation: typeof field?.validation === "string" ? field.validation : "",
|
validation:
|
||||||
|
typeof field?.validation === "string" ? field.validation : "",
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -170,7 +171,9 @@ export function TenantSchemaPage() {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.key}
|
value={field.key}
|
||||||
onChange={(e) => updateField(index, { key: e.target.value })}
|
onChange={(e) =>
|
||||||
|
updateField(index, { key: e.target.value })
|
||||||
|
}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.tenants.schema.field.key_placeholder",
|
"ui.admin.tenants.schema.field.key_placeholder",
|
||||||
"예: employee_id",
|
"예: employee_id",
|
||||||
@@ -315,4 +318,3 @@ export function TenantSchemaPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ const MemberListDialog: React.FC<{
|
|||||||
{node.name}{" "}
|
{node.name}{" "}
|
||||||
{t("ui.admin.tenants.members.list_title", "구성원 관리")}
|
{t("ui.admin.tenants.members.list_title", "구성원 관리")}
|
||||||
<span className="text-sm font-normal text-muted-foreground ml-1">
|
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||||||
({isDirectLoading ? "..." : directData?.total ?? 0})
|
({isDirectLoading ? "..." : (directData?.total ?? 0)})
|
||||||
</span>
|
</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -167,7 +167,7 @@ const MemberListDialog: React.FC<{
|
|||||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-0 py-2"
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-0 py-2"
|
||||||
>
|
>
|
||||||
{t("ui.admin.tenants.members.direct", "소속 멤버")} (
|
{t("ui.admin.tenants.members.direct", "소속 멤버")} (
|
||||||
{isDirectLoading ? "..." : directData?.total ?? 0})
|
{isDirectLoading ? "..." : (directData?.total ?? 0)})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="descendants"
|
value="descendants"
|
||||||
@@ -715,7 +715,10 @@ const TenantTreeRow: React.FC<{
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 cursor-pointer hover:bg-muted p-1.5 rounded-md transition-all group/members w-full text-left"
|
className="flex items-center gap-2 cursor-pointer hover:bg-muted p-1.5 rounded-md transition-all group/members w-full text-left"
|
||||||
onClick={() => setIsMemberListOpen(true)}
|
onClick={() => setIsMemberListOpen(true)}
|
||||||
title={t("msg.admin.org.hover_member_info", "클릭하여 멤버 상세 조회")}
|
title={t(
|
||||||
|
"msg.admin.org.hover_member_info",
|
||||||
|
"클릭하여 멤버 상세 조회",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="bg-primary/10 p-1.5 rounded text-primary">
|
<div className="bg-primary/10 p-1.5 rounded text-primary">
|
||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
@@ -872,10 +875,7 @@ function TenantUserGroupsTab() {
|
|||||||
const tree = buildTenantFullTree(allTenants, tenantId);
|
const tree = buildTenantFullTree(allTenants, tenantId);
|
||||||
if (tree.currentBase) {
|
if (tree.currentBase) {
|
||||||
// Merge backend-provided UserGroups into the tree as virtual children
|
// Merge backend-provided UserGroups into the tree as virtual children
|
||||||
tree.currentBase.children = [
|
tree.currentBase.children = [...tree.currentBase.children, ...groupNodes];
|
||||||
...tree.currentBase.children,
|
|
||||||
...groupNodes,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return tree;
|
return tree;
|
||||||
}, [allTenants, tenantId, groupNodes]);
|
}, [allTenants, tenantId, groupNodes]);
|
||||||
@@ -1092,9 +1092,9 @@ function TenantUserGroupsTab() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{treeSearchTerm && (
|
{treeSearchTerm && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setTreeSearchTerm("")}
|
onClick={() => setTreeSearchTerm("")}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -103,9 +103,13 @@ function UserCreatePage() {
|
|||||||
const registerMetadata = (field: UserSchemaField) =>
|
const registerMetadata = (field: UserSchemaField) =>
|
||||||
register(`metadata.${field.key}` as `metadata.${string}`, {
|
register(`metadata.${field.key}` as `metadata.${string}`, {
|
||||||
required: field.required
|
required: field.required
|
||||||
? t("msg.admin.users.create.form.field_required", "{{label}}은(는) 필수입니다.", {
|
? t(
|
||||||
label: field.label || field.key,
|
"msg.admin.users.create.form.field_required",
|
||||||
})
|
"{{label}}은(는) 필수입니다.",
|
||||||
|
{
|
||||||
|
label: field.label || field.key,
|
||||||
|
},
|
||||||
|
)
|
||||||
: false,
|
: false,
|
||||||
pattern: field.validation
|
pattern: field.validation
|
||||||
? {
|
? {
|
||||||
@@ -537,5 +541,4 @@ function UserCreatePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default UserCreatePage;
|
export default UserCreatePage;
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { ArrowLeft, BadgeCheck, Building2, Loader2, Save, Users } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
BadgeCheck,
|
||||||
|
Building2,
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
@@ -36,16 +43,16 @@ type UserSchemaField = {
|
|||||||
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
|
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
|
||||||
|
|
||||||
// [New] Component for per-tenant profile/schema management
|
// [New] Component for per-tenant profile/schema management
|
||||||
function TenantProfileCard({
|
function TenantProfileCard({
|
||||||
tenant,
|
tenant,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
isAdmin
|
isAdmin,
|
||||||
}: {
|
}: {
|
||||||
tenant: any,
|
tenant: any;
|
||||||
register: any,
|
register: any;
|
||||||
errors: any,
|
errors: any;
|
||||||
isAdmin: boolean
|
isAdmin: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { data: detail, isLoading } = useQuery({
|
const { data: detail, isLoading } = useQuery({
|
||||||
queryKey: ["tenant", tenant.id],
|
queryKey: ["tenant", tenant.id],
|
||||||
@@ -56,7 +63,12 @@ function TenantProfileCard({
|
|||||||
? (detail?.config?.userSchema as UserSchemaField[])
|
? (detail?.config?.userSchema as UserSchemaField[])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (isLoading) return <div className="p-4 border rounded-lg animate-pulse bg-muted/20">Loading schema...</div>;
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<div className="p-4 border rounded-lg animate-pulse bg-muted/20">
|
||||||
|
Loading schema...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
if (schema.length === 0) return null;
|
if (schema.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,16 +76,23 @@ function TenantProfileCard({
|
|||||||
<div className="bg-muted/50 px-4 py-2 border-b border-border flex items-center justify-between">
|
<div className="bg-muted/50 px-4 py-2 border-b border-border flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Building2 size={14} className="text-primary" />
|
<Building2 size={14} className="text-primary" />
|
||||||
<span className="text-xs font-bold uppercase tracking-tight">{tenant.name}</span>
|
<span className="text-xs font-bold uppercase tracking-tight">
|
||||||
|
{tenant.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-mono opacity-50">{tenant.slug}</span>
|
<span className="text-[10px] font-mono opacity-50">{tenant.slug}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 grid gap-4 md:grid-cols-2">
|
<div className="p-4 grid gap-4 md:grid-cols-2">
|
||||||
{schema.map((field) => (
|
{schema.map((field) => (
|
||||||
<div key={field.key} className="space-y-2">
|
<div key={field.key} className="space-y-2">
|
||||||
<Label htmlFor={`metadata.${tenant.id}.${field.key}`} className="text-xs">
|
<Label
|
||||||
|
htmlFor={`metadata.${tenant.id}.${field.key}`}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
{field.required && (
|
||||||
|
<span className="ml-1 text-destructive">*</span>
|
||||||
|
)}
|
||||||
{field.adminOnly && (
|
{field.adminOnly && (
|
||||||
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
|
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
|
||||||
Admin Only
|
Admin Only
|
||||||
@@ -83,13 +102,24 @@ function TenantProfileCard({
|
|||||||
<Input
|
<Input
|
||||||
id={`metadata.${tenant.id}.${field.key}`}
|
id={`metadata.${tenant.id}.${field.key}`}
|
||||||
type={
|
type={
|
||||||
field.type === "number" ? "number" :
|
field.type === "number"
|
||||||
field.type === "date" ? "date" :
|
? "number"
|
||||||
field.type === "boolean" ? "checkbox" : "text"
|
: field.type === "date"
|
||||||
|
? "date"
|
||||||
|
: field.type === "boolean"
|
||||||
|
? "checkbox"
|
||||||
|
: "text"
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"
|
||||||
}
|
}
|
||||||
className={field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"}
|
|
||||||
{...register(`metadata.${tenant.id}.${field.key}`, {
|
{...register(`metadata.${tenant.id}.${field.key}`, {
|
||||||
required: field.required ? t("msg.admin.users.detail.form.field_required", "필수입니다.") : false,
|
required: field.required
|
||||||
|
? t(
|
||||||
|
"msg.admin.users.detail.form.field_required",
|
||||||
|
"필수입니다.",
|
||||||
|
)
|
||||||
|
: false,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{errors.metadata?.[tenant.id]?.[field.key] && (
|
{errors.metadata?.[tenant.id]?.[field.key] && (
|
||||||
@@ -154,7 +184,8 @@ function UserDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
const isAdmin =
|
||||||
|
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -224,7 +255,10 @@ function UserDetailPage() {
|
|||||||
|
|
||||||
// Combined affiliated tenants
|
// Combined affiliated tenants
|
||||||
const userAffiliatedTenants = [...(user.joinedTenants || [])];
|
const userAffiliatedTenants = [...(user.joinedTenants || [])];
|
||||||
if (user.tenant && !userAffiliatedTenants.find(t => t.id === user.tenant?.id)) {
|
if (
|
||||||
|
user.tenant &&
|
||||||
|
!userAffiliatedTenants.find((t) => t.id === user.tenant?.id)
|
||||||
|
) {
|
||||||
userAffiliatedTenants.push(user.tenant);
|
userAffiliatedTenants.push(user.tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,35 +315,55 @@ function UserDetailPage() {
|
|||||||
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
|
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||||
<Building2 size={16} className="text-primary" />
|
<Building2 size={16} className="text-primary" />
|
||||||
{t("ui.admin.users.detail.tenants_section.title", "소속 및 조직 정보")}
|
{t(
|
||||||
|
"ui.admin.users.detail.tenants_section.title",
|
||||||
|
"소속 및 조직 정보",
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
||||||
{t("ui.admin.users.detail.tenants_section.primary", "대표 소속 테넌트")}
|
{t(
|
||||||
|
"ui.admin.users.detail.tenants_section.primary",
|
||||||
|
"대표 소속 테넌트",
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
{/* Select box to specify representative tenant from joined ones */}
|
{/* Select box to specify representative tenant from joined ones */}
|
||||||
{userAffiliatedTenants.length > 0 ? (
|
{userAffiliatedTenants.length > 0 ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
|
||||||
{...register("companyCode")}
|
{...register("companyCode")}
|
||||||
disabled={profile?.role === "tenant_admin" && userAffiliatedTenants.length <= 1}
|
disabled={
|
||||||
|
profile?.role === "tenant_admin" &&
|
||||||
|
userAffiliatedTenants.length <= 1
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="">{t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}</option>
|
<option value="">
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.form.tenant_global",
|
||||||
|
"시스템 전역",
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
{userAffiliatedTenants.map((t) => (
|
{userAffiliatedTenants.map((t) => (
|
||||||
<option key={t.id} value={t.slug}>
|
<option key={t.id} value={t.slug}>
|
||||||
{t.name} ({t.slug})
|
{t.name} ({t.slug})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<BadgeCheck size={14} className="absolute right-8 top-3 text-primary pointer-events-none" />
|
<BadgeCheck
|
||||||
|
size={14}
|
||||||
|
className="absolute right-8 top-3 text-primary pointer-events-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 p-2 rounded-md bg-background border border-dashed border-border text-muted-foreground italic text-xs">
|
<div className="flex items-center gap-2 p-2 rounded-md bg-background border border-dashed border-border text-muted-foreground italic text-xs">
|
||||||
{t("ui.admin.users.detail.form.tenant_global", "시스템 전역 (소속 없음)")}
|
{t(
|
||||||
|
"ui.admin.users.detail.form.tenant_global",
|
||||||
|
"시스템 전역 (소속 없음)",
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
@@ -320,15 +374,22 @@ function UserDetailPage() {
|
|||||||
{userAffiliatedTenants.length > 1 && (
|
{userAffiliatedTenants.length > 1 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
||||||
{t("ui.admin.users.detail.tenants_section.additional", "전체 소속 목록")}
|
{t(
|
||||||
|
"ui.admin.users.detail.tenants_section.additional",
|
||||||
|
"전체 소속 목록",
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
{userAffiliatedTenants.map(jt => (
|
{userAffiliatedTenants.map((jt) => (
|
||||||
<Link
|
<Link
|
||||||
key={jt.id}
|
key={jt.id}
|
||||||
to={`/tenants/${jt.id}`}
|
to={`/tenants/${jt.id}`}
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
|
||||||
jt.id === tenants.find(t => t.slug === watch("companyCode"))?.id ? "bg-primary/10 border-primary/30 text-primary font-bold" : "bg-background border-border text-muted-foreground hover:border-primary/50"
|
jt.id ===
|
||||||
|
tenants.find((t) => t.slug === watch("companyCode"))
|
||||||
|
?.id
|
||||||
|
? "bg-primary/10 border-primary/30 text-primary font-bold"
|
||||||
|
: "bg-background border-border text-muted-foreground hover:border-primary/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Users size={10} />
|
<Users size={10} />
|
||||||
@@ -453,7 +514,10 @@ function UserDetailPage() {
|
|||||||
<div className="border-t pt-6 space-y-6">
|
<div className="border-t pt-6 space-y-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
{t("ui.admin.users.detail.custom_fields.multi_title", "테넌트별 프로필 관리")}
|
{t(
|
||||||
|
"ui.admin.users.detail.custom_fields.multi_title",
|
||||||
|
"테넌트별 프로필 관리",
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
사용자가 소속된 각 테넌트별 맞춤 정보를 관리합니다.
|
사용자가 소속된 각 테넌트별 맞춤 정보를 관리합니다.
|
||||||
|
|||||||
@@ -45,15 +45,15 @@ import {
|
|||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
bulkUpdateUsers,
|
bulkUpdateUsers,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
exportUsersCSVUrl,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
exportUsersCSVUrl,
|
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
|
||||||
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
|
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
|
||||||
|
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||||
|
|
||||||
type UserSchemaField = {
|
type UserSchemaField = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -67,7 +67,9 @@ function UserListPage() {
|
|||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const [searchDraft, setSearchDraft] = React.useState("");
|
const [searchDraft, setSearchDraft] = React.useState("");
|
||||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||||
const [visibleColumns, setVisibleColumns] = React.useState<Record<string, boolean>>({});
|
const [visibleColumns, setVisibleColumns] = React.useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
@@ -129,7 +131,10 @@ function UserListPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["users", { limit, offset, search, companyCode: selectedCompany }],
|
queryKey: [
|
||||||
|
"users",
|
||||||
|
{ limit, offset, search, companyCode: selectedCompany },
|
||||||
|
],
|
||||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
@@ -190,7 +195,12 @@ function UserListPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
query.refetch();
|
query.refetch();
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
toast.success(t("msg.admin.users.bulk.delete_success", "선택한 사용자들이 삭제되었습니다."));
|
toast.success(
|
||||||
|
t(
|
||||||
|
"msg.admin.users.bulk.delete_success",
|
||||||
|
"선택한 사용자들이 삭제되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,7 +209,12 @@ function UserListPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
query.refetch();
|
query.refetch();
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
toast.success(t("msg.admin.users.bulk.update_success", "선택한 사용자들의 정보가 수정되었습니다."));
|
toast.success(
|
||||||
|
t(
|
||||||
|
"msg.admin.users.bulk.update_success",
|
||||||
|
"선택한 사용자들의 정보가 수정되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,7 +225,15 @@ function UserListPage() {
|
|||||||
|
|
||||||
const handleBulkDelete = () => {
|
const handleBulkDelete = () => {
|
||||||
if (selectedUserIds.length === 0) return;
|
if (selectedUserIds.length === 0) return;
|
||||||
if (window.confirm(t("msg.admin.users.bulk.delete_confirm", "{{count}}명의 사용자를 정말 삭제하시겠습니까?", { count: selectedUserIds.length }))) {
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.admin.users.bulk.delete_confirm",
|
||||||
|
"{{count}}명의 사용자를 정말 삭제하시겠습니까?",
|
||||||
|
{ count: selectedUserIds.length },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
bulkDeleteMutation.mutate(selectedUserIds);
|
bulkDeleteMutation.mutate(selectedUserIds);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -273,19 +296,30 @@ function UserListPage() {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("msg.admin.users.list.columns.description", "사용자 목록에 표시할 커스텀 필드를 선택하세요.")}
|
{t(
|
||||||
|
"msg.admin.users.list.columns.description",
|
||||||
|
"사용자 목록에 표시할 커스텀 필드를 선택하세요.",
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
{userSchema.length === 0 && (
|
{userSchema.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
{t("msg.admin.users.list.columns.no_custom", "현재 테넌트에 정의된 커스텀 필드가 없습니다.")}
|
{t(
|
||||||
|
"msg.admin.users.list.columns.no_custom",
|
||||||
|
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{userSchema.map((field) => (
|
{userSchema.map((field) => (
|
||||||
<label key={field.key} className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer">
|
<label
|
||||||
|
key={field.key}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
@@ -294,14 +328,18 @@ function UserListPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium">{field.label}</span>
|
<span className="text-sm font-medium">{field.label}</span>
|
||||||
<span className="text-xs text-muted-foreground font-mono">{field.key}</span>
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{field.key}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="secondary">{t("ui.common.close", "닫기")}</Button>
|
<Button variant="secondary">
|
||||||
|
{t("ui.common.close", "닫기")}
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -387,7 +425,10 @@ function UserListPage() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||||
checked={items.length > 0 && selectedUserIds.length === items.length}
|
checked={
|
||||||
|
items.length > 0 &&
|
||||||
|
selectedUserIds.length === items.length
|
||||||
|
}
|
||||||
onChange={toggleSelectAll}
|
onChange={toggleSelectAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -407,13 +448,14 @@ function UserListPage() {
|
|||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
{/* Dynamic Columns from Schema */}
|
{/* Dynamic Columns from Schema */}
|
||||||
{userSchema.map((field) => (
|
{userSchema.map(
|
||||||
visibleColumns[field.key] !== false && (
|
(field) =>
|
||||||
<TableHead key={field.key} className="uppercase">
|
visibleColumns[field.key] !== false && (
|
||||||
{field.label}
|
<TableHead key={field.key} className="uppercase">
|
||||||
</TableHead>
|
{field.label}
|
||||||
)
|
</TableHead>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
{t("ui.admin.users.list.table.created", "CREATED")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -444,9 +486,11 @@ function UserListPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{items.map((user) => (
|
{items.map((user) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={user.id}
|
key={user.id}
|
||||||
className={selectedUserIds.includes(user.id) ? "bg-primary/5" : ""}
|
className={
|
||||||
|
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<input
|
<input
|
||||||
@@ -494,13 +538,14 @@ function UserListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{/* Dynamic Metadata Cells */}
|
{/* Dynamic Metadata Cells */}
|
||||||
{userSchema.map((field) => (
|
{userSchema.map(
|
||||||
visibleColumns[field.key] !== false && (
|
(field) =>
|
||||||
<TableCell key={field.key} className="text-sm">
|
visibleColumns[field.key] !== false && (
|
||||||
{String(user.metadata?.[field.key] ?? "-")}
|
<TableCell key={field.key} className="text-sm">
|
||||||
</TableCell>
|
{String(user.metadata?.[field.key] ?? "-")}
|
||||||
)
|
</TableCell>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -534,36 +579,38 @@ function UserListPage() {
|
|||||||
{selectedUserIds.length > 0 && (
|
{selectedUserIds.length > 0 && (
|
||||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300">
|
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300">
|
||||||
<span className="text-sm font-medium border-r border-background/20 pr-4 mr-2">
|
<span className="text-sm font-medium border-r border-background/20 pr-4 mr-2">
|
||||||
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", { count: selectedUserIds.length })}
|
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", {
|
||||||
|
count: selectedUserIds.length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-background hover:bg-background/10 h-8"
|
className="text-background hover:bg-background/10 h-8"
|
||||||
onClick={() => handleBulkStatusChange("active")}
|
onClick={() => handleBulkStatusChange("active")}
|
||||||
>
|
>
|
||||||
{t("ui.common.status.active", "활성화")}
|
{t("ui.common.status.active", "활성화")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-background hover:bg-background/10 h-8"
|
className="text-background hover:bg-background/10 h-8"
|
||||||
onClick={() => handleBulkStatusChange("inactive")}
|
onClick={() => handleBulkStatusChange("inactive")}
|
||||||
>
|
>
|
||||||
{t("ui.common.status.inactive", "비활성화")}
|
{t("ui.common.status.inactive", "비활성화")}
|
||||||
</Button>
|
</Button>
|
||||||
<UserBulkMoveGroupModal
|
<UserBulkMoveGroupModal
|
||||||
userIds={selectedUserIds}
|
userIds={selectedUserIds}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
query.refetch();
|
query.refetch();
|
||||||
setSelectedUserIds([]);
|
setSelectedUserIds([]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
||||||
onClick={handleBulkDelete}
|
onClick={handleBulkDelete}
|
||||||
>
|
>
|
||||||
@@ -571,9 +618,9 @@ function UserListPage() {
|
|||||||
{t("ui.common.delete", "삭제")}
|
{t("ui.common.delete", "삭제")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-background/50 hover:text-background h-8 w-8 ml-2"
|
className="text-background/50 hover:text-background h-8 w-8 ml-2"
|
||||||
onClick={() => setSelectedUserIds([])}
|
onClick={() => setSelectedUserIds([])}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import {
|
|||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
bulkUpdateUsers,
|
type GroupSummary,
|
||||||
fetchGroups,
|
|
||||||
fetchTenants,
|
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
type GroupSummary
|
bulkUpdateUsers,
|
||||||
|
fetchGroups,
|
||||||
|
fetchTenants,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
@@ -28,12 +28,16 @@ interface UserBulkMoveGroupModalProps {
|
|||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroupModalProps) {
|
export function UserBulkMoveGroupModal({
|
||||||
|
userIds,
|
||||||
|
onSuccess,
|
||||||
|
}: UserBulkMoveGroupModalProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [selectedTenantSlug, setSelectedTenantSlug] = React.useState<string>("");
|
const [selectedTenantSlug, setSelectedTenantSlug] =
|
||||||
|
React.useState<string>("");
|
||||||
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
|
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
|
||||||
const [searchTerm, setSearchTerm] = React.useState("");
|
const [searchTerm, setSearchTerm] = React.useState("");
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
@@ -43,9 +47,10 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
|
|||||||
});
|
});
|
||||||
const tenants = tenantsData?.items ?? [];
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|
||||||
const selectedTenantId = React.useMemo(() =>
|
const selectedTenantId = React.useMemo(
|
||||||
tenants.find(t => t.slug === selectedTenantSlug)?.id ?? "",
|
() => tenants.find((t) => t.slug === selectedTenantSlug)?.id ?? "",
|
||||||
[tenants, selectedTenantSlug]);
|
[tenants, selectedTenantSlug],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: groups, isLoading: isGroupsLoading } = useQuery({
|
const { data: groups, isLoading: isGroupsLoading } = useQuery({
|
||||||
queryKey: ["tenant-groups", selectedTenantId],
|
queryKey: ["tenant-groups", selectedTenantId],
|
||||||
@@ -56,7 +61,12 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: bulkUpdateUsers,
|
mutationFn: bulkUpdateUsers,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t("msg.admin.users.bulk.move_success", "사용자들의 부서가 이동되었습니다."));
|
toast.success(
|
||||||
|
t(
|
||||||
|
"msg.admin.users.bulk.move_success",
|
||||||
|
"사용자들의 부서가 이동되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
},
|
},
|
||||||
@@ -79,27 +89,41 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
|
|||||||
const filteredGroups = React.useMemo(() => {
|
const filteredGroups = React.useMemo(() => {
|
||||||
if (!groups) return [];
|
if (!groups) return [];
|
||||||
if (!searchTerm) return groups;
|
if (!searchTerm) return groups;
|
||||||
return groups.filter(g => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
return groups.filter((g) =>
|
||||||
|
g.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
}, [groups, searchTerm]);
|
}, [groups, searchTerm]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="text-background hover:bg-background/10 h-8">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-background hover:bg-background/10 h-8"
|
||||||
|
>
|
||||||
{t("ui.admin.users.bulk.move_group", "부서 이동")}
|
{t("ui.admin.users.bulk.move_group", "부서 이동")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("msg.admin.users.bulk.move_description", "선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.", { count: userIds.length })}
|
{t(
|
||||||
|
"msg.admin.users.bulk.move_description",
|
||||||
|
"선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.",
|
||||||
|
{ count: userIds.length },
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">{t("ui.admin.users.create.form.tenant", "테넌트 선택")}</label>
|
<label className="text-sm font-medium">
|
||||||
|
{t("ui.admin.users.create.form.tenant", "테넌트 선택")}
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
value={selectedTenantSlug}
|
value={selectedTenantSlug}
|
||||||
@@ -110,14 +134,18 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
|
|||||||
>
|
>
|
||||||
<option value="">{t("ui.common.select", "선택하세요...")}</option>
|
<option value="">{t("ui.common.select", "선택하세요...")}</option>
|
||||||
{tenants.map((t) => (
|
{tenants.map((t) => (
|
||||||
<option key={t.id} value={t.slug}>{t.name} ({t.slug})</option>
|
<option key={t.id} value={t.slug}>
|
||||||
|
{t.name} ({t.slug})
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTenantSlug && (
|
{selectedTenantSlug && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">{t("ui.admin.users.bulk.select_group", "부서 선택")}</label>
|
<label className="text-sm font-medium">
|
||||||
|
{t("ui.admin.users.bulk.select_group", "부서 선택")}
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -133,14 +161,18 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedGroupName("")}
|
onClick={() => setSelectedGroupName("")}
|
||||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
|
||||||
selectedGroupName === "" ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
selectedGroupName === ""
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderTree size={14} />
|
<FolderTree size={14} />
|
||||||
{t("ui.admin.users.bulk.no_department", "(부서 없음)")}
|
{t("ui.admin.users.bulk.no_department", "(부서 없음)")}
|
||||||
</button>
|
</button>
|
||||||
{isGroupsLoading ? (
|
{isGroupsLoading ? (
|
||||||
<div className="flex justify-center py-4"><Loader2 className="animate-spin text-muted-foreground" /></div>
|
<div className="flex justify-center py-4">
|
||||||
|
<Loader2 className="animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredGroups.map((group) => (
|
filteredGroups.map((group) => (
|
||||||
<button
|
<button
|
||||||
@@ -148,7 +180,9 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedGroupName(group.name)}
|
onClick={() => setSelectedGroupName(group.name)}
|
||||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
|
||||||
selectedGroupName === group.name ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
selectedGroupName === group.name
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderTree size={14} />
|
<FolderTree size={14} />
|
||||||
@@ -166,11 +200,13 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
|
|||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
{t("ui.common.cancel", "취소")}
|
{t("ui.common.cancel", "취소")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleMove}
|
onClick={handleMove}
|
||||||
disabled={!selectedTenantSlug || mutation.isPending}
|
disabled={!selectedTenantSlug || mutation.isPending}
|
||||||
>
|
>
|
||||||
{mutation.isPending && <Loader2 size={16} className="mr-2 animate-spin" />}
|
{mutation.isPending && (
|
||||||
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||||
|
)}
|
||||||
{t("ui.admin.users.bulk.do_move", "이동 실행")}
|
{t("ui.admin.users.bulk.do_move", "이동 실행")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { AlertCircle, CheckCircle2, Download, FileText, Loader2, Upload } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +19,11 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||||
import { bulkCreateUsers, type BulkUserItem, type BulkUserResult } from "../../../lib/adminApi";
|
import {
|
||||||
|
type BulkUserItem,
|
||||||
|
type BulkUserResult,
|
||||||
|
bulkCreateUsers,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { parseUserCSV } from "../utils/csvParser";
|
import { parseUserCSV } from "../utils/csvParser";
|
||||||
|
|
||||||
@@ -64,9 +75,15 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
|
|
||||||
const downloadTemplate = () => {
|
const downloadTemplate = () => {
|
||||||
const headers = "email,name,phone,role,companyCode,department,employee_id";
|
const headers = "email,name,phone,role,companyCode,department,employee_id";
|
||||||
const example = "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
|
const example =
|
||||||
const blob = new Blob([`${headers}
|
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
|
||||||
${example}`], { type: "text/csv" });
|
const blob = new Blob(
|
||||||
|
[
|
||||||
|
`${headers}
|
||||||
|
${example}`,
|
||||||
|
],
|
||||||
|
{ type: "text/csv" },
|
||||||
|
);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -82,11 +99,17 @@ ${example}`], { type: "text/csv" });
|
|||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const successCount = results?.filter(r => r.success).length ?? 0;
|
const successCount = results?.filter((r) => r.success).length ?? 0;
|
||||||
const failCount = results ? results.length - successCount : 0;
|
const failCount = results ? results.length - successCount : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(val) => { setOpen(val); if (!val) reset(); }}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
if (!val) reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="outline" className="gap-2">
|
||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
@@ -95,16 +118,26 @@ ${example}`], { type: "text/csv" });
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("msg.admin.users.bulk.description", "CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.")}
|
{t(
|
||||||
|
"msg.admin.users.bulk.description",
|
||||||
|
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{!results ? (
|
{!results ? (
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<Button variant="ghost" size="sm" onClick={downloadTemplate} className="gap-2">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={downloadTemplate}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
|
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -115,8 +148,13 @@ ${example}`], { type: "text/csv" });
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => fileInputRef.current?.click()} variant="secondary">
|
<Button
|
||||||
{file ? t("ui.common.change_file", "파일 변경") : t("ui.common.select_file", "파일 선택")}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{file
|
||||||
|
? t("ui.common.change_file", "파일 변경")
|
||||||
|
: t("ui.common.select_file", "파일 선택")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,7 +163,9 @@ ${example}`], { type: "text/csv" });
|
|||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<FileText className="text-primary" />
|
<FileText className="text-primary" />
|
||||||
<span className="font-medium">{file.name}</span>
|
<span className="font-medium">{file.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({(file.size / 1024).toFixed(1)} KB)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{parsing ? (
|
{parsing ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
@@ -134,7 +174,11 @@ ${example}`], { type: "text/csv" });
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("msg.admin.users.bulk.parsed_count", "{{count}}명의 사용자가 감지되었습니다.", { count: previewData.length })}
|
{t(
|
||||||
|
"msg.admin.users.bulk.parsed_count",
|
||||||
|
"{{count}}명의 사용자가 감지되었습니다.",
|
||||||
|
{ count: previewData.length },
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +204,10 @@ ${example}`], { type: "text/csv" });
|
|||||||
))}
|
))}
|
||||||
{previewData.length > 10 && (
|
{previewData.length > 10 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className="p-2 text-center text-muted-foreground italic">
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
className="p-2 text-center text-muted-foreground italic"
|
||||||
|
>
|
||||||
... and {previewData.length - 10} more users
|
... and {previewData.length - 10} more users
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -174,28 +221,49 @@ ${example}`], { type: "text/csv" });
|
|||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
||||||
<div className="flex-1 text-center">
|
<div className="flex-1 text-center">
|
||||||
<div className="text-2xl font-bold text-green-600">{successCount}</div>
|
<div className="text-2xl font-bold text-green-600">
|
||||||
<div className="text-xs text-muted-foreground uppercase">{t("ui.common.success", "성공")}</div>
|
{successCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase">
|
||||||
|
{t("ui.common.success", "성공")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-10 bg-border" />
|
<div className="w-px h-10 bg-border" />
|
||||||
<div className="flex-1 text-center">
|
<div className="flex-1 text-center">
|
||||||
<div className="text-2xl font-bold text-destructive">{failCount}</div>
|
<div className="text-2xl font-bold text-destructive">
|
||||||
<div className="text-xs text-muted-foreground uppercase">{t("ui.common.fail", "실패")}</div>
|
{failCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase">
|
||||||
|
{t("ui.common.fail", "실패")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="h-[250px] rounded-md border">
|
<ScrollArea className="h-[250px] rounded-md border">
|
||||||
<div className="p-2 space-y-2">
|
<div className="p-2 space-y-2">
|
||||||
{results.map((r, i) => (
|
{results.map((r, i) => (
|
||||||
<div key={i} className="flex items-start gap-3 p-2 rounded border bg-card text-sm">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
|
||||||
|
>
|
||||||
{r.success ? (
|
{r.success ? (
|
||||||
<CheckCircle2 size={16} className="text-green-500 mt-0.5" />
|
<CheckCircle2
|
||||||
|
size={16}
|
||||||
|
className="text-green-500 mt-0.5"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AlertCircle size={16} className="text-destructive mt-0.5" />
|
<AlertCircle
|
||||||
|
size={16}
|
||||||
|
className="text-destructive mt-0.5"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">{r.email}</div>
|
<div className="font-medium truncate">{r.email}</div>
|
||||||
{!r.success && <div className="text-xs text-destructive">{r.message}</div>}
|
{!r.success && (
|
||||||
|
<div className="text-xs text-destructive">
|
||||||
|
{r.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -206,12 +274,14 @@ ${example}`], { type: "text/csv" });
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{!results ? (
|
{!results ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={previewData.length === 0 || mutation.isPending}
|
disabled={previewData.length === 0 || mutation.isPending}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{mutation.isPending && <Loader2 size={16} className="mr-2 animate-spin" />}
|
{mutation.isPending && (
|
||||||
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||||
|
)}
|
||||||
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type BulkUserItem } from "../../../lib/adminApi";
|
import type { BulkUserItem } from "../../../lib/adminApi";
|
||||||
|
|
||||||
export function parseUserCSV(text: string): BulkUserItem[] {
|
export function parseUserCSV(text: string): BulkUserItem[] {
|
||||||
const lines = text.split(/\r?\n/);
|
const lines = text.split(/\r?\n/);
|
||||||
@@ -20,9 +20,14 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
|||||||
if (value === undefined || value === "") return;
|
if (value === undefined || value === "") return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
["email", "name", "phone", "role", "companycode", "department"].includes(
|
[
|
||||||
header,
|
"email",
|
||||||
)
|
"name",
|
||||||
|
"phone",
|
||||||
|
"role",
|
||||||
|
"companycode",
|
||||||
|
"department",
|
||||||
|
].includes(header)
|
||||||
) {
|
) {
|
||||||
const key = header === "companycode" ? "companyCode" : header;
|
const key = header === "companycode" ? "companyCode" : header;
|
||||||
item[key] = value;
|
item[key] = value;
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export function exportUsersCSVUrl(search?: string, companyCode?: string) {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (search) params.append("search", search);
|
if (search) params.append("search", search);
|
||||||
if (companyCode) params.append("companyCode", companyCode);
|
if (companyCode) params.append("companyCode", companyCode);
|
||||||
|
|
||||||
// Get mock role from storage if exists for dev environment
|
// Get mock role from storage if exists for dev environment
|
||||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||||
if (mockRole) params.append("x-test-role", mockRole);
|
if (mockRole) params.append("x-test-role", mockRole);
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ export function buildTenantFullTree(
|
|||||||
// Function to calculate recursive counts with cycle protection
|
// Function to calculate recursive counts with cycle protection
|
||||||
const calculateRecursive = (node: TenantNode): number => {
|
const calculateRecursive = (node: TenantNode): number => {
|
||||||
if (visitedForCalc.has(node.id)) {
|
if (visitedForCalc.has(node.id)) {
|
||||||
console.warn(`Circular dependency detected in tenant tree for ID: ${node.id}`);
|
console.warn(
|
||||||
|
`Circular dependency detected in tenant tree for ID: ${node.id}`,
|
||||||
|
);
|
||||||
return 0; // Prevent infinite loop
|
return 0; // Prevent infinite loop
|
||||||
}
|
}
|
||||||
visitedForCalc.add(node.id);
|
visitedForCalc.add(node.id);
|
||||||
@@ -51,8 +53,8 @@ export function buildTenantFullTree(
|
|||||||
total += calculateRecursive(child);
|
total += calculateRecursive(child);
|
||||||
}
|
}
|
||||||
node.recursiveMemberCount = total;
|
node.recursiveMemberCount = total;
|
||||||
|
|
||||||
// We don't remove from visitedForCalc here because a tree shouldn't have
|
// We don't remove from visitedForCalc here because a tree shouldn't have
|
||||||
// multiple paths to the same node anyway (it's a tree, not a graph).
|
// multiple paths to the same node anyway (it's a tree, not a graph).
|
||||||
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
|
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
|
||||||
// a node should only be visited once.
|
// a node should only be visited once.
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ test.describe("Bulk Actions and Tree Search", () => {
|
|||||||
const authority = "http://localhost:5000/oidc";
|
const authority = "http://localhost:5000/oidc";
|
||||||
const client_id = "adminfront";
|
const client_id = "adminfront";
|
||||||
const key = `oidc.user:${authority}:${client_id}`;
|
const key = `oidc.user:${authority}:${client_id}`;
|
||||||
window.localStorage.setItem(key, JSON.stringify({
|
window.localStorage.setItem(
|
||||||
access_token: "fake", profile: { sub: "admin", role: "super_admin" }, expires_at: 9999999999
|
key,
|
||||||
}));
|
JSON.stringify({
|
||||||
|
access_token: "fake",
|
||||||
|
profile: { sub: "admin", role: "super_admin" },
|
||||||
|
expires_at: 9999999999,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock APIs
|
// Mock APIs
|
||||||
@@ -18,34 +23,61 @@ test.describe("Bulk Actions and Tree Search", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/api/v1/admin/users?*", async (route) => {
|
await page.route("**/api/v1/admin/users?*", async (route) => {
|
||||||
await route.fulfill({ json: {
|
await route.fulfill({
|
||||||
items: [
|
json: {
|
||||||
{ id: "u-1", name: "User One", email: "u1@test.com", status: "active", role: "user", createdAt: new Date().toISOString() },
|
items: [
|
||||||
{ id: "u-2", name: "User Two", email: "u2@test.com", status: "active", role: "user", createdAt: new Date().toISOString() },
|
{
|
||||||
],
|
id: "u-1",
|
||||||
total: 2
|
name: "User One",
|
||||||
}});
|
email: "u1@test.com",
|
||||||
|
status: "active",
|
||||||
|
role: "user",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-2",
|
||||||
|
name: "User Two",
|
||||||
|
email: "u2@test.com",
|
||||||
|
status: "active",
|
||||||
|
role: "user",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/api/v1/admin/tenants/t-1", async (route) => {
|
await page.route("**/api/v1/admin/tenants/t-1", async (route) => {
|
||||||
await route.fulfill({ json: { id: "t-1", name: "Main Tenant", slug: "main" } });
|
await route.fulfill({
|
||||||
|
json: { id: "t-1", name: "Main Tenant", slug: "main" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/api/v1/admin/tenants/t-1/organization", async (route) => {
|
await page.route(
|
||||||
await route.fulfill({ json: [
|
"**/api/v1/admin/tenants/t-1/organization",
|
||||||
{ id: "g-1", name: "Engineering", slug: "eng", tenantId: "t-1" },
|
async (route) => {
|
||||||
{ id: "g-2", name: "Sales", slug: "sales", tenantId: "t-1" },
|
await route.fulfill({
|
||||||
]});
|
json: [
|
||||||
});
|
{ id: "g-1", name: "Engineering", slug: "eng", tenantId: "t-1" },
|
||||||
|
{ id: "g-2", name: "Sales", slug: "sales", tenantId: "t-1" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show bulk action bar when users are selected", async ({ page }) => {
|
test("should show bulk action bar when users are selected", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/users");
|
await page.goto("/users");
|
||||||
|
|
||||||
// Check individual row
|
// Check individual row
|
||||||
await page.locator('input[type="checkbox"]').nth(1).check();
|
await page.locator('input[type="checkbox"]').nth(1).check();
|
||||||
await expect(page.getByText("1명 선택됨")).toBeVisible();
|
await expect(page.getByText("1명 선택됨")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /활성화|Active/i })).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("button", { name: /활성화|Active/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// Check select all
|
// Check select all
|
||||||
await page.locator('input[type="checkbox"]').first().check();
|
await page.locator('input[type="checkbox"]').first().check();
|
||||||
@@ -56,15 +88,19 @@ test.describe("Bulk Actions and Tree Search", () => {
|
|||||||
await expect(page.getByText("명 선택됨")).not.toBeVisible();
|
await expect(page.getByText("명 선택됨")).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should filter and highlight nodes in organization tree", async ({ page }) => {
|
test("should filter and highlight nodes in organization tree", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/tenants/t-1");
|
await page.goto("/tenants/t-1");
|
||||||
await page.getByRole("link", { name: /하위 테넌트 관리|Sub-tenant/i }).click();
|
await page
|
||||||
|
.getByRole("link", { name: /하위 테넌트 관리|Sub-tenant/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
const searchInput = page.getByPlaceholder(/조직도 내 검색|Search in tree/i);
|
const searchInput = page.getByPlaceholder(/조직도 내 검색|Search in tree/i);
|
||||||
await expect(searchInput).toBeVisible();
|
await expect(searchInput).toBeVisible();
|
||||||
|
|
||||||
await searchInput.fill("Eng");
|
await searchInput.fill("Eng");
|
||||||
|
|
||||||
// Check if Engineering row is highlighted
|
// Check if Engineering row is highlighted
|
||||||
const engRow = page.locator('tr:has-text("Engineering")');
|
const engRow = page.locator('tr:has-text("Engineering")');
|
||||||
await expect(engRow).toHaveClass(/bg-primary\/10/);
|
await expect(engRow).toHaveClass(/bg-primary\/10/);
|
||||||
|
|||||||
@@ -21,14 +21,22 @@ test.describe("Users Bulk Upload", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mock OIDC config
|
// Mock OIDC config
|
||||||
await page.route("**/oidc/.well-known/openid-configuration", async (route) => {
|
await page.route(
|
||||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
"**/oidc/.well-known/openid-configuration",
|
||||||
});
|
async (route) => {
|
||||||
|
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Mock user profile
|
// Mock user profile
|
||||||
await page.route("**/api/v1/user/me", async (route) => {
|
await page.route("**/api/v1/user/me", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: { id: "admin-user", name: "Admin User", email: "admin@example.com", role: "super_admin" },
|
json: {
|
||||||
|
id: "admin-user",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,13 +50,19 @@ test.describe("Users Bulk Upload", () => {
|
|||||||
|
|
||||||
test("should open bulk upload modal and show preview", async ({ page }) => {
|
test("should open bulk upload modal and show preview", async ({ page }) => {
|
||||||
await page.goto("/users");
|
await page.goto("/users");
|
||||||
|
|
||||||
const bulkBtn = page.getByRole("button", { name: /일괄 등록|Bulk Import/i });
|
const bulkBtn = page.getByRole("button", {
|
||||||
|
name: /일괄 등록|Bulk Import/i,
|
||||||
|
});
|
||||||
await expect(bulkBtn).toBeVisible();
|
await expect(bulkBtn).toBeVisible();
|
||||||
await bulkBtn.click();
|
await bulkBtn.click();
|
||||||
|
|
||||||
await expect(page.getByText(/사용자 일괄 등록|User Bulk Upload/i)).toBeVisible();
|
await expect(
|
||||||
await expect(page.getByRole("button", { name: /템플릿 다운로드|Download Template/i })).toBeVisible();
|
page.getByText(/사용자 일괄 등록|User Bulk Upload/i),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /템플릿 다운로드|Download Template/i }),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show success results after mock upload", async ({ page }) => {
|
test("should show success results after mock upload", async ({ page }) => {
|
||||||
@@ -58,7 +72,11 @@ test.describe("Users Bulk Upload", () => {
|
|||||||
json: {
|
json: {
|
||||||
results: [
|
results: [
|
||||||
{ email: "success@test.com", success: true, userId: "u-1" },
|
{ email: "success@test.com", success: true, userId: "u-1" },
|
||||||
{ email: "fail@test.com", success: false, message: "Invalid format" },
|
{
|
||||||
|
email: "fail@test.com",
|
||||||
|
success: false,
|
||||||
|
message: "Invalid format",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -69,7 +87,9 @@ test.describe("Users Bulk Upload", () => {
|
|||||||
|
|
||||||
// Directly set internal state for testing results view if file simulation is hard
|
// Directly set internal state for testing results view if file simulation is hard
|
||||||
// But let's assume we want to see the "Start Upload" button disabled initially
|
// But let's assume we want to see the "Start Upload" button disabled initially
|
||||||
const uploadBtn = page.getByRole("button", { name: /등록 시작|Start Upload/i });
|
const uploadBtn = page.getByRole("button", {
|
||||||
|
name: /등록 시작|Start Upload/i,
|
||||||
|
});
|
||||||
await expect(uploadBtn).toBeDisabled();
|
await expect(uploadBtn).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,19 +10,31 @@ test.describe("User Schema Dynamic Form", () => {
|
|||||||
const authData = {
|
const authData = {
|
||||||
access_token: "fake-token",
|
access_token: "fake-token",
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
profile: { sub: "admin-user", name: "Admin User", email: "admin@example.com" },
|
profile: {
|
||||||
|
sub: "admin-user",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@example.com",
|
||||||
|
},
|
||||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
};
|
};
|
||||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/oidc/.well-known/openid-configuration", async (route) => {
|
await page.route(
|
||||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
"**/oidc/.well-known/openid-configuration",
|
||||||
});
|
async (route) => {
|
||||||
|
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await page.route("**/api/v1/user/me", async (route) => {
|
await page.route("**/api/v1/user/me", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: { id: "admin-user", name: "Admin User", email: "admin@example.com", role: "super_admin" },
|
json: {
|
||||||
|
id: "admin-user",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@example.com",
|
||||||
|
role: "super_admin",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,11 +46,21 @@ test.describe("User Schema Dynamic Form", () => {
|
|||||||
slug: "test-tenant",
|
slug: "test-tenant",
|
||||||
config: {
|
config: {
|
||||||
userSchema: [
|
userSchema: [
|
||||||
{ key: "emp_id", label: "Employee ID", required: true, validation: "^E[0-9]{3}$" },
|
{
|
||||||
{ key: "salary", label: "Salary", adminOnly: true, type: "number" }
|
key: "emp_id",
|
||||||
]
|
label: "Employee ID",
|
||||||
}
|
required: true,
|
||||||
}
|
validation: "^E[0-9]{3}$",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "salary",
|
||||||
|
label: "Salary",
|
||||||
|
adminOnly: true,
|
||||||
|
type: "number",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,38 +72,51 @@ test.describe("User Schema Dynamic Form", () => {
|
|||||||
name: "John Doe",
|
name: "John Doe",
|
||||||
email: "john@test.com",
|
email: "john@test.com",
|
||||||
companyCode: "test-tenant",
|
companyCode: "test-tenant",
|
||||||
metadata: { emp_id: "E123", salary: 1000 }
|
metadata: { emp_id: "E123", salary: 1000 },
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||||
if (route.request().method() === "GET") {
|
if (route.request().method() === "GET") {
|
||||||
await route.fulfill({ json: { items: [{id: "t-1", slug: "test-tenant", name: "Test Tenant"}], total: 1 } });
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [{ id: "t-1", slug: "test-tenant", name: "Test Tenant" }],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should render custom fields from schema in user detail", async ({ page }) => {
|
test("should render custom fields from schema in user detail", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/users/u-1");
|
await page.goto("/users/u-1");
|
||||||
|
|
||||||
await expect(page.getByText("테넌트 확장 정보 (Custom Fields)")).toBeVisible();
|
await expect(
|
||||||
|
page.getByText("테넌트 확장 정보 (Custom Fields)"),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByLabel("Employee ID")).toHaveValue("E123");
|
await expect(page.getByLabel("Employee ID")).toHaveValue("E123");
|
||||||
await expect(page.getByLabel("Salary")).toHaveValue("1000");
|
await expect(page.getByLabel("Salary")).toHaveValue("1000");
|
||||||
|
|
||||||
// Check for Admin Only badge
|
// Check for Admin Only badge
|
||||||
await expect(page.getByText("Admin Only")).toBeVisible();
|
await expect(page.getByText("Admin Only")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show regex validation error for custom field", async ({ page }) => {
|
test("should show regex validation error for custom field", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/users/u-1");
|
await page.goto("/users/u-1");
|
||||||
|
|
||||||
const empIdInput = page.getByLabel("Employee ID");
|
const empIdInput = page.getByLabel("Employee ID");
|
||||||
await empIdInput.fill("invalid");
|
await empIdInput.fill("invalid");
|
||||||
|
|
||||||
// Click somewhere to trigger blur/validation
|
// Click somewhere to trigger blur/validation
|
||||||
await page.getByLabel("이름").click();
|
await page.getByLabel("이름").click();
|
||||||
|
|
||||||
await expect(page.getByText("Employee ID 형식이 올바르지 않습니다.")).toBeVisible();
|
await expect(
|
||||||
|
page.getByText("Employee ID 형식이 올바르지 않습니다."),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ func (m *MockAuditRepository) Create(log *domain.AuditLog) error {
|
|||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAuditRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) {
|
func (m *MockAuditRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) {
|
||||||
args := m.Called(ctx, limit, cursor)
|
args := m.Called(ctx, limit, cursor, tenantID)
|
||||||
return args.Get(0).([]domain.AuditLog), args.Error(1)
|
return args.Get(0).([]domain.AuditLog), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
368
locales/en.toml
368
locales/en.toml
File diff suppressed because one or more lines are too long
335
locales/ko.toml
335
locales/ko.toml
File diff suppressed because one or more lines are too long
@@ -13,11 +13,35 @@ jangheon = ""
|
|||||||
ptc = ""
|
ptc = ""
|
||||||
saman = ""
|
saman = ""
|
||||||
|
|
||||||
|
[domain.tenant_type]
|
||||||
|
company = ""
|
||||||
|
company_group = ""
|
||||||
|
personal = ""
|
||||||
|
user_group = ""
|
||||||
|
|
||||||
[err]
|
[err]
|
||||||
|
|
||||||
[err.common]
|
[err.common]
|
||||||
unknown = ""
|
unknown = ""
|
||||||
|
|
||||||
|
[err.backend]
|
||||||
|
authorization_pending = ""
|
||||||
|
bad_request = ""
|
||||||
|
conflict = ""
|
||||||
|
expired_token = ""
|
||||||
|
forbidden = ""
|
||||||
|
internal_error = ""
|
||||||
|
invalid_code = ""
|
||||||
|
invalid_or_expired_code = ""
|
||||||
|
invalid_session = ""
|
||||||
|
invalid_session_reference = ""
|
||||||
|
not_found = ""
|
||||||
|
not_supported = ""
|
||||||
|
password_or_email_mismatch = ""
|
||||||
|
rate_limited = ""
|
||||||
|
service_unavailable = ""
|
||||||
|
slow_down = ""
|
||||||
|
|
||||||
[err.userfront]
|
[err.userfront]
|
||||||
|
|
||||||
[err.userfront.auth_proxy]
|
[err.userfront.auth_proxy]
|
||||||
@@ -49,6 +73,9 @@ scope_admin = ""
|
|||||||
session_ttl = ""
|
session_ttl = ""
|
||||||
tenant_headers = ""
|
tenant_headers = ""
|
||||||
|
|
||||||
|
[msg.admin.common]
|
||||||
|
forbidden = ""
|
||||||
|
|
||||||
[msg.admin.api_keys]
|
[msg.admin.api_keys]
|
||||||
|
|
||||||
[msg.admin.api_keys.create]
|
[msg.admin.api_keys.create]
|
||||||
@@ -90,16 +117,35 @@ count = ""
|
|||||||
[msg.admin.groups]
|
[msg.admin.groups]
|
||||||
|
|
||||||
[msg.admin.groups.list]
|
[msg.admin.groups.list]
|
||||||
|
create_error = ""
|
||||||
|
create_success = ""
|
||||||
|
delete_confirm = ""
|
||||||
|
delete_error = ""
|
||||||
|
delete_success = ""
|
||||||
|
empty = ""
|
||||||
|
import_error = ""
|
||||||
|
import_success = ""
|
||||||
|
loading = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.admin.groups.members]
|
[msg.admin.groups.members]
|
||||||
|
add_success = ""
|
||||||
count = ""
|
count = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
|
remove_confirm = ""
|
||||||
|
remove_success = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[msg.admin.groups.prompt]
|
[msg.admin.groups.prompt]
|
||||||
user_id = ""
|
user_id = ""
|
||||||
|
|
||||||
|
[msg.admin.groups.roles]
|
||||||
|
assign_success = ""
|
||||||
|
description = ""
|
||||||
|
empty = ""
|
||||||
|
remove_confirm = ""
|
||||||
|
remove_success = ""
|
||||||
|
|
||||||
[msg.admin.header]
|
[msg.admin.header]
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
@@ -107,6 +153,12 @@ subtitle = ""
|
|||||||
idp_policy = ""
|
idp_policy = ""
|
||||||
scope = ""
|
scope = ""
|
||||||
|
|
||||||
|
[msg.admin.org]
|
||||||
|
hover_member_info = ""
|
||||||
|
import_description = ""
|
||||||
|
import_error = ""
|
||||||
|
import_success = ""
|
||||||
|
|
||||||
[msg.admin.overview]
|
[msg.admin.overview]
|
||||||
description = ""
|
description = ""
|
||||||
idp_fallback = ""
|
idp_fallback = ""
|
||||||
@@ -122,14 +174,38 @@ tenant_title = ""
|
|||||||
[msg.admin.overview.quick_links]
|
[msg.admin.overview.quick_links]
|
||||||
description = ""
|
description = ""
|
||||||
|
|
||||||
|
[msg.admin.overview.summary]
|
||||||
|
audit_events_24h = ""
|
||||||
|
oidc_clients = ""
|
||||||
|
policy_gate = ""
|
||||||
|
total_tenants = ""
|
||||||
|
|
||||||
[msg.admin.tenants]
|
[msg.admin.tenants]
|
||||||
|
approve_confirm = ""
|
||||||
|
approve_success = ""
|
||||||
delete_confirm = ""
|
delete_confirm = ""
|
||||||
|
delete_success = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
fetch_error = ""
|
fetch_error = ""
|
||||||
|
missing_id = ""
|
||||||
not_found = ""
|
not_found = ""
|
||||||
remove_sub_confirm = ""
|
remove_sub_confirm = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
|
[msg.admin.tenants.admins]
|
||||||
|
add_success = ""
|
||||||
|
empty = ""
|
||||||
|
remove_confirm = ""
|
||||||
|
remove_success = ""
|
||||||
|
subtitle = ""
|
||||||
|
|
||||||
|
[msg.admin.tenants.owners]
|
||||||
|
add_success = ""
|
||||||
|
empty = ""
|
||||||
|
remove_confirm = ""
|
||||||
|
remove_success = ""
|
||||||
|
subtitle = ""
|
||||||
|
|
||||||
[msg.admin.tenants.create]
|
[msg.admin.tenants.create]
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
@@ -164,6 +240,16 @@ subtitle = ""
|
|||||||
|
|
||||||
[msg.admin.users]
|
[msg.admin.users]
|
||||||
|
|
||||||
|
[msg.admin.users.bulk]
|
||||||
|
delete_confirm = ""
|
||||||
|
delete_success = ""
|
||||||
|
description = ""
|
||||||
|
move_description = ""
|
||||||
|
move_error = ""
|
||||||
|
move_success = ""
|
||||||
|
parsed_count = ""
|
||||||
|
update_success = ""
|
||||||
|
|
||||||
[msg.admin.users.create]
|
[msg.admin.users.create]
|
||||||
error = ""
|
error = ""
|
||||||
password_required = ""
|
password_required = ""
|
||||||
@@ -174,6 +260,8 @@ subtitle = ""
|
|||||||
|
|
||||||
[msg.admin.users.create.form]
|
[msg.admin.users.create.form]
|
||||||
email_required = ""
|
email_required = ""
|
||||||
|
field_invalid = ""
|
||||||
|
field_required = ""
|
||||||
name_required = ""
|
name_required = ""
|
||||||
password_auto_help = ""
|
password_auto_help = ""
|
||||||
password_manual_help = ""
|
password_manual_help = ""
|
||||||
@@ -190,6 +278,7 @@ update_error = ""
|
|||||||
update_success = ""
|
update_success = ""
|
||||||
|
|
||||||
[msg.admin.users.detail.form]
|
[msg.admin.users.detail.form]
|
||||||
|
field_required = ""
|
||||||
name_required = ""
|
name_required = ""
|
||||||
|
|
||||||
[msg.admin.users.detail.security]
|
[msg.admin.users.detail.security]
|
||||||
@@ -201,13 +290,20 @@ empty = ""
|
|||||||
fetch_error = ""
|
fetch_error = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
|
[msg.admin.users.list.columns]
|
||||||
|
description = ""
|
||||||
|
no_custom = ""
|
||||||
|
|
||||||
[msg.admin.users.list.registry]
|
[msg.admin.users.list.registry]
|
||||||
count = ""
|
count = ""
|
||||||
|
|
||||||
[msg.common]
|
[msg.common]
|
||||||
|
error = ""
|
||||||
loading = ""
|
loading = ""
|
||||||
saving = ""
|
no_description = ""
|
||||||
|
parsing = ""
|
||||||
requesting = ""
|
requesting = ""
|
||||||
|
saving = ""
|
||||||
unknown_error = ""
|
unknown_error = ""
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
@@ -676,16 +772,28 @@ status = ""
|
|||||||
time = ""
|
time = ""
|
||||||
|
|
||||||
[ui.admin.groups]
|
[ui.admin.groups]
|
||||||
|
import_csv = ""
|
||||||
|
|
||||||
[ui.admin.groups.create]
|
[ui.admin.groups.create]
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.groups.detail]
|
||||||
|
breadcrumb_org = ""
|
||||||
|
breadcrumb_tenant = ""
|
||||||
|
breadcrumb_unit = ""
|
||||||
|
members_subtitle = ""
|
||||||
|
members_title = ""
|
||||||
|
permissions_subtitle = ""
|
||||||
|
permissions_title = ""
|
||||||
|
|
||||||
[ui.admin.groups.form]
|
[ui.admin.groups.form]
|
||||||
desc_label = ""
|
desc_label = ""
|
||||||
desc_placeholder = ""
|
desc_placeholder = ""
|
||||||
name_label = ""
|
name_label = ""
|
||||||
name_placeholder = ""
|
name_placeholder = ""
|
||||||
submit = ""
|
submit = ""
|
||||||
|
unit_level_label = ""
|
||||||
|
unit_level_placeholder = ""
|
||||||
|
|
||||||
[ui.admin.groups.list]
|
[ui.admin.groups.list]
|
||||||
title = ""
|
title = ""
|
||||||
@@ -717,6 +825,12 @@ user_groups = ""
|
|||||||
tenants = ""
|
tenants = ""
|
||||||
users = ""
|
users = ""
|
||||||
|
|
||||||
|
[ui.admin.org]
|
||||||
|
download_template = ""
|
||||||
|
import_btn = ""
|
||||||
|
import_title = ""
|
||||||
|
start_import = ""
|
||||||
|
|
||||||
[ui.admin.overview]
|
[ui.admin.overview]
|
||||||
kicker = ""
|
kicker = ""
|
||||||
title = ""
|
title = ""
|
||||||
@@ -726,10 +840,20 @@ title = ""
|
|||||||
|
|
||||||
[ui.admin.overview.quick_links]
|
[ui.admin.overview.quick_links]
|
||||||
add_tenant = ""
|
add_tenant = ""
|
||||||
tenant_dashboard = ""
|
api_key_management = ""
|
||||||
|
user_management = ""
|
||||||
title = ""
|
title = ""
|
||||||
view_audit_logs = ""
|
view_audit_logs = ""
|
||||||
|
|
||||||
|
[ui.admin.overview.summary]
|
||||||
|
audit_events_24h = ""
|
||||||
|
oidc_clients = ""
|
||||||
|
policy_gate = ""
|
||||||
|
total_tenants = ""
|
||||||
|
|
||||||
|
[ui.admin.profile]
|
||||||
|
manageable_tenants = ""
|
||||||
|
|
||||||
[ui.admin.role]
|
[ui.admin.role]
|
||||||
rp_admin = ""
|
rp_admin = ""
|
||||||
super_admin = ""
|
super_admin = ""
|
||||||
@@ -740,6 +864,31 @@ user = ""
|
|||||||
add = ""
|
add = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.tenants.admins]
|
||||||
|
add_button = ""
|
||||||
|
already_admin = ""
|
||||||
|
dialog_description = ""
|
||||||
|
dialog_no_results = ""
|
||||||
|
dialog_search_hint = ""
|
||||||
|
dialog_search_placeholder = ""
|
||||||
|
dialog_title = ""
|
||||||
|
remove_title = ""
|
||||||
|
table_actions = ""
|
||||||
|
table_email = ""
|
||||||
|
table_name = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.tenants.owners]
|
||||||
|
add_button = ""
|
||||||
|
already_owner = ""
|
||||||
|
dialog_description = ""
|
||||||
|
dialog_title = ""
|
||||||
|
remove_title = ""
|
||||||
|
table_actions = ""
|
||||||
|
table_email = ""
|
||||||
|
table_name = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.admin.tenants.breadcrumb]
|
[ui.admin.tenants.breadcrumb]
|
||||||
list = ""
|
list = ""
|
||||||
section = ""
|
section = ""
|
||||||
@@ -756,9 +905,11 @@ description = ""
|
|||||||
domains_label = ""
|
domains_label = ""
|
||||||
domains_placeholder = ""
|
domains_placeholder = ""
|
||||||
name = ""
|
name = ""
|
||||||
|
parent = ""
|
||||||
slug = ""
|
slug = ""
|
||||||
slug_placeholder = ""
|
slug_placeholder = ""
|
||||||
status = ""
|
status = ""
|
||||||
|
type = ""
|
||||||
|
|
||||||
[ui.admin.tenants.create.memo]
|
[ui.admin.tenants.create.memo]
|
||||||
title = ""
|
title = ""
|
||||||
@@ -766,12 +917,27 @@ title = ""
|
|||||||
[ui.admin.tenants.create.profile]
|
[ui.admin.tenants.create.profile]
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.tenants.detail]
|
||||||
|
breadcrumb_list = ""
|
||||||
|
header_subtitle = ""
|
||||||
|
loading = ""
|
||||||
|
tab_federation = ""
|
||||||
|
tab_organization = ""
|
||||||
|
tab_permissions = ""
|
||||||
|
tab_profile = ""
|
||||||
|
tab_schema = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.tenants.list]
|
||||||
|
select_placeholder = ""
|
||||||
|
|
||||||
[ui.admin.tenants.members]
|
[ui.admin.tenants.members]
|
||||||
descendants = ""
|
descendants = ""
|
||||||
direct = ""
|
direct = ""
|
||||||
direct_label = ""
|
direct_label = ""
|
||||||
list_title = ""
|
list_title = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
total = ""
|
||||||
total_label = ""
|
total_label = ""
|
||||||
|
|
||||||
[ui.admin.tenants.members.table]
|
[ui.admin.tenants.members.table]
|
||||||
@@ -789,14 +955,18 @@ save = ""
|
|||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
[ui.admin.tenants.schema.field]
|
[ui.admin.tenants.schema.field]
|
||||||
|
admin_only = ""
|
||||||
key = ""
|
key = ""
|
||||||
key_placeholder = ""
|
key_placeholder = ""
|
||||||
label = ""
|
label = ""
|
||||||
label_placeholder = ""
|
label_placeholder = ""
|
||||||
|
required = ""
|
||||||
type = ""
|
type = ""
|
||||||
type_boolean = ""
|
type_boolean = ""
|
||||||
|
type_date = ""
|
||||||
type_number = ""
|
type_number = ""
|
||||||
type_text = ""
|
type_text = ""
|
||||||
|
validation_placeholder = ""
|
||||||
|
|
||||||
[ui.admin.tenants.sub]
|
[ui.admin.tenants.sub]
|
||||||
add = ""
|
add = ""
|
||||||
@@ -807,6 +977,7 @@ manage = ""
|
|||||||
no_candidates = ""
|
no_candidates = ""
|
||||||
search_placeholder = ""
|
search_placeholder = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
tree_search_placeholder = ""
|
||||||
|
|
||||||
[ui.admin.tenants.sub.table]
|
[ui.admin.tenants.sub.table]
|
||||||
action = ""
|
action = ""
|
||||||
@@ -820,10 +991,22 @@ members = ""
|
|||||||
name = ""
|
name = ""
|
||||||
slug = ""
|
slug = ""
|
||||||
status = ""
|
status = ""
|
||||||
|
type = ""
|
||||||
updated = ""
|
updated = ""
|
||||||
|
|
||||||
[ui.admin.users]
|
[ui.admin.users]
|
||||||
|
|
||||||
|
[ui.admin.users.bulk]
|
||||||
|
do_move = ""
|
||||||
|
download_template = ""
|
||||||
|
move_group = ""
|
||||||
|
move_title = ""
|
||||||
|
no_department = ""
|
||||||
|
select_group = ""
|
||||||
|
selected_count = ""
|
||||||
|
start_upload = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.admin.users.create]
|
[ui.admin.users.create]
|
||||||
back = ""
|
back = ""
|
||||||
go_list = ""
|
go_list = ""
|
||||||
@@ -868,13 +1051,10 @@ title = ""
|
|||||||
section = ""
|
section = ""
|
||||||
|
|
||||||
[ui.admin.users.detail.custom_fields]
|
[ui.admin.users.detail.custom_fields]
|
||||||
title = ""
|
multi_title = ""
|
||||||
|
|
||||||
[ui.admin.users.detail.form]
|
[ui.admin.users.detail.form]
|
||||||
department = ""
|
name_required = ""
|
||||||
department_placeholder = ""
|
|
||||||
name = ""
|
|
||||||
name_placeholder = ""
|
|
||||||
phone = ""
|
phone = ""
|
||||||
phone_placeholder = ""
|
phone_placeholder = ""
|
||||||
role = ""
|
role = ""
|
||||||
@@ -887,21 +1067,32 @@ password = ""
|
|||||||
password_placeholder = ""
|
password_placeholder = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.users.detail.tenants_section]
|
||||||
|
additional = ""
|
||||||
|
primary = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.admin.users.list]
|
[ui.admin.users.list]
|
||||||
add = ""
|
add = ""
|
||||||
delete_aria = ""
|
bulk_import = ""
|
||||||
edit_aria = ""
|
empty = ""
|
||||||
|
fetch_error = ""
|
||||||
search_placeholder = ""
|
search_placeholder = ""
|
||||||
tenant_slug = ""
|
subtitle = ""
|
||||||
title = ""
|
|
||||||
|
|
||||||
[ui.admin.users.list.breadcrumb]
|
[ui.admin.users.list.breadcrumb]
|
||||||
list = ""
|
list = ""
|
||||||
section = ""
|
section = ""
|
||||||
|
|
||||||
[ui.admin.users.list.registry]
|
[ui.admin.users.list.columns]
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.users.list.filter]
|
||||||
|
tenant = ""
|
||||||
|
|
||||||
|
[ui.admin.users.list.registry]
|
||||||
|
count = ""
|
||||||
|
|
||||||
[ui.admin.users.list.table]
|
[ui.admin.users.list.table]
|
||||||
actions = ""
|
actions = ""
|
||||||
created = ""
|
created = ""
|
||||||
@@ -918,10 +1109,13 @@ role = ""
|
|||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
add = ""
|
add = ""
|
||||||
|
all = ""
|
||||||
admin_only = ""
|
admin_only = ""
|
||||||
assign = ""
|
assign = ""
|
||||||
back = ""
|
back = ""
|
||||||
cancel = ""
|
cancel = ""
|
||||||
|
change_file = ""
|
||||||
|
clear_search = ""
|
||||||
close = ""
|
close = ""
|
||||||
collapse = ""
|
collapse = ""
|
||||||
confirm = ""
|
confirm = ""
|
||||||
@@ -930,6 +1124,9 @@ create = ""
|
|||||||
delete = ""
|
delete = ""
|
||||||
details = ""
|
details = ""
|
||||||
edit = ""
|
edit = ""
|
||||||
|
export = ""
|
||||||
|
fail = ""
|
||||||
|
go_home = ""
|
||||||
view = ""
|
view = ""
|
||||||
hyphen = ""
|
hyphen = ""
|
||||||
manage = ""
|
manage = ""
|
||||||
@@ -945,17 +1142,18 @@ reset = ""
|
|||||||
read_only = ""
|
read_only = ""
|
||||||
refresh = ""
|
refresh = ""
|
||||||
remove = ""
|
remove = ""
|
||||||
requesting = ""
|
|
||||||
resend = ""
|
resend = ""
|
||||||
retry = ""
|
retry = ""
|
||||||
save = ""
|
save = ""
|
||||||
search = ""
|
search = ""
|
||||||
select = ""
|
select = ""
|
||||||
|
select_file = ""
|
||||||
select_placeholder = ""
|
select_placeholder = ""
|
||||||
show_more = ""
|
show_more = ""
|
||||||
language = ""
|
language = ""
|
||||||
language_ko = ""
|
language_ko = ""
|
||||||
language_en = ""
|
language_en = ""
|
||||||
|
success = ""
|
||||||
theme_dark = ""
|
theme_dark = ""
|
||||||
theme_light = ""
|
theme_light = ""
|
||||||
theme_toggle = ""
|
theme_toggle = ""
|
||||||
@@ -966,10 +1164,6 @@ admin_only = ""
|
|||||||
command_only = ""
|
command_only = ""
|
||||||
system = ""
|
system = ""
|
||||||
|
|
||||||
[ui.common.role]
|
|
||||||
admin = ""
|
|
||||||
user = ""
|
|
||||||
|
|
||||||
[ui.common.status]
|
[ui.common.status]
|
||||||
active = ""
|
active = ""
|
||||||
blocked = ""
|
blocked = ""
|
||||||
@@ -992,7 +1186,6 @@ env_badge = ""
|
|||||||
scope_badge = ""
|
scope_badge = ""
|
||||||
|
|
||||||
[ui.dev.nav]
|
[ui.dev.nav]
|
||||||
audit_logs = ""
|
|
||||||
clients = ""
|
clients = ""
|
||||||
logout = ""
|
logout = ""
|
||||||
|
|
||||||
@@ -1040,10 +1233,8 @@ type_label = ""
|
|||||||
export_csv = ""
|
export_csv = ""
|
||||||
revoke = ""
|
revoke = ""
|
||||||
revoked_at = ""
|
revoked_at = ""
|
||||||
scope_all = ""
|
|
||||||
scope_label = ""
|
scope_label = ""
|
||||||
search_placeholder = ""
|
search_placeholder = ""
|
||||||
status_all = ""
|
|
||||||
status_label = ""
|
status_label = ""
|
||||||
status_revoked = ""
|
status_revoked = ""
|
||||||
subject = ""
|
subject = ""
|
||||||
@@ -1051,7 +1242,6 @@ title = ""
|
|||||||
|
|
||||||
[ui.dev.clients.consents.breadcrumb]
|
[ui.dev.clients.consents.breadcrumb]
|
||||||
clients = ""
|
clients = ""
|
||||||
current = ""
|
|
||||||
home = ""
|
home = ""
|
||||||
|
|
||||||
[ui.dev.clients.consents.filters]
|
[ui.dev.clients.consents.filters]
|
||||||
@@ -1062,13 +1252,6 @@ active_grants = ""
|
|||||||
avg_scopes = ""
|
avg_scopes = ""
|
||||||
total_scopes = ""
|
total_scopes = ""
|
||||||
|
|
||||||
[ui.dev.clients.stats]
|
|
||||||
total = ""
|
|
||||||
active_sessions = ""
|
|
||||||
auth_failures = ""
|
|
||||||
realtime = ""
|
|
||||||
stable = ""
|
|
||||||
|
|
||||||
[ui.dev.clients.consents.table]
|
[ui.dev.clients.consents.table]
|
||||||
action = ""
|
action = ""
|
||||||
first_granted = ""
|
first_granted = ""
|
||||||
@@ -1080,10 +1263,6 @@ user = ""
|
|||||||
|
|
||||||
[ui.dev.clients.details]
|
[ui.dev.clients.details]
|
||||||
|
|
||||||
[ui.dev.clients.details.breadcrumb]
|
|
||||||
current = ""
|
|
||||||
section = ""
|
|
||||||
|
|
||||||
[ui.dev.clients.details.credentials]
|
[ui.dev.clients.details.credentials]
|
||||||
client_id = ""
|
client_id = ""
|
||||||
client_secret = ""
|
client_secret = ""
|
||||||
@@ -1124,9 +1303,6 @@ title = ""
|
|||||||
add_title = ""
|
add_title = ""
|
||||||
add_btn = ""
|
add_btn = ""
|
||||||
|
|
||||||
[ui.dev.clients.general.breadcrumb]
|
|
||||||
section = ""
|
|
||||||
|
|
||||||
[ui.dev.clients.general.identity]
|
[ui.dev.clients.general.identity]
|
||||||
description = ""
|
description = ""
|
||||||
description_placeholder = ""
|
description_placeholder = ""
|
||||||
@@ -1441,151 +1617,3 @@ verify = ""
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = ""
|
action = ""
|
||||||
|
|
||||||
|
|
||||||
# Auto-added missing keys
|
|
||||||
|
|
||||||
[domain.tenant_type]
|
|
||||||
company = ""
|
|
||||||
company_group = ""
|
|
||||||
personal = ""
|
|
||||||
user_group = ""
|
|
||||||
|
|
||||||
[msg.admin.groups.list]
|
|
||||||
create_error = ""
|
|
||||||
create_success = ""
|
|
||||||
delete_confirm = ""
|
|
||||||
delete_error = ""
|
|
||||||
delete_success = ""
|
|
||||||
empty = ""
|
|
||||||
loading = ""
|
|
||||||
|
|
||||||
[msg.admin.groups.members]
|
|
||||||
add_success = ""
|
|
||||||
remove_confirm = ""
|
|
||||||
remove_success = ""
|
|
||||||
|
|
||||||
[msg.admin.groups.roles]
|
|
||||||
assign_success = ""
|
|
||||||
description = ""
|
|
||||||
empty = ""
|
|
||||||
remove_confirm = ""
|
|
||||||
remove_success = ""
|
|
||||||
|
|
||||||
[msg.admin.tenants.admins]
|
|
||||||
add_success = ""
|
|
||||||
empty = ""
|
|
||||||
remove_confirm = ""
|
|
||||||
remove_success = ""
|
|
||||||
subtitle = ""
|
|
||||||
|
|
||||||
[msg.admin.tenants.owners]
|
|
||||||
add_success = ""
|
|
||||||
empty = ""
|
|
||||||
remove_confirm = ""
|
|
||||||
remove_success = ""
|
|
||||||
subtitle = ""
|
|
||||||
|
|
||||||
[msg.admin.tenants]
|
|
||||||
approve_confirm = ""
|
|
||||||
approve_success = ""
|
|
||||||
delete_success = ""
|
|
||||||
missing_id = ""
|
|
||||||
|
|
||||||
[msg.common]
|
|
||||||
error = ""
|
|
||||||
no_description = ""
|
|
||||||
|
|
||||||
[ui.admin.groups]
|
|
||||||
add_unit = ""
|
|
||||||
|
|
||||||
[ui.admin.groups.create]
|
|
||||||
description = ""
|
|
||||||
|
|
||||||
[ui.admin.groups.detail]
|
|
||||||
breadcrumb_org = ""
|
|
||||||
breadcrumb_tenant = ""
|
|
||||||
breadcrumb_unit = ""
|
|
||||||
members_subtitle = ""
|
|
||||||
members_title = ""
|
|
||||||
permissions_subtitle = ""
|
|
||||||
permissions_title = ""
|
|
||||||
|
|
||||||
[ui.admin.groups.form]
|
|
||||||
parent_label = ""
|
|
||||||
parent_none = ""
|
|
||||||
unit_level_label = ""
|
|
||||||
unit_level_placeholder = ""
|
|
||||||
|
|
||||||
[ui.admin.tenants.admins]
|
|
||||||
add_button = ""
|
|
||||||
already_admin = ""
|
|
||||||
dialog_description = ""
|
|
||||||
dialog_no_results = ""
|
|
||||||
dialog_search_hint = ""
|
|
||||||
dialog_search_placeholder = ""
|
|
||||||
dialog_title = ""
|
|
||||||
remove_title = ""
|
|
||||||
table_actions = ""
|
|
||||||
table_email = ""
|
|
||||||
table_name = ""
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
[ui.admin.tenants.owners]
|
|
||||||
add_button = ""
|
|
||||||
already_owner = ""
|
|
||||||
dialog_description = ""
|
|
||||||
dialog_title = ""
|
|
||||||
remove_title = ""
|
|
||||||
table_actions = ""
|
|
||||||
table_email = ""
|
|
||||||
table_name = ""
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
[ui.admin.tenants.create.form]
|
|
||||||
parent = ""
|
|
||||||
type = ""
|
|
||||||
|
|
||||||
[ui.admin.tenants.detail]
|
|
||||||
breadcrumb_list = ""
|
|
||||||
header_subtitle = ""
|
|
||||||
loading = ""
|
|
||||||
tab_federation = ""
|
|
||||||
tab_organization = ""
|
|
||||||
tab_permissions = ""
|
|
||||||
tab_profile = ""
|
|
||||||
tab_schema = ""
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
[ui.admin.tenants.list]
|
|
||||||
select_placeholder = ""
|
|
||||||
|
|
||||||
[ui.admin.tenants.profile]
|
|
||||||
allowed_domains = ""
|
|
||||||
allowed_domains_help = ""
|
|
||||||
approve_button = ""
|
|
||||||
description = ""
|
|
||||||
name = ""
|
|
||||||
slug = ""
|
|
||||||
status = ""
|
|
||||||
subtitle = ""
|
|
||||||
title = ""
|
|
||||||
type = ""
|
|
||||||
|
|
||||||
[ui.admin.tenants.table]
|
|
||||||
type = ""
|
|
||||||
|
|
||||||
[ui.admin.users.create.form]
|
|
||||||
job_title = ""
|
|
||||||
job_title_placeholder = ""
|
|
||||||
position = ""
|
|
||||||
position_placeholder = ""
|
|
||||||
|
|
||||||
[ui.admin.users.detail.form]
|
|
||||||
job_title = ""
|
|
||||||
job_title_placeholder = ""
|
|
||||||
position = ""
|
|
||||||
position_placeholder = ""
|
|
||||||
|
|
||||||
[ui.admin.users.list.table]
|
|
||||||
position_job = ""
|
|
||||||
|
|||||||
Reference in New Issue
Block a user