1
0
forked from baron/baron-sso

린트 적용

This commit is contained in:
2026-03-05 17:50:34 +09:00
parent c2b55081a6
commit 45ae1bb1c0
21 changed files with 1114 additions and 810 deletions

View File

@@ -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 {
@@ -16,7 +16,11 @@ interface RoleGuardProps {
* <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,

View File

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

View File

@@ -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>
@@ -84,7 +91,12 @@ function GlobalOverviewPage() {
<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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";
@@ -40,12 +47,12 @@ 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,13 +315,19 @@ 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 */}
@@ -296,20 +336,34 @@ function UserDetailPage() {
<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">
. .

View File

@@ -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>
@@ -446,7 +488,9 @@ function UserListPage() {
{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,7 +579,9 @@ 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

View File

@@ -15,11 +15,11 @@ import {
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 {
type GroupSummary,
type TenantSummary,
bulkUpdateUsers, bulkUpdateUsers,
fetchGroups, fetchGroups,
fetchTenants, fetchTenants,
type TenantSummary,
type GroupSummary
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
@@ -28,9 +28,13 @@ 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("");
@@ -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} />
@@ -170,7 +204,9 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
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>

View File

@@ -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>
))} ))}
@@ -211,7 +279,9 @@ ${example}`], { type: "text/csv" });
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>
) : ( ) : (

View File

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

View File

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

View File

@@ -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,9 +88,13 @@ 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();

View File

@@ -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",
},
}); });
}); });
@@ -43,12 +51,18 @@ 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();
}); });
}); });

View File

@@ -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,22 +72,31 @@ 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");
@@ -73,7 +104,9 @@ test.describe("User Schema Dynamic Form", () => {
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");
@@ -82,6 +115,8 @@ test.describe("User Schema Dynamic Form", () => {
// 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();
}); });
}); });

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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