Merge branch 'dev' into fix/login
This commit is contained in:
commit
725ba375d2
67 files changed
+7241
-2392
No files matched your search
@@ -6,7 +6,6 @@ import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
import AuthPage from "../features/auth/AuthPage";
|
||||
import LoginPage from "../features/auth/LoginPage";
|
||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||
@@ -35,7 +34,6 @@ export const router = createBrowserRouter(
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <GlobalOverviewPage /> },
|
||||
{ path: "dashboard", element: <DashboardPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "auth", element: <AuthPage /> },
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type * as React from "react";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
|
||||
interface RoleGuardProps {
|
||||
children: React.ReactNode;
|
||||
roles: string[];
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* RoleGuard conditionally renders children based on the current user's role.
|
||||
*
|
||||
* Usage:
|
||||
* <RoleGuard roles={['super_admin']}>
|
||||
* <button>System Only Action</button>
|
||||
* </RoleGuard>
|
||||
*/
|
||||
export function RoleGuard({
|
||||
children,
|
||||
roles,
|
||||
fallback = null,
|
||||
}: RoleGuardProps) {
|
||||
const { data: profile, isLoading } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
const userRole = profile?.role || "user";
|
||||
const hasAccess = roles.includes(userRole);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
User as UserIcon,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
@@ -22,23 +23,14 @@ import { t } from "../../lib/i18n";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
|
||||
const navItems = [
|
||||
const staticNavItems = [
|
||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||
{
|
||||
label: "ui.admin.nav.tenant_dashboard",
|
||||
to: "/dashboard",
|
||||
icon: ShieldHalf,
|
||||
},
|
||||
{
|
||||
label: "ui.admin.nav.tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
},
|
||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
||||
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
||||
];
|
||||
|
||||
function AppLayout() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -51,9 +43,57 @@ function AppLayout() {
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
enabled: auth.isAuthenticated && !auth.isLoading,
|
||||
enabled:
|
||||
(auth.isAuthenticated && !auth.isLoading) ||
|
||||
import.meta.env.MODE === "development" ||
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true,
|
||||
});
|
||||
|
||||
const navItems = React.useMemo(() => {
|
||||
const items = [...staticNavItems];
|
||||
const isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
|
||||
// 테스트 모드이면 profile이 없어도 super_admin으로 간주하여 모든 메뉴 렌더링
|
||||
const isSuperAdmin = isTest || profile?.role === "super_admin";
|
||||
const isTenantAdmin = profile?.role === "tenant_admin";
|
||||
const manageableCount = profile?.manageableTenants?.length ?? 0;
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (isTest) return true;
|
||||
if (item.to === "/api-keys") return isSuperAdmin;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (isSuperAdmin) {
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
} else if (isTenantAdmin || manageableCount > 0) {
|
||||
if (manageableCount <= 1 && profile?.tenantId) {
|
||||
// Direct link if only one (or zero in array but has tenantId) tenant
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.my_tenant",
|
||||
to: `/tenants/${profile.tenantId}`,
|
||||
icon: Building2,
|
||||
});
|
||||
} else if (manageableCount > 1) {
|
||||
// Show list menu if multiple tenants
|
||||
filteredItems.splice(1, 0, {
|
||||
label: "ui.admin.nav.tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filteredItems;
|
||||
}, [profile]);
|
||||
|
||||
const handleLogout = () => {
|
||||
if (
|
||||
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
|
||||
@@ -252,6 +292,49 @@ function AppLayout() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manageable Tenants Section */}
|
||||
{profile?.manageableTenants &&
|
||||
profile.manageableTenants.length > 0 && (
|
||||
<div className="px-2 py-2 border-b border-border/50 mb-1">
|
||||
<p className="px-1 mb-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.profile.manageable_tenants",
|
||||
"Manageable Tenants",
|
||||
)}
|
||||
</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1 pr-1 custom-scrollbar">
|
||||
{profile.manageableTenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
navigate(`/tenants/${tenant.id}`);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-left text-muted-foreground transition hover:bg-muted/50 hover:text-foreground group"
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary transition-colors">
|
||||
{tenant.type === "USER_GROUP" ? (
|
||||
<Users size={12} />
|
||||
) : (
|
||||
<Building2 size={12} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className="font-medium truncate">
|
||||
{tenant.name}
|
||||
</span>
|
||||
<span className="text-[9px] opacity-60 font-mono truncate">
|
||||
{tenant.slug}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
LineChart,
|
||||
Radio,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
const guardHighlights = [
|
||||
{
|
||||
title: "Tenant isolation",
|
||||
body: "All admin calls expect X-Tenant-ID and are prepared for tenant-aware headers.",
|
||||
metric: "Header guard",
|
||||
accent: "active",
|
||||
},
|
||||
{
|
||||
title: "Admin TTL",
|
||||
body: "Session budget kept short for admins. App session vs admin session split is explicit.",
|
||||
metric: "15m default",
|
||||
accent: "ttl",
|
||||
},
|
||||
{
|
||||
title: "Audit-first",
|
||||
body: "Every management action should log to ClickHouse. Hooks in place for later wiring.",
|
||||
metric: "per-action",
|
||||
accent: "audit",
|
||||
},
|
||||
];
|
||||
|
||||
const stackReadiness = [
|
||||
"React 19 + Vite 7, strict TS, Router v6 data router.",
|
||||
"TanStack Query 5 provider ready with sane defaults.",
|
||||
"Axios client stub with bearer + tenant header injector.",
|
||||
"Tailwind v4 tokens tuned for admin dark plane.",
|
||||
"React Hook Form + Zod planned for client forms.",
|
||||
"IdP-neutral auth hook point reserved for role guard.",
|
||||
];
|
||||
|
||||
const nextSteps = [
|
||||
"Add IdP-neutral OIDC/OAuth auth layer and enforce admin role in RequireAuth.",
|
||||
"Persist tenant picklist and feed X-Tenant-ID for every admin call.",
|
||||
"Add shadcn/ui primitives for forms and tables; lock lint/format.",
|
||||
];
|
||||
|
||||
function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<section className="relative overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-7 shadow-[var(--shadow-card)]">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_24%_20%,rgba(54,211,153,0.14),transparent_32%)]" />
|
||||
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
<Sparkles size={14} />
|
||||
adminfront ready
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold leading-tight">
|
||||
Build the admin plane with{" "}
|
||||
<span className="text-[var(--color-accent)]">tenant-aware</span>{" "}
|
||||
defaults and{" "}
|
||||
<span className="text-[var(--color-accent-strong)]">
|
||||
least privilege
|
||||
</span>{" "}
|
||||
UX.
|
||||
</h2>
|
||||
<p className="text-[var(--color-muted)]">
|
||||
Route, query, and styling scaffolds are in place. Use this canvas
|
||||
to ship RP registry, audit exploration, and guarded login aligned
|
||||
with issue #60 while keeping providers swappable.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
|
||||
Router + Query wired
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
|
||||
Admin namespace only
|
||||
</span>
|
||||
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
|
||||
Auth hook pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<ShieldCheck size={16} />
|
||||
Admin guard scoped to /admin
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<Building2 size={16} />
|
||||
Tenant selection placeholder ready
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
|
||||
<Radio size={16} />
|
||||
Audit stream hook for ClickHouse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{guardHighlights.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
{item.metric}
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
|
||||
{item.accent}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mt-3 space-y-2">
|
||||
<h3 className="text-lg font-semibold">{item.title}</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-[1.2fr,0.8fr]">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Stack readiness
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold">Matches issue #60</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
Setup notes
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{stackReadiness.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||
>
|
||||
<CheckCircle2
|
||||
size={16}
|
||||
className="text-[var(--color-accent)]"
|
||||
/>
|
||||
<p className="text-sm">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Next actions
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
Ship the first guarded flows
|
||||
</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{nextSteps.map((item, idx) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text)]">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Ops board
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold">What to prototype next</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||
Audit → ClickHouse
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
|
||||
RP registry
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<LineChart size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.16em]">
|
||||
Metrics
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="mt-2 text-lg font-semibold">
|
||||
RP registration funnel
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Visualize create → secret rotate → redirect URI edits per tenant.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Activity size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.16em]">Audit</span>
|
||||
</div>
|
||||
<h4 className="mt-2 text-lg font-semibold">Admin action stream</h4>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Live feed of admin API calls with per-action tenant, actor, and
|
||||
rate-limit outcome.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<ShieldCheck size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.16em]">
|
||||
Access
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="mt-2 text-lg font-semibold">Admin login journey</h4>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Outline SMS + app-based MFA choice and emphasize “admin session”
|
||||
TTL with logout.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
@@ -19,41 +20,6 @@ import {
|
||||
import { t } from "../../lib/i18n";
|
||||
import PermissionChecker from "./components/PermissionChecker";
|
||||
|
||||
const summaryCards = [
|
||||
{
|
||||
labelKey: "ui.admin.overview.summary.total_tenants",
|
||||
labelFallback: "Total Tenants",
|
||||
value: "-",
|
||||
hintKey: "msg.admin.overview.summary.total_tenants",
|
||||
hintFallback: "Tenant-aware core",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.overview.summary.oidc_clients",
|
||||
labelFallback: "OIDC Clients",
|
||||
value: "-",
|
||||
hintKey: "msg.admin.overview.summary.oidc_clients",
|
||||
hintFallback: "Hydra registry",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.overview.summary.audit_events_24h",
|
||||
labelFallback: "Audit Events (24h)",
|
||||
value: "-",
|
||||
hintKey: "msg.admin.overview.summary.audit_events_24h",
|
||||
hintFallback: "ClickHouse stream",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.overview.summary.policy_gate",
|
||||
labelFallback: "Policy Gate",
|
||||
value: "Planned",
|
||||
hintKey: "msg.admin.overview.summary.policy_gate",
|
||||
hintFallback: "Keto + Admin checks",
|
||||
icon: Database,
|
||||
},
|
||||
];
|
||||
|
||||
function GlobalOverviewPage() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
@@ -72,42 +38,99 @@ function GlobalOverviewPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="muted">
|
||||
{t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
|
||||
</Badge>
|
||||
<Badge variant="muted">
|
||||
{t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
|
||||
</Badge>
|
||||
</div>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="muted">
|
||||
{t("msg.admin.overview.idp_primary", "IDP: Ory primary")}
|
||||
</Badge>
|
||||
<Badge variant="muted">
|
||||
{t("msg.admin.overview.idp_fallback", "Fallback: Descope")}
|
||||
</Badge>
|
||||
</div>
|
||||
</RoleGuard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map(
|
||||
({
|
||||
labelKey,
|
||||
labelFallback,
|
||||
value,
|
||||
hintKey,
|
||||
hintFallback,
|
||||
icon: Icon,
|
||||
}) => (
|
||||
<Card key={labelKey} className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardDescription>{t(labelKey, labelFallback)}</CardDescription>
|
||||
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold">{value}</div>
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||
{t(hintKey, hintFallback)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
)}
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<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)]">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold">-</div>
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.overview.summary.total_tenants",
|
||||
"Tenant-aware core",
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<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)]">
|
||||
<ShieldCheck size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold">-</div>
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||
{t("msg.admin.overview.summary.oidc_clients", "Hydra registry")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</RoleGuard>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<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)]">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold">-</div>
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.overview.summary.audit_events_24h",
|
||||
"ClickHouse stream",
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<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)]">
|
||||
<Database size={16} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold">Planned</div>
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.overview.summary.policy_gate",
|
||||
"Keto + Admin checks",
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
|
||||
@@ -178,16 +201,46 @@ function GlobalOverviewPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full justify-between"
|
||||
variant="outline"
|
||||
>
|
||||
<Link to="/tenants/new">
|
||||
{t("ui.admin.overview.quick_links.add_tenant", "테넌트 추가")}
|
||||
<ArrowUpRight size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full justify-between"
|
||||
variant="outline"
|
||||
>
|
||||
<Link to="/tenants/new">
|
||||
{t("ui.admin.overview.quick_links.add_tenant", "테넌트 추가")}
|
||||
<Link to="/users">
|
||||
{t(
|
||||
"ui.admin.overview.quick_links.user_management",
|
||||
"사용자 관리",
|
||||
)}
|
||||
<ArrowUpRight size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full justify-between"
|
||||
variant="outline"
|
||||
>
|
||||
<Link to="/api-keys">
|
||||
{t(
|
||||
"ui.admin.overview.quick_links.api_key_management",
|
||||
"API 키 관리",
|
||||
)}
|
||||
<ArrowUpRight size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full justify-between"
|
||||
@@ -201,24 +254,13 @@ function GlobalOverviewPage() {
|
||||
<ArrowUpRight size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full justify-between"
|
||||
variant="outline"
|
||||
>
|
||||
<Link to="/dashboard">
|
||||
{t(
|
||||
"ui.admin.overview.quick_links.tenant_dashboard",
|
||||
"테넌트 대시보드",
|
||||
)}
|
||||
<ArrowUpRight size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PermissionChecker />
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<PermissionChecker />
|
||||
</RoleGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Download, FileText, Loader2, Upload } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { importOrgChart } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
interface OrgChartUploadModalProps {
|
||||
tenantId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function OrgChartUploadModal({
|
||||
tenantId,
|
||||
onSuccess,
|
||||
}: OrgChartUploadModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (file: File) => importOrgChart(tenantId, file),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.org.import_success",
|
||||
"조직도가 성공적으로 업로드되었습니다.",
|
||||
),
|
||||
);
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (file) {
|
||||
mutation.mutate(file);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "email,name,organization,position,jobtitle,is_owner";
|
||||
const example = `ceo@example.com,홍길동,경영진,대표이사,경영총괄,true
|
||||
cto@example.com,이몽룡,기술부문,이사,기술총괄,true
|
||||
user1@example.com,성춘향,기술부문/개발팀,팀원,프론트엔드 개발,false`;
|
||||
const blob = new Blob(
|
||||
[
|
||||
`${headers}
|
||||
${example}`,
|
||||
],
|
||||
{ type: "text/csv" },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "org_chart_template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Upload size={14} />
|
||||
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV)")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.org.import_title", "조직도 일괄 등록")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.org.import_description",
|
||||
"CSV 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("ui.admin.org.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20 flex items-center gap-3">
|
||||
<FileText className="text-primary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{file.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || mutation.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("ui.admin.org.start_import", "임포트 시작")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -100,19 +100,29 @@ function TenantCreatePage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
<Label htmlFor="tenant-name" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input
|
||||
id="tenant-name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.create.form.name_placeholder",
|
||||
"테넌트 이름을 입력하세요",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
<Label htmlFor="tenant-type" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
|
||||
</Label>
|
||||
<select
|
||||
id="type"
|
||||
id="tenant-type"
|
||||
name="type"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
@@ -141,11 +151,12 @@ function TenantCreatePage() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
<Label htmlFor="parentId" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
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={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
@@ -160,10 +171,12 @@ function TenantCreatePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
name="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder={t(
|
||||
@@ -173,23 +186,30 @@ function TenantCreatePage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
<Label
|
||||
htmlFor="tenant-description"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t("ui.admin.tenants.create.form.description", "설명")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="tenant-description"
|
||||
name="description"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
<Label htmlFor="tenant-domains" className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.tenants.create.form.domains_label",
|
||||
"허용된 도메인 (콤마로 구분)",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="tenant-domains"
|
||||
name="domains"
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
placeholder={t(
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
removeGroupMember,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
|
||||
type UserGroupNode = GroupSummary & {
|
||||
children: UserGroupNode[];
|
||||
@@ -443,13 +444,19 @@ function TenantGroupsPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => groupsQuery.refetch()}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<OrgChartUploadModal
|
||||
tenantId={tenantId}
|
||||
onSuccess={() => groupsQuery.refetch()}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => groupsQuery.refetch()}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -23,17 +24,41 @@ import {
|
||||
import {
|
||||
type TenantSummary,
|
||||
deleteTenant,
|
||||
fetchMe,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
// Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin") {
|
||||
const manageableCount = profile.manageableTenants?.length ?? 0;
|
||||
// If only 1 in array, OR array is empty but we have a primary tenantId
|
||||
if (
|
||||
(manageableCount === 1 || manageableCount === 0) &&
|
||||
profile.tenantId
|
||||
) {
|
||||
navigate(`/tenants/${profile.tenantId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [profile, navigate]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["tenants", { limit: 1000, offset: 0 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
enabled:
|
||||
profile?.role === "super_admin" ||
|
||||
(profile?.role === "tenant_admin" &&
|
||||
(profile.manageableTenants?.length ?? 0) > 1),
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (tenantId: string) => deleteTenant(tenantId),
|
||||
onSuccess: () => {
|
||||
@@ -41,6 +66,31 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
profile &&
|
||||
profile.role !== "super_admin" &&
|
||||
profile.role !== "tenant_admin"
|
||||
) {
|
||||
return (
|
||||
<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>
|
||||
<Button onClick={() => navigate("/")}>
|
||||
{t("ui.common.go_home", "홈으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// While redirecting (only if exactly one manageable tenant)
|
||||
if (
|
||||
profile?.role === "tenant_admin" &&
|
||||
(profile.manageableTenants?.length ?? 0) <= 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
@@ -95,12 +145,14 @@ function TenantListPage() {
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/tenants/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Button asChild>
|
||||
<Link to="/tenants/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Label } from "../../../components/ui/label";
|
||||
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type SchemaFieldType = "text" | "number" | "boolean";
|
||||
type SchemaFieldType = "text" | "number" | "boolean" | "date";
|
||||
|
||||
type SchemaField = {
|
||||
id: string;
|
||||
@@ -25,6 +25,8 @@ type SchemaField = {
|
||||
label: string;
|
||||
type: SchemaFieldType;
|
||||
required: boolean;
|
||||
adminOnly: boolean;
|
||||
validation?: string;
|
||||
};
|
||||
|
||||
function createFieldId() {
|
||||
@@ -62,10 +64,15 @@ export function TenantSchemaPage() {
|
||||
key: typeof field?.key === "string" ? field.key : "",
|
||||
label: typeof field?.label === "string" ? field.label : "",
|
||||
type:
|
||||
field?.type === "number" || field?.type === "boolean"
|
||||
field?.type === "number" ||
|
||||
field?.type === "boolean" ||
|
||||
field?.type === "date"
|
||||
? field.type
|
||||
: "text",
|
||||
required: Boolean(field?.required),
|
||||
adminOnly: Boolean(field?.adminOnly),
|
||||
validation:
|
||||
typeof field?.validation === "string" ? field.validation : "",
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -105,6 +112,8 @@ export function TenantSchemaPage() {
|
||||
label: "",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
validation: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -141,7 +150,7 @@ export function TenantSchemaPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-6">
|
||||
{fields.length === 0 && (
|
||||
<div className="py-12 text-center text-muted-foreground border border-dashed rounded-lg bg-muted/10">
|
||||
{t(
|
||||
@@ -153,84 +162,144 @@ export function TenantSchemaPage() {
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-end gap-4 p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors"
|
||||
className="p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors space-y-4"
|
||||
>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
|
||||
</Label>
|
||||
<Input
|
||||
value={field.key}
|
||||
onChange={(e) => updateField(index, { key: e.target.value })}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.schema.field.key_placeholder",
|
||||
"예: employee_id",
|
||||
)}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
|
||||
</Label>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) =>
|
||||
updateField(index, { label: e.target.value })
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.schema.field.label_placeholder",
|
||||
"예: 사번",
|
||||
)}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40 space-y-2">
|
||||
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.admin.tenants.schema.field.type", "유형")}
|
||||
</Label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
|
||||
value={field.type}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
if (
|
||||
nextType === "text" ||
|
||||
nextType === "number" ||
|
||||
nextType === "boolean"
|
||||
) {
|
||||
updateField(index, { type: nextType });
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
|
||||
</Label>
|
||||
<Input
|
||||
value={field.key}
|
||||
onChange={(e) =>
|
||||
updateField(index, { key: e.target.value })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="text">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_text",
|
||||
"텍스트 (Text)",
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.schema.field.key_placeholder",
|
||||
"예: employee_id",
|
||||
)}
|
||||
</option>
|
||||
<option value="number">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_number",
|
||||
"숫자 (Number)",
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
|
||||
</Label>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) =>
|
||||
updateField(index, { label: e.target.value })
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.schema.field.label_placeholder",
|
||||
"예: 사번",
|
||||
)}
|
||||
</option>
|
||||
<option value="boolean">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_boolean",
|
||||
"불리언 (Boolean)",
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.admin.tenants.schema.field.type", "유형")}
|
||||
</Label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
|
||||
value={field.type}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
if (
|
||||
nextType === "text" ||
|
||||
nextType === "number" ||
|
||||
nextType === "boolean" ||
|
||||
nextType === "date"
|
||||
) {
|
||||
updateField(index, { type: nextType });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="text">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_text",
|
||||
"텍스트 (Text)",
|
||||
)}
|
||||
</option>
|
||||
<option value="number">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_number",
|
||||
"숫자 (Number)",
|
||||
)}
|
||||
</option>
|
||||
<option value="boolean">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_boolean",
|
||||
"불리언 (Boolean)",
|
||||
)}
|
||||
</option>
|
||||
<option value="date">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.type_date",
|
||||
"날짜 (Date)",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) =>
|
||||
updateField(index, { required: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.adminOnly}
|
||||
onChange={(e) =>
|
||||
updateField(index, { adminOnly: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"ui.admin.tenants.schema.field.admin_only",
|
||||
"관리자 전용",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={field.validation}
|
||||
onChange={(e) =>
|
||||
updateField(index, { validation: e.target.value })
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.schema.field.validation_placeholder",
|
||||
"정규식 (예: ^[0-9]+$)",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
className="h-9 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 h-10 w-10"
|
||||
onClick={() => removeField(index)}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 h-10 w-10"
|
||||
onClick={() => removeField(index)}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@@ -56,9 +56,11 @@ import {
|
||||
TabsTrigger,
|
||||
} from "../../../components/ui/tabs";
|
||||
import {
|
||||
type GroupSummary,
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
createUser,
|
||||
fetchGroups,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
updateTenant,
|
||||
@@ -141,6 +143,9 @@ const MemberListDialog: React.FC<{
|
||||
<Users size={24} className="text-primary" />
|
||||
{node.name}{" "}
|
||||
{t("ui.admin.tenants.members.list_title", "구성원 관리")}
|
||||
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||||
({isDirectLoading ? "..." : (directData?.total ?? 0)})
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
@@ -162,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"
|
||||
>
|
||||
{t("ui.admin.tenants.members.direct", "소속 멤버")} (
|
||||
{node.memberCount || 0})
|
||||
{isDirectLoading ? "..." : (directData?.total ?? 0)})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="descendants"
|
||||
@@ -578,20 +583,79 @@ const TenantTreeRow: React.FC<{
|
||||
level: number;
|
||||
isRoot: boolean;
|
||||
onRemove: (id: string, name: string) => void;
|
||||
onMove: (id: string, newParentId: string) => void;
|
||||
isUpdating: boolean;
|
||||
}> = ({ node, level, isRoot, onRemove, isUpdating }) => {
|
||||
searchTerm?: string;
|
||||
}> = ({ node, level, isRoot, onRemove, onMove, isUpdating, searchTerm }) => {
|
||||
const navigate = useNavigate();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isUserAddOpen, setIsUserAddOpen] = useState(false);
|
||||
const [isMemberListOpen, setIsMemberListOpen] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
|
||||
// Auto expand if search matches children
|
||||
React.useEffect(() => {
|
||||
if (searchTerm) {
|
||||
const hasMatchingChild = (n: TenantNode): boolean => {
|
||||
return n.children.some(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
hasMatchingChild(c),
|
||||
);
|
||||
};
|
||||
if (hasMatchingChild(node)) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}
|
||||
}, [searchTerm, node]);
|
||||
|
||||
const isMatching =
|
||||
searchTerm &&
|
||||
(node.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
node.slug.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const TypeIcon = getTenantIcon(node.type);
|
||||
|
||||
// DnD Handlers
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
if (isRoot) return;
|
||||
e.dataTransfer.setData("nodeId", node.id);
|
||||
e.dataTransfer.setData("nodeName", node.name);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (isUpdating) return;
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const draggedId = e.dataTransfer.getData("nodeId");
|
||||
if (!draggedId || draggedId === node.id) return;
|
||||
onMove(draggedId, node.id);
|
||||
};
|
||||
|
||||
const hoverTitle = `${node.name} (${node.type})\n${t("ui.admin.tenants.members.direct", "소속 멤버")}: ${node.memberCount || 0}\n${t("ui.admin.tenants.members.total", "총 멤버")}: ${node.recursiveMemberCount || 0}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className={`group hover:bg-muted/30 transition-colors ${isRoot ? "bg-primary/5 font-bold" : ""}`}
|
||||
draggable={!isRoot}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`group hover:bg-muted/30 transition-colors ${isRoot ? "bg-primary/5 font-bold" : ""} ${isMatching ? "bg-primary/10 ring-1 ring-primary/20" : ""} ${isDragOver ? "bg-primary/20 border-2 border-dashed border-primary" : ""}`}
|
||||
title={hoverTitle}
|
||||
>
|
||||
<TableCell style={{ paddingLeft: `${1 + level * 2}rem` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -651,6 +715,10 @@ const TenantTreeRow: React.FC<{
|
||||
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"
|
||||
onClick={() => setIsMemberListOpen(true)}
|
||||
title={t(
|
||||
"msg.admin.org.hover_member_info",
|
||||
"클릭하여 멤버 상세 조회",
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 p-1.5 rounded text-primary">
|
||||
<Users size={16} />
|
||||
@@ -705,7 +773,15 @@ const TenantTreeRow: React.FC<{
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => navigate(`/tenants/${node.id}`)}
|
||||
onClick={() => {
|
||||
if (node.type === "USER_GROUP") {
|
||||
// User groups have a different detail path
|
||||
const baseTenantId = node.tenantId || tenantId;
|
||||
navigate(`/tenants/${baseTenantId}/organization/${node.id}`);
|
||||
} else {
|
||||
navigate(`/tenants/${node.id}`);
|
||||
}
|
||||
}}
|
||||
title={t("ui.common.manage", "관리")}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
@@ -739,7 +815,9 @@ const TenantTreeRow: React.FC<{
|
||||
level={level + 1}
|
||||
isRoot={false}
|
||||
onRemove={onRemove}
|
||||
onMove={onMove}
|
||||
isUpdating={isUpdating}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -752,6 +830,7 @@ function TenantUserGroupsTab() {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isHeaderUserAddOpen, setIsHeaderUserAddOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [treeSearchTerm, setTreeSearchTerm] = useState("");
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
@@ -760,6 +839,23 @@ function TenantUserGroupsTab() {
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
});
|
||||
|
||||
const { data: groupsData, isLoading: isGroupsLoading } = useQuery({
|
||||
queryKey: ["tenant-groups", tenantId],
|
||||
queryFn: () => fetchGroups(tenantId),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const groupNodes = useMemo(() => {
|
||||
if (!groupsData) return [];
|
||||
return groupsData.map((g) => ({
|
||||
...g,
|
||||
type: "USER_GROUP",
|
||||
children: [], // Simplified for now, just a list or separate tree
|
||||
memberCount: g.members?.length || 0,
|
||||
recursiveMemberCount: g.members?.length || 0,
|
||||
})) as unknown as TenantNode[];
|
||||
}, [groupsData]);
|
||||
|
||||
const updateParentMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
@@ -775,13 +871,21 @@ function TenantUserGroupsTab() {
|
||||
|
||||
const allTenants = data?.items ?? [];
|
||||
|
||||
const { currentBase, subTree } = useMemo(
|
||||
() => buildTenantFullTree(allTenants, tenantId),
|
||||
[allTenants, tenantId],
|
||||
);
|
||||
const { currentBase, subTree } = useMemo(() => {
|
||||
const tree = buildTenantFullTree(allTenants, tenantId);
|
||||
if (tree.currentBase) {
|
||||
// Merge backend-provided UserGroups into the tree as virtual children
|
||||
tree.currentBase.children = [...tree.currentBase.children, ...groupNodes];
|
||||
}
|
||||
return tree;
|
||||
}, [allTenants, tenantId, groupNodes]);
|
||||
|
||||
const handleAdd = (id: string) =>
|
||||
updateParentMutation.mutate({ id, parentId: tenantId });
|
||||
const handleMove = (id: string, newParentId: string) => {
|
||||
if (id === newParentId) return;
|
||||
updateParentMutation.mutate({ id, parentId: newParentId });
|
||||
};
|
||||
const handleRemove = (id: string, name: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
@@ -974,6 +1078,30 @@ function TenantUserGroupsTab() {
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="px-6 py-3 bg-muted/5 border-b flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.sub.tree_search_placeholder",
|
||||
"조직도 내 검색...",
|
||||
)}
|
||||
className="pl-9 h-9"
|
||||
value={treeSearchTerm}
|
||||
onChange={(e) => setTreeSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{treeSearchTerm && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTreeSearchTerm("")}
|
||||
className="text-xs"
|
||||
>
|
||||
{t("ui.common.clear_search", "검색 초기화")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/5">
|
||||
@@ -1001,7 +1129,9 @@ function TenantUserGroupsTab() {
|
||||
level={0}
|
||||
isRoot={true}
|
||||
onRemove={handleRemove}
|
||||
onMove={handleMove}
|
||||
isUpdating={updateParentMutation.isPending}
|
||||
searchTerm={treeSearchTerm}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -26,8 +26,10 @@ import { t } from "../../lib/i18n";
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label?: string;
|
||||
type?: "text" | "number" | "boolean";
|
||||
type?: "text" | "number" | "boolean" | "date";
|
||||
required?: boolean;
|
||||
adminOnly?: boolean;
|
||||
validation?: string;
|
||||
};
|
||||
|
||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
||||
@@ -48,10 +50,16 @@ function UserCreatePage() {
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<UserFormValues>({
|
||||
defaultValues: {
|
||||
@@ -68,6 +76,13 @@ function UserCreatePage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Lock company for tenant_admin
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin" && profile.companyCode) {
|
||||
setValue("companyCode", profile.companyCode);
|
||||
}
|
||||
}, [profile, setValue]);
|
||||
|
||||
const selectedCompanyCode = watch("companyCode");
|
||||
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
||||
|
||||
@@ -85,8 +100,28 @@ function UserCreatePage() {
|
||||
? (tenantDetail?.config?.userSchema as UserSchemaField[])
|
||||
: [];
|
||||
|
||||
const registerMetadata = (key: string) =>
|
||||
register(`metadata.${key}` as `metadata.${string}`);
|
||||
const registerMetadata = (field: UserSchemaField) =>
|
||||
register(`metadata.${field.key}` as `metadata.${string}`, {
|
||||
required: field.required
|
||||
? t(
|
||||
"msg.admin.users.create.form.field_required",
|
||||
"{{label}}은(는) 필수입니다.",
|
||||
{
|
||||
label: field.label || field.key,
|
||||
},
|
||||
)
|
||||
: false,
|
||||
pattern: field.validation
|
||||
? {
|
||||
value: new RegExp(field.validation),
|
||||
message: t(
|
||||
"msg.admin.users.create.form.field_invalid",
|
||||
"{{label}} 형식이 올바르지 않습니다.",
|
||||
{ label: field.label || field.key },
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
@@ -107,17 +142,16 @@ function UserCreatePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: UserCreateRequest) => {
|
||||
const onSubmit = (data: UserFormValues) => {
|
||||
setError(null);
|
||||
setGeneratedPassword(null);
|
||||
setCreatedEmail(null);
|
||||
|
||||
if (autoPassword) {
|
||||
mutation.mutate({ ...data, password: "" });
|
||||
return;
|
||||
}
|
||||
const payload = { ...data };
|
||||
|
||||
if (!data.password) {
|
||||
if (autoPassword) {
|
||||
payload.password = "";
|
||||
} else if (!data.password) {
|
||||
setError(
|
||||
t(
|
||||
"msg.admin.users.create.password_required",
|
||||
@@ -127,7 +161,7 @@ function UserCreatePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate(data);
|
||||
mutation.mutate(payload);
|
||||
};
|
||||
|
||||
const onCopyPassword = async () => {
|
||||
@@ -335,6 +369,7 @@ function UserCreatePage() {
|
||||
id="companyCode"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("companyCode")}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
@@ -414,13 +449,40 @@ function UserCreatePage() {
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`metadata.${field.key}`}>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
)}
|
||||
{field.adminOnly && (
|
||||
<span className="ml-2 text-[10px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter">
|
||||
Admin Only
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id={`metadata.${field.key}`}
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
{...registerMetadata(field.key)}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "date"
|
||||
? "date"
|
||||
: field.type === "boolean"
|
||||
? "checkbox"
|
||||
: "text"
|
||||
}
|
||||
className={
|
||||
field.type === "boolean" ? "w-auto h-auto" : ""
|
||||
}
|
||||
{...registerMetadata(field)}
|
||||
/>
|
||||
{errors.metadata?.[field.key] && (
|
||||
<p className="text-xs text-destructive">
|
||||
{
|
||||
(errors.metadata[field.key] as { message?: string })
|
||||
?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
Loader2,
|
||||
Save,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
type FieldErrors,
|
||||
type UseFormRegister,
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
@@ -15,7 +26,9 @@ import {
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
type TenantSummary,
|
||||
type UserUpdateRequest,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
fetchUser,
|
||||
@@ -26,11 +39,107 @@ import { t } from "../../lib/i18n";
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label?: string;
|
||||
type?: "text" | "number" | "boolean";
|
||||
type?: "text" | "number" | "boolean" | "date";
|
||||
required?: boolean;
|
||||
adminOnly?: boolean;
|
||||
validation?: string;
|
||||
};
|
||||
|
||||
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
|
||||
type UserFormValues = UserUpdateRequest & {
|
||||
metadata: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
// [New] Component for per-tenant profile/schema management
|
||||
function TenantProfileCard({
|
||||
tenant,
|
||||
register,
|
||||
errors,
|
||||
isAdmin,
|
||||
}: {
|
||||
tenant: TenantSummary;
|
||||
register: UseFormRegister<UserFormValues>;
|
||||
errors: FieldErrors<UserFormValues>;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { data: detail, isLoading } = useQuery({
|
||||
queryKey: ["tenant", tenant.id],
|
||||
queryFn: () => fetchTenant(tenant.id),
|
||||
});
|
||||
|
||||
const schema: UserSchemaField[] = Array.isArray(detail?.config?.userSchema)
|
||||
? (detail?.config?.userSchema as UserSchemaField[])
|
||||
: [];
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="p-4 border rounded-lg animate-pulse bg-muted/20">
|
||||
Loading schema...
|
||||
</div>
|
||||
);
|
||||
if (schema.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<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">
|
||||
<Building2 size={14} className="text-primary" />
|
||||
<span className="text-xs font-bold uppercase tracking-tight">
|
||||
{tenant.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono opacity-50">{tenant.slug}</span>
|
||||
</div>
|
||||
<div className="p-4 grid gap-4 md:grid-cols-2">
|
||||
{schema.map((field) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`metadata.${tenant.id}.${field.key}`}
|
||||
className="text-xs"
|
||||
>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
)}
|
||||
{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">
|
||||
Admin Only
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`metadata.${tenant.id}.${field.key}`}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "date"
|
||||
? "date"
|
||||
: field.type === "boolean"
|
||||
? "checkbox"
|
||||
: "text"
|
||||
}
|
||||
className={
|
||||
field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"
|
||||
}
|
||||
{...register(`metadata.${tenant.id}.${field.key}`, {
|
||||
required: field.required
|
||||
? t(
|
||||
"msg.admin.users.detail.form.field_required",
|
||||
"필수입니다.",
|
||||
)
|
||||
: false,
|
||||
})}
|
||||
/>
|
||||
{errors.metadata?.[tenant.id]?.[field.key] && (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{errors.metadata[tenant.id][field.key].message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -40,6 +149,11 @@ function UserDetailPage() {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
@@ -77,25 +191,8 @@ function UserDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCompanyCode = watch("companyCode");
|
||||
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
||||
|
||||
const selectedTenantId = selectedTenant?.id ?? "";
|
||||
|
||||
const { data: tenantDetail } = useQuery({
|
||||
queryKey: ["tenant", selectedTenantId],
|
||||
queryFn: () => fetchTenant(selectedTenantId),
|
||||
enabled: selectedTenantId.length > 0,
|
||||
});
|
||||
|
||||
const userSchema: UserSchemaField[] = Array.isArray(
|
||||
tenantDetail?.config?.userSchema,
|
||||
)
|
||||
? (tenantDetail?.config?.userSchema as UserSchemaField[])
|
||||
: [];
|
||||
|
||||
const registerMetadata = (key: string) =>
|
||||
register(`metadata.${key}` as `metadata.${string}`);
|
||||
const isAdmin =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user) {
|
||||
@@ -139,8 +236,8 @@ function UserDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: UserUpdateRequest) => {
|
||||
const payload = { ...data };
|
||||
const onSubmit = (data: UserFormValues) => {
|
||||
const payload: UserUpdateRequest = { ...data };
|
||||
if (!payload.password) {
|
||||
payload.password = undefined;
|
||||
}
|
||||
@@ -163,6 +260,15 @@ function UserDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Combined affiliated tenants
|
||||
const userAffiliatedTenants = [...(user.joinedTenants || [])];
|
||||
if (
|
||||
user.tenant &&
|
||||
!userAffiliatedTenants.find((t) => t.id === user.tenant?.id)
|
||||
) {
|
||||
userAffiliatedTenants.push(user.tenant);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
@@ -212,6 +318,97 @@ function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tenant Affiliation Section (Enhanced) */}
|
||||
<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">
|
||||
<Building2 size={16} className="text-primary" />
|
||||
{t(
|
||||
"ui.admin.users.detail.tenants_section.title",
|
||||
"소속 및 조직 정보",
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
||||
{t(
|
||||
"ui.admin.users.detail.tenants_section.primary",
|
||||
"대표 소속 테넌트",
|
||||
)}
|
||||
</Label>
|
||||
|
||||
{/* Select box to specify representative tenant from joined ones */}
|
||||
{userAffiliatedTenants.length > 0 ? (
|
||||
<div className="relative">
|
||||
<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"
|
||||
{...register("companyCode")}
|
||||
disabled={
|
||||
profile?.role === "tenant_admin" &&
|
||||
userAffiliatedTenants.length <= 1
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
"ui.admin.users.detail.form.tenant_global",
|
||||
"시스템 전역",
|
||||
)}
|
||||
</option>
|
||||
{userAffiliatedTenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<BadgeCheck
|
||||
size={14}
|
||||
className="absolute right-8 top-3 text-primary pointer-events-none"
|
||||
/>
|
||||
</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">
|
||||
{t(
|
||||
"ui.admin.users.detail.form.tenant_global",
|
||||
"시스템 전역 (소속 없음)",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
* 사용자의 주된 정체성을 결정하는 대표 조직을 선택합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{userAffiliatedTenants.length > 1 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
|
||||
{t(
|
||||
"ui.admin.users.detail.tenants_section.additional",
|
||||
"전체 소속 목록",
|
||||
)}
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{userAffiliatedTenants.map((jt) => (
|
||||
<Link
|
||||
key={jt.id}
|
||||
to={`/tenants/${jt.id}`}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<Users size={10} />
|
||||
{jt.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
@@ -304,33 +501,6 @@ function UserDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyCode">
|
||||
{t("ui.admin.users.detail.form.tenant", "테넌트 (Tenant)")}
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
id="companyCode"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("companyCode")}
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
"ui.admin.users.detail.form.tenant_global",
|
||||
"시스템 전역 (소속 없음)",
|
||||
)}
|
||||
</option>
|
||||
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">
|
||||
{t("ui.admin.users.detail.form.department", "부서")}
|
||||
@@ -347,69 +517,38 @@ function UserDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position">
|
||||
{t("ui.admin.users.detail.form.position", "직급")}
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="position"
|
||||
placeholder={t(
|
||||
"ui.admin.users.detail.form.position_placeholder",
|
||||
"수석/책임/선임",
|
||||
)}
|
||||
{...register("position")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="jobTitle">
|
||||
{t("ui.admin.users.detail.form.job_title", "직무")}
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="jobTitle"
|
||||
placeholder={t(
|
||||
"ui.admin.users.detail.form.job_title_placeholder",
|
||||
"프론트엔드 개발",
|
||||
)}
|
||||
{...register("jobTitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{userSchema.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
||||
{/* Tenant-specific Profiles (Namespaced Metadata) */}
|
||||
<div className="border-t pt-6 space-y-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{t(
|
||||
"ui.admin.users.detail.custom_fields.title",
|
||||
"테넌트 확장 정보 (Custom Fields)",
|
||||
"ui.admin.users.detail.custom_fields.multi_title",
|
||||
"테넌트별 프로필 관리",
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{userSchema.map((field) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`metadata.${field.key}`}>
|
||||
{field.label}
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id={`metadata.${field.key}`}
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
{...registerMetadata(field.key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
사용자가 소속된 각 테넌트별 맞춤 정보를 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{userAffiliatedTenants.map((tenant) => (
|
||||
<TenantProfileCard
|
||||
key={tenant.id}
|
||||
tenant={tenant}
|
||||
register={register}
|
||||
errors={errors}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
||||
{t("ui.admin.users.detail.security.title", "보안 설정")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{t(
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileDown,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
@@ -21,6 +23,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
@@ -30,20 +41,101 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { deleteUser, fetchUsers } from "../../lib/adminApi";
|
||||
import {
|
||||
bulkDeleteUsers,
|
||||
bulkUpdateUsers,
|
||||
deleteUser,
|
||||
exportUsersCSVUrl,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
|
||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [searchDraft, setSearchDraft] = React.useState("");
|
||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||
const [visibleColumns, setVisibleColumns] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
// Lock company for tenant_admin
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin" && profile.companyCode) {
|
||||
setSelectedCompany(profile.companyCode);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const selectedTenantId = React.useMemo(() => {
|
||||
return tenants.find((t) => t.slug === selectedCompany)?.id ?? "";
|
||||
}, [tenants, selectedCompany]);
|
||||
|
||||
const { data: tenantDetail } = useQuery({
|
||||
queryKey: ["tenant", selectedTenantId],
|
||||
queryFn: () => fetchTenant(selectedTenantId),
|
||||
enabled: selectedTenantId.length > 0,
|
||||
});
|
||||
|
||||
const userSchema: UserSchemaField[] = Array.isArray(
|
||||
tenantDetail?.config?.userSchema,
|
||||
)
|
||||
? (tenantDetail?.config?.userSchema as UserSchemaField[])
|
||||
: [];
|
||||
|
||||
// Initialize visible columns when schema changes
|
||||
React.useEffect(() => {
|
||||
if (userSchema.length > 0) {
|
||||
const initial: Record<string, boolean> = {};
|
||||
for (const field of userSchema) {
|
||||
initial[field.key] = true;
|
||||
}
|
||||
setVisibleColumns((prev) => {
|
||||
// Only set if not already set for these keys to avoid reset on every render
|
||||
const next = { ...initial, ...prev };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [userSchema]);
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
setVisibleColumns((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["users", { limit, offset, search }],
|
||||
queryFn: () => fetchUsers(limit, offset, search),
|
||||
queryKey: [
|
||||
"users",
|
||||
{ limit, offset, search, companyCode: selectedCompany },
|
||||
],
|
||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
@@ -65,6 +157,11 @@ function UserListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const url = exportUsersCSVUrl(search, selectedCompany);
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
@@ -79,11 +176,67 @@ function UserListPage() {
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (items.length > 0) {
|
||||
console.log("User items:", items);
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedUserIds.length === items.length) {
|
||||
setSelectedUserIds([]);
|
||||
} else {
|
||||
setSelectedUserIds(items.map((u) => u.id));
|
||||
}
|
||||
}, [items]);
|
||||
};
|
||||
|
||||
const toggleSelectUser = (id: string) => {
|
||||
setSelectedUserIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: bulkDeleteUsers,
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.bulk.delete_success",
|
||||
"선택한 사용자들이 삭제되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const bulkUpdateMutation = useMutation({
|
||||
mutationFn: bulkUpdateUsers,
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.bulk.update_success",
|
||||
"선택한 사용자들의 정보가 수정되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleBulkStatusChange = (status: string) => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.users.bulk.delete_confirm",
|
||||
"{{count}}명의 사용자를 정말 삭제하시겠습니까?",
|
||||
{ count: selectedUserIds.length },
|
||||
),
|
||||
)
|
||||
) {
|
||||
bulkDeleteMutation.mutate(selectedUserIds);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (userId: string, userName: string) => {
|
||||
if (
|
||||
@@ -111,13 +264,13 @@ function UserListPage() {
|
||||
{t("ui.admin.users.list.breadcrumb.list", "List")}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">
|
||||
<h2 className="text-3xl font-semibold" data-testid="page-title">
|
||||
{t("ui.admin.users.list.title", "사용자 관리")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.users.list.subtitle",
|
||||
"시스템 사용자를 조회하고 관리합니다. (Local DB)",
|
||||
"시스템 사용자를 조회하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -130,6 +283,67 @@ function UserListPage() {
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExport} className="gap-2">
|
||||
<FileDown size={16} />
|
||||
{t("ui.common.export", "내보내기")}
|
||||
</Button>
|
||||
<UserBulkUploadModal onSuccess={() => query.refetch()} />
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Settings2 size={16} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.users.list.columns.description",
|
||||
"사용자 목록에 표시할 커스텀 필드를 선택하세요.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{userSchema.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{t(
|
||||
"msg.admin.users.list.columns.no_custom",
|
||||
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{userSchema.map((field) => (
|
||||
<label
|
||||
key={field.key}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
checked={visibleColumns[field.key] !== false}
|
||||
onChange={() => toggleColumn(field.key)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{field.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{field.key}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button asChild>
|
||||
<Link to="/users/new">
|
||||
<Plus size={16} />
|
||||
@@ -155,8 +369,8 @@ function UserListPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
@@ -169,6 +383,29 @@ function UserListPage() {
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
|
||||
{t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
|
||||
</span>
|
||||
<select
|
||||
className="flex h-9 w-[200px] 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 disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(e) => {
|
||||
setSelectedCompany(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">{t("ui.common.all", "전체")}</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" onClick={handleSearch}>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
@@ -180,11 +417,22 @@ function UserListPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<TableHead className="w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={
|
||||
items.length > 0 &&
|
||||
selectedUserIds.length === items.length
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t("ui.admin.users.list.table.name_email", "NAME / EMAIL")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
@@ -199,12 +447,15 @@ function UserListPage() {
|
||||
"TENANT / DEPT",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.admin.users.list.table.position_job",
|
||||
"POSITION / JOB",
|
||||
)}
|
||||
</TableHead>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<TableHead key={field.key} className="uppercase">
|
||||
{field.label}
|
||||
</TableHead>
|
||||
),
|
||||
)}
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
||||
</TableHead>
|
||||
@@ -216,20 +467,39 @@ function UserListPage() {
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
<TableCell
|
||||
colSpan={6 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
<TableCell
|
||||
colSpan={6 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.admin.users.list.empty", "검색 결과가 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={
|
||||
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
@@ -262,32 +532,20 @@ function UserListPage() {
|
||||
<span className="font-medium text-blue-600">
|
||||
{user.tenant?.name || user.companyCode || "-"}
|
||||
</span>
|
||||
{user.tenant && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase">
|
||||
{t(
|
||||
"ui.admin.users.list.tenant_slug",
|
||||
"Slug: {{slug}}",
|
||||
{
|
||||
slug: user.tenant.slug,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium">
|
||||
{user.position || "-"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.jobTitle || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<TableCell key={field.key} className="text-sm">
|
||||
{String(user.metadata?.[field.key] ?? "-")}
|
||||
</TableCell>
|
||||
),
|
||||
)}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
@@ -297,11 +555,6 @@ function UserListPage() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.edit_aria",
|
||||
"사용자 수정: {{name}}",
|
||||
{ name: user.name },
|
||||
)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
@@ -311,11 +564,6 @@ function UserListPage() {
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.delete_aria",
|
||||
"사용자 삭제: {{name}}",
|
||||
{ name: user.name },
|
||||
)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
@@ -327,6 +575,68 @@ function UserListPage() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{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"
|
||||
data-testid="bulk-action-bar"
|
||||
>
|
||||
<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,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={() => handleBulkStatusChange("active")}
|
||||
data-testid="bulk-active-btn"
|
||||
>
|
||||
{t("ui.common.status.active", "활성화")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={() => handleBulkStatusChange("inactive")}
|
||||
data-testid="bulk-inactive-btn"
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성화")}
|
||||
</Button>
|
||||
<UserBulkMoveGroupModal
|
||||
userIds={selectedUserIds}
|
||||
onSuccess={() => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
}}
|
||||
/>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
||||
onClick={handleBulkDelete}
|
||||
data-testid="bulk-delete-btn"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-background/50 hover:text-background h-8 w-8 ml-2"
|
||||
onClick={() => setSelectedUserIds([])}
|
||||
aria-label={t("ui.common.close", "닫기")}
|
||||
data-testid="bulk-close-btn"
|
||||
>
|
||||
<Plus size={16} className="rotate-45" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { FolderTree, Loader2, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import {
|
||||
type GroupSummary,
|
||||
type TenantSummary,
|
||||
bulkUpdateUsers,
|
||||
fetchGroups,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
interface UserBulkMoveGroupModalProps {
|
||||
userIds: string[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function UserBulkMoveGroupModal({
|
||||
userIds,
|
||||
onSuccess,
|
||||
}: UserBulkMoveGroupModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedTenantSlug, setSelectedTenantSlug] =
|
||||
React.useState<string>("");
|
||||
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
enabled: open,
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
const selectedTenantId = React.useMemo(
|
||||
() => tenants.find((t) => t.slug === selectedTenantSlug)?.id ?? "",
|
||||
[tenants, selectedTenantSlug],
|
||||
);
|
||||
|
||||
const { data: groups, isLoading: isGroupsLoading } = useQuery({
|
||||
queryKey: ["tenant-groups", selectedTenantId],
|
||||
queryFn: () => fetchGroups(selectedTenantId),
|
||||
enabled: open && !!selectedTenantId,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: bulkUpdateUsers,
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.bulk.move_success",
|
||||
"사용자들의 부서가 이동되었습니다.",
|
||||
),
|
||||
);
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(t("msg.admin.users.bulk.move_error", "부서 이동 실패"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleMove = () => {
|
||||
if (!selectedTenantSlug) return;
|
||||
mutation.mutate({
|
||||
userIds,
|
||||
companyCode: selectedTenantSlug,
|
||||
department: selectedGroupName, // can be empty for "No Department"
|
||||
});
|
||||
};
|
||||
|
||||
const filteredGroups = React.useMemo(() => {
|
||||
if (!groups) return [];
|
||||
if (!searchTerm) return groups;
|
||||
return groups.filter((g) =>
|
||||
g.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}, [groups, searchTerm]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
>
|
||||
{t("ui.admin.users.bulk.move_group", "부서 이동")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.users.bulk.move_description",
|
||||
"선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.",
|
||||
{ count: userIds.length },
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("ui.admin.users.create.form.tenant", "테넌트 선택")}
|
||||
</label>
|
||||
<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"
|
||||
value={selectedTenantSlug}
|
||||
onChange={(e) => {
|
||||
setSelectedTenantSlug(e.target.value);
|
||||
setSelectedGroupName("");
|
||||
}}
|
||||
>
|
||||
<option value="">{t("ui.common.select", "선택하세요...")}</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedTenantSlug && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("ui.admin.users.bulk.select_group", "부서 선택")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("ui.common.search", "검색...")}
|
||||
className="pl-9 h-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-[200px] rounded-md border p-2">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedGroupName("")}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<FolderTree size={14} />
|
||||
{t("ui.admin.users.bulk.no_department", "(부서 없음)")}
|
||||
</button>
|
||||
{isGroupsLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
filteredGroups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedGroupName(group.name)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<FolderTree size={14} />
|
||||
{group.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMove}
|
||||
disabled={!selectedTenantSlug || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("ui.admin.users.bulk.do_move", "이동 실행")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import {
|
||||
type BulkUserItem,
|
||||
type BulkUserResult,
|
||||
bulkCreateUsers,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { parseUserCSV } from "../utils/csvParser";
|
||||
|
||||
interface UserBulkUploadModalProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [parsing, setParsing] = React.useState(false);
|
||||
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
|
||||
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: bulkCreateUsers,
|
||||
onSuccess: (data) => {
|
||||
setResults(data.results);
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
parseCSV(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const parseCSV = (file: File) => {
|
||||
setParsing(true);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const data = parseUserCSV(text);
|
||||
setPreviewData(data);
|
||||
setParsing(false);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (previewData.length > 0) {
|
||||
mutation.mutate(previewData);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "email,name,phone,role,companyCode,department,employee_id";
|
||||
const example =
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
|
||||
const blob = new Blob(
|
||||
[
|
||||
`${headers}
|
||||
${example}`,
|
||||
],
|
||||
{ type: "text/csv" },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "user_bulk_template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setPreviewData([]);
|
||||
setResults(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const successCount = results?.filter((r) => r.success).length ?? 0;
|
||||
const failCount = results ? results.length - successCount : 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
data-testid="bulk-import-btn"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle data-testid="bulk-upload-title">
|
||||
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.users.bulk.description",
|
||||
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!results ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileText className="text-primary" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</div>
|
||||
{parsing ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t("msg.common.parsing", "파싱 중...")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.bulk.parsed_count",
|
||||
"{{count}}명의 사용자가 감지되었습니다.",
|
||||
{ count: previewData.length },
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewData.length > 0 && (
|
||||
<ScrollArea className="h-[200px] rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="p-2 text-left">Email</th>
|
||||
<th className="p-2 text-left">Name</th>
|
||||
<th className="p-2 text-left">Tenant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.slice(0, 10).map((u) => (
|
||||
<tr key={u.email} className="border-t">
|
||||
<td className="p-2">{u.email}</td>
|
||||
<td className="p-2">{u.name}</td>
|
||||
<td className="p-2">{u.companyCode || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{previewData.length > 10 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="p-2 text-center text-muted-foreground italic"
|
||||
>
|
||||
... and {previewData.length - 10} more users
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<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-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{successCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.success", "성공")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-destructive">
|
||||
{failCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">
|
||||
{t("ui.common.fail", "실패")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[250px] rounded-md border">
|
||||
<div className="p-2 space-y-2">
|
||||
{results.map((r) => (
|
||||
<div
|
||||
key={r.email}
|
||||
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
|
||||
>
|
||||
{r.success ? (
|
||||
<CheckCircle2
|
||||
size={16}
|
||||
className="text-green-500 mt-0.5"
|
||||
/>
|
||||
) : (
|
||||
<AlertCircle
|
||||
size={16}
|
||||
className="text-destructive mt-0.5"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{r.email}</div>
|
||||
{!r.success && (
|
||||
<div className="text-xs text-destructive">
|
||||
{r.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!results ? (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={previewData.length === 0 || mutation.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="bulk-start-btn"
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="bulk-close-dialog-btn"
|
||||
>
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseUserCSV } from "./csvParser";
|
||||
|
||||
describe("parseUserCSV", () => {
|
||||
it("should parse valid CSV correctly", () => {
|
||||
const csv = `email,name,phone,role,companyCode,department,emp_id
|
||||
user1@test.com,Hong Gil Dong,010-1111-2222,user,baron,HR,E001
|
||||
user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
email: "user1@test.com",
|
||||
name: "Hong Gil Dong",
|
||||
phone: "010-1111-2222",
|
||||
role: "user",
|
||||
companyCode: "baron",
|
||||
department: "HR",
|
||||
metadata: {
|
||||
emp_id: "E001",
|
||||
},
|
||||
});
|
||||
expect(result[1].email).toBe("user2@test.com");
|
||||
expect(result[1].metadata.emp_id).toBe("E002");
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", () => {
|
||||
expect(parseUserCSV("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should skip rows without email or name", () => {
|
||||
const csv = `email,name
|
||||
,Only Name
|
||||
no-name@test.com,`;
|
||||
expect(parseUserCSV(csv)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle mixed case headers", () => {
|
||||
const csv = `EMAIL,Name,CompanyCode
|
||||
test@test.com,Test,baron`;
|
||||
const result = parseUserCSV(csv);
|
||||
expect(result[0].email).toBe("test@test.com");
|
||||
expect(result[0].companyCode).toBe("baron");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { BulkUserItem } from "../../../lib/adminApi";
|
||||
|
||||
export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
const lines = text.split(/\r?\n/);
|
||||
if (lines.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const data: BulkUserItem[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (!lines[i].trim()) continue;
|
||||
|
||||
const values = lines[i].split(",").map((v) => v.trim());
|
||||
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
for (let index = 0; index < headers.length; index++) {
|
||||
const header = headers[index];
|
||||
const value = values[index];
|
||||
if (value === undefined || value === "") continue;
|
||||
|
||||
if (header === "email") {
|
||||
item.email = value;
|
||||
} else if (header === "name") {
|
||||
item.name = value;
|
||||
} else if (header === "phone") {
|
||||
item.phone = value;
|
||||
} else if (header === "role") {
|
||||
item.role = value;
|
||||
} else if (header === "companycode") {
|
||||
item.companyCode = value;
|
||||
} else if (header === "department") {
|
||||
item.department = value;
|
||||
} else {
|
||||
item.metadata[header] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.email && item.name) {
|
||||
data.push(item as BulkUserItem);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -260,6 +260,21 @@ export async function removeGroupMember(
|
||||
);
|
||||
}
|
||||
|
||||
export async function importOrgChart(tenantId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const { data } = await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/organization/import`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export type GroupRole = {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
@@ -296,21 +311,6 @@ export async function removeGroupRole(
|
||||
);
|
||||
}
|
||||
|
||||
export async function importOrgChart(tenantId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const { data } = await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/organization/import`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// API Key Management (M2M)
|
||||
export type ApiKeyCreateRequest = {
|
||||
name: string;
|
||||
@@ -354,6 +354,7 @@ export type UserSummary = {
|
||||
status: string;
|
||||
companyCode?: string;
|
||||
tenant?: TenantSummary;
|
||||
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||
metadata?: Record<string, unknown>;
|
||||
department?: string;
|
||||
position?: string;
|
||||
@@ -397,6 +398,27 @@ export type UserUpdateRequest = {
|
||||
jobTitle?: string;
|
||||
};
|
||||
|
||||
export type BulkUserItem = {
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
companyCode?: string;
|
||||
department?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type BulkUserResult = {
|
||||
email: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type BulkUserResponse = {
|
||||
results: BulkUserResult[];
|
||||
};
|
||||
|
||||
export async function fetchUsers(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
@@ -424,6 +446,43 @@ export async function createUser(payload: UserCreateRequest) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export function exportUsersCSVUrl(search?: string, companyCode?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.append("search", search);
|
||||
if (companyCode) params.append("companyCode", companyCode);
|
||||
|
||||
// Get mock role from storage if exists for dev environment
|
||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||
if (mockRole) params.append("x-test-role", mockRole);
|
||||
|
||||
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
|
||||
return `${baseUrl}/admin/users/export?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
||||
const { data } = await apiClient.post<BulkUserResponse>(
|
||||
"/v1/admin/users/bulk",
|
||||
{ users },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function bulkUpdateUsers(payload: {
|
||||
userIds: string[];
|
||||
status?: string;
|
||||
role?: string;
|
||||
}) {
|
||||
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function bulkDeleteUsers(userIds: string[]) {
|
||||
const { data } = await apiClient.delete("/v1/admin/users/bulk", {
|
||||
data: { userIds },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
||||
const { data } = await apiClient.put<UserSummary>(
|
||||
`/v1/admin/users/${userId}`,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import axios from "axios";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api",
|
||||
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE
|
||||
? "http://playwright-mock/api"
|
||||
: (import.meta.env.VITE_ADMIN_API_BASE ?? "/api"),
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
|
||||
@@ -24,30 +24,47 @@ export function buildTenantFullTree(
|
||||
});
|
||||
}
|
||||
|
||||
// Build initial children relations
|
||||
const visitedDuringBuild = new Set<string>();
|
||||
// Build initial children relations and prevent simple cycles
|
||||
for (const t of allTenants) {
|
||||
if (t.parentId) {
|
||||
if (t.parentId && t.parentId !== t.id) {
|
||||
const parent = tenantMap.get(t.parentId);
|
||||
const child = tenantMap.get(t.id);
|
||||
if (parent && child) {
|
||||
// Simple cycle prevention during build: don't add if it creates an immediate loop
|
||||
parent.children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to calculate recursive counts
|
||||
const visitedForCalc = new Set<string>();
|
||||
// Function to calculate recursive counts with cycle protection
|
||||
const calculateRecursive = (node: TenantNode): number => {
|
||||
if (visitedForCalc.has(node.id)) {
|
||||
console.warn(
|
||||
`Circular dependency detected in tenant tree for ID: ${node.id}`,
|
||||
);
|
||||
return 0; // Prevent infinite loop
|
||||
}
|
||||
visitedForCalc.add(node.id);
|
||||
|
||||
let total = node.memberCount || 0;
|
||||
for (const child of node.children) {
|
||||
total += calculateRecursive(child);
|
||||
}
|
||||
node.recursiveMemberCount = total;
|
||||
|
||||
// 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).
|
||||
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
|
||||
// a node should only be visited once.
|
||||
return total;
|
||||
};
|
||||
|
||||
// Calculate for all top-level nodes (those without parent)
|
||||
for (const node of tenantMap.values()) {
|
||||
if (!node.parentId) {
|
||||
visitedForCalc.clear();
|
||||
calculateRecursive(node);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +74,7 @@ export function buildTenantFullTree(
|
||||
const base = tenantMap.get(rootId);
|
||||
if (base) {
|
||||
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
||||
visitedForCalc.clear();
|
||||
calculateRecursive(base);
|
||||
return { currentBase: base, subTree: base.children };
|
||||
}
|
||||
|
||||
+393
-129
@@ -13,11 +13,35 @@ jangheon = "Jangheon"
|
||||
ptc = "PTC"
|
||||
saman = "Saman"
|
||||
|
||||
[domain.tenant_type]
|
||||
company = "Company"
|
||||
company_group = "Company Group"
|
||||
personal = "Personal"
|
||||
user_group = "User Group"
|
||||
|
||||
[err]
|
||||
|
||||
[err.common]
|
||||
unknown = "An unknown error occurred."
|
||||
|
||||
[err.backend]
|
||||
authorization_pending = "Authentication approval is still pending."
|
||||
bad_request = "Please check your request."
|
||||
conflict = "The request conflicts with the current state."
|
||||
expired_token = "The token has expired."
|
||||
forbidden = "This request is not allowed."
|
||||
internal_error = "An internal error occurred while processing the request."
|
||||
invalid_code = "The verification code is invalid."
|
||||
invalid_or_expired_code = "The verification code is invalid or expired."
|
||||
invalid_session = "The session is invalid."
|
||||
invalid_session_reference = "The session reference is invalid."
|
||||
not_found = "The requested authentication flow was not found."
|
||||
not_supported = "This login method is not supported."
|
||||
password_or_email_mismatch = "Email or password does not match."
|
||||
rate_limited = "Too many requests. Please try again later."
|
||||
service_unavailable = "The authentication service is currently unavailable."
|
||||
slow_down = "Requests are too frequent. Please try again shortly."
|
||||
|
||||
[err.userfront]
|
||||
|
||||
[err.userfront.auth_proxy]
|
||||
@@ -43,12 +67,15 @@ missing = "Missing"
|
||||
[msg]
|
||||
|
||||
[msg.admin]
|
||||
logout_confirm = "Are you sure you want to log out?"
|
||||
idp_env_prod = "IDP env: prod"
|
||||
logout_confirm = "Are you sure you want to log out?"
|
||||
scope_admin = "Scoped to /admin"
|
||||
session_ttl = "Session TTL: 15m admin"
|
||||
tenant_headers = "Tenant-aware headers"
|
||||
|
||||
[msg.admin.common]
|
||||
forbidden = "You do not have permission to perform this action."
|
||||
|
||||
[msg.admin.api_keys]
|
||||
|
||||
[msg.admin.api_keys.create]
|
||||
@@ -89,17 +116,40 @@ count = "Count"
|
||||
|
||||
[msg.admin.groups]
|
||||
|
||||
[msg.admin.groups.create]
|
||||
description = "Adds a new organization unit such as a department or team."
|
||||
title = "Create New Organization Unit"
|
||||
|
||||
[msg.admin.groups.list]
|
||||
create_error = "Create Failed"
|
||||
create_success = "Create Success"
|
||||
delete_confirm = "Delete Confirm"
|
||||
delete_error = "Delete Error"
|
||||
delete_success = "Delete Success"
|
||||
empty = "Empty"
|
||||
import_error = "Import Error"
|
||||
import_success = "Import Success"
|
||||
loading = "Loading..."
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.groups.members]
|
||||
add_success = "Add Success"
|
||||
count = "Count"
|
||||
empty = "Empty"
|
||||
remove_confirm = "Remove Confirm"
|
||||
remove_success = "Remove Success"
|
||||
title = "Title"
|
||||
|
||||
[msg.admin.groups.prompt]
|
||||
user_id = "User Id"
|
||||
|
||||
[msg.admin.groups.roles]
|
||||
assign_success = "Assign Success"
|
||||
description = "Description"
|
||||
empty = "Empty"
|
||||
remove_confirm = "Are you sure you want to revoke this role?"
|
||||
remove_success = "Role revoked successfully."
|
||||
|
||||
[msg.admin.header]
|
||||
subtitle = "Tenant isolation & least privilege by default"
|
||||
|
||||
@@ -107,6 +157,12 @@ subtitle = "Tenant isolation & least privilege by default"
|
||||
idp_policy = "IDP Policy"
|
||||
scope = "Scope"
|
||||
|
||||
[msg.admin.org]
|
||||
hover_member_info = "Hover to see member details."
|
||||
import_description = "Upload a CSV file to bulk register the organization chart."
|
||||
import_error = "An error occurred during organization chart import."
|
||||
import_success = "Organization chart imported successfully."
|
||||
|
||||
[msg.admin.overview]
|
||||
description = "Description"
|
||||
idp_fallback = "Fallback: Descope"
|
||||
@@ -122,52 +178,38 @@ tenant_title = "Tenant isolation"
|
||||
[msg.admin.overview.quick_links]
|
||||
description = "Description"
|
||||
|
||||
[msg.admin.overview.summary]
|
||||
audit_events_24h = "24h Audit Events"
|
||||
oidc_clients = "OIDC Clients"
|
||||
policy_gate = "Policy Gate Status"
|
||||
total_tenants = "Total Tenants"
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_confirm = "Delete Tenant \\\"{{name}}\\\"?"
|
||||
approve_confirm = "Approve Confirm"
|
||||
approve_success = "Approve Success"
|
||||
delete_confirm = "Delete Tenant \"{{name}}\"?"
|
||||
delete_success = "Tenant deleted."
|
||||
empty = "Empty"
|
||||
fetch_error = "Fetch Error"
|
||||
missing_id = "No Tenant ID."
|
||||
not_found = "Tenant not found."
|
||||
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.tenants.admins]
|
||||
add_success = "Admin added successfully."
|
||||
empty = "No admins registered."
|
||||
remove_confirm = "Are you sure you want to remove admin permission for {{name}}?"
|
||||
remove_success = "Admin permission revoked."
|
||||
subtitle = "Users with permissions to manage this tenant's resources."
|
||||
title = "Tenant Admin Settings"
|
||||
add_success = "Add Success"
|
||||
empty = "Empty"
|
||||
remove_confirm = "Remove Confirm"
|
||||
remove_success = "Remove Success"
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.tenants.owners]
|
||||
add_success = "Owner added successfully."
|
||||
empty = "No owners registered."
|
||||
remove_confirm = "Are you sure you want to remove owner permission for {{name}}?"
|
||||
remove_confirm = "Are you sure you want to remove this owner?"
|
||||
remove_success = "Owner permission revoked."
|
||||
subtitle = "List of owners with top-level permissions for this tenant."
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = "Add Admin"
|
||||
already_admin = "Already Admin"
|
||||
dialog_description = "Search users by name or email to grant admin permissions."
|
||||
dialog_no_results = "No results found."
|
||||
dialog_search_hint = "Please enter a search term."
|
||||
dialog_search_placeholder = "Search users (min 2 chars)..."
|
||||
dialog_title = "Add New Admin"
|
||||
remove_title = "Revoke Admin Permission"
|
||||
table_actions = "Actions"
|
||||
table_email = "Email"
|
||||
table_name = "Name"
|
||||
title = "Tenant Admins"
|
||||
|
||||
[ui.admin.tenants.owners]
|
||||
add_button = "Add Owner"
|
||||
dialog_description = "Search users by name or email to grant owner permissions."
|
||||
dialog_title = "Add New Owner"
|
||||
table_actions = "Actions"
|
||||
table_email = "Email"
|
||||
table_name = "Name"
|
||||
title = "Tenant Owners"
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
subtitle = "Subtitle"
|
||||
|
||||
@@ -182,13 +224,15 @@ subtitle = "Subtitle"
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.tenants.members]
|
||||
empty = "Empty"
|
||||
desc = "View the list of users belonging to this organization."
|
||||
empty = "No members found."
|
||||
limit_notice = "Showing members from the first 10 descendant organizations due to size limits."
|
||||
|
||||
[msg.admin.tenants.registry]
|
||||
count = "Count"
|
||||
|
||||
[msg.admin.tenants.schema]
|
||||
empty = "No custom fields defined. Click \\\"Add Field\\\" to begin."
|
||||
empty = "No custom fields defined. Click \"Add Field\" to begin."
|
||||
missing_id = "Tenant ID missing"
|
||||
subtitle = "Define custom attributes for users in this tenant."
|
||||
update_error = "Failed to update schema"
|
||||
@@ -200,15 +244,28 @@ subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.users]
|
||||
|
||||
[msg.admin.users.bulk]
|
||||
delete_confirm = "Are you sure you want to delete the selected {{count}} users?"
|
||||
delete_success = "{{count}} users have been deleted."
|
||||
description = "Bulk register or manage users via CSV file."
|
||||
move_description = "Bulk move selected users to another tenant."
|
||||
move_error = "Error moving users."
|
||||
move_success = "{{count}} users moved successfully."
|
||||
parsed_count = "Parsed {{count}} rows."
|
||||
update_success = "User info updated successfully."
|
||||
|
||||
[msg.admin.users.create]
|
||||
error = "Failed to User Create."
|
||||
password_required = "Password Required"
|
||||
success = "User created successfully."
|
||||
|
||||
[msg.admin.users.create.account]
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.users.create.form]
|
||||
email_required = "Email Required"
|
||||
field_invalid = "Invalid {{label}} format."
|
||||
field_required = "{{label}} is required."
|
||||
name_required = "Name Required"
|
||||
password_auto_help = "Password Auto Help"
|
||||
password_manual_help = "Password Manual Help"
|
||||
@@ -225,6 +282,7 @@ update_error = "Failed to User Edit."
|
||||
update_success = "Update Success"
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "Required."
|
||||
name_required = "Name Required"
|
||||
|
||||
[msg.admin.users.detail.security]
|
||||
@@ -236,23 +294,40 @@ empty = "Empty"
|
||||
fetch_error = "Fetch Error"
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.users.list.columns]
|
||||
description = "Select columns to display in the table."
|
||||
no_custom = "No custom fields defined for this tenant."
|
||||
|
||||
[msg.admin.users.list.registry]
|
||||
count = "Count"
|
||||
|
||||
[msg.common]
|
||||
error = "Error"
|
||||
loading = "Loading..."
|
||||
no_description = "No Description."
|
||||
parsing = "Parsing data..."
|
||||
requesting = "Requesting..."
|
||||
saving = "Saving..."
|
||||
unknown_error = "unknown error"
|
||||
|
||||
[msg.dev]
|
||||
logout_confirm = "Are you sure you want to log out?"
|
||||
|
||||
[msg.dev.audit]
|
||||
empty = "No audit logs found."
|
||||
forbidden = "You do not have permission to view audit logs. Please request access from an administrator."
|
||||
load_error = "Error loading audit logs: {{error}}"
|
||||
loaded_count = "Loaded {{count}} rows"
|
||||
loading = "Loading audit logs..."
|
||||
subtitle = "Shows DevFront activity history within current tenant/app scope."
|
||||
|
||||
[msg.dev.clients]
|
||||
copy_client_id = "Copy Client Id"
|
||||
load_error = "Error loading clients: {{error}}"
|
||||
loading = "Loading clients..."
|
||||
showing = "Showing {{shown}} of {{total}} clients"
|
||||
status_update_error = "Failed to update client status"
|
||||
status_updated = "Status Updated"
|
||||
loading = "Loading apps..."
|
||||
showing = "Showing {{shown}} of {{total}} apps"
|
||||
deleted = "App deleted."
|
||||
delete_error = "Failed to delete: {{error}}"
|
||||
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
|
||||
|
||||
[msg.dev.clients.consents]
|
||||
empty = "No consents found."
|
||||
@@ -260,6 +335,7 @@ load_error = "Error loading consents: {{error}}"
|
||||
loading = "Loading consents..."
|
||||
showing = "Showing {{from}} to {{to}} of {{total}} users"
|
||||
subtitle = "Subtitle"
|
||||
revoke_confirm = "Are you sure you want to revoke this user's permissions? After revocation, the user must consent again on next login."
|
||||
|
||||
[msg.dev.clients.details]
|
||||
copy_client_id = "Client ID copied."
|
||||
@@ -287,29 +363,36 @@ note = "Note"
|
||||
load_error = "Error loading client: {{error}}"
|
||||
loading = "Loading client..."
|
||||
saved = "Saved"
|
||||
save_error = "Failed to save: {{error}}"
|
||||
status_changed = "Status changed to {{status}}."
|
||||
|
||||
[msg.dev.clients.federation]
|
||||
subtitle = "Manage external identity providers for this application."
|
||||
add_subtitle = "Connect an external OIDC provider."
|
||||
empty = "No IdP configurations found."
|
||||
|
||||
[msg.dev.clients.general.identity]
|
||||
logo_help = "Logo Help"
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.dev.clients.general.redirect]
|
||||
help = "Help"
|
||||
help = "Enter the redirect URIs. You can modify them in the Federation tab after creation."
|
||||
|
||||
[msg.dev.clients.general.scopes]
|
||||
empty = "Empty"
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.dev.clients.general.security]
|
||||
confidential_help = "Confidential Help"
|
||||
public_help = "Public Help"
|
||||
subtitle = "Subtitle"
|
||||
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
||||
subtitle = "Select application type. Security level determines authentication method."
|
||||
|
||||
[msg.dev.clients.help]
|
||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
|
||||
|
||||
[msg.dev.clients.registry]
|
||||
description = "Description"
|
||||
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
||||
|
||||
[msg.dev.clients.scopes]
|
||||
email = "Email"
|
||||
@@ -330,8 +413,8 @@ dev_scope = "Dev Scope"
|
||||
hydra_health = "Hydra Health"
|
||||
|
||||
[msg.dev.sidebar]
|
||||
notice = "Notice"
|
||||
notice_detail = "Notice Detail"
|
||||
notice = "Developer Console"
|
||||
notice_detail = "Register and manage client applications."
|
||||
|
||||
[msg.info]
|
||||
saved_success = "Saved successfully."
|
||||
@@ -354,6 +437,7 @@ approved_device = "Approved Device"
|
||||
approved_ip = "Approve IP: {{ip}}"
|
||||
audit_empty = "Audit Empty"
|
||||
audit_load_error = "Audit Load Error"
|
||||
render_error = "Dashboard render error: {{error}}"
|
||||
auth_method = "Auth Method"
|
||||
client_id = "Client ID: {{id}}"
|
||||
client_id_missing = "Client Id Missing"
|
||||
@@ -385,7 +469,7 @@ empty = "Empty"
|
||||
load_error = "Load Error"
|
||||
|
||||
[msg.userfront.error]
|
||||
detail_contact = "msg.userfront.error.detail_contact"
|
||||
detail_contact = "Please contact administrator."
|
||||
detail_generic = "Detail Generic"
|
||||
detail_request = "Detail Request"
|
||||
id = "Id"
|
||||
@@ -446,7 +530,6 @@ token_missing = "Token Missing"
|
||||
verification_failed = "Verification Failed"
|
||||
|
||||
[msg.userfront.login.link]
|
||||
approved = "Approved"
|
||||
helper = "Sending you a login link"
|
||||
missing_login_id = "Missing Login Id"
|
||||
missing_phone = "Missing Phone"
|
||||
@@ -509,8 +592,6 @@ organization = "Organization"
|
||||
security = "Security"
|
||||
|
||||
[msg.userfront.qr]
|
||||
approve_error = "Approve Error"
|
||||
approve_success = "Approve Success"
|
||||
camera_error = "Camera Error"
|
||||
permission_error = "Permission Error"
|
||||
permission_required = "Permission Required"
|
||||
@@ -550,15 +631,15 @@ disabled = "Disabled"
|
||||
|
||||
[msg.userfront.signup]
|
||||
failed = "Failed"
|
||||
privacy_full = "\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 Line truncated
|
||||
tos_full = "\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한)\\n회사는 회원이 Line truncated
|
||||
privacy_full = "Privacy Full"
|
||||
tos_full = "Tos Full"
|
||||
|
||||
[msg.userfront.signup.agreement]
|
||||
title = "Title"
|
||||
title = "Agreement Title"
|
||||
|
||||
[msg.userfront.signup.auth]
|
||||
affiliate_notice = "Affiliate Notice"
|
||||
title = "Title"
|
||||
title = "Auth Title"
|
||||
|
||||
[msg.userfront.signup.email]
|
||||
code_mismatch = "Code Mismatch"
|
||||
@@ -574,7 +655,7 @@ lowercase_required = "Lowercase Required"
|
||||
mismatch = "Mismatch"
|
||||
number_required = "Number Required"
|
||||
symbol_required = "Symbol Required"
|
||||
title = "Title"
|
||||
title = "Password Title"
|
||||
uppercase_required = "Uppercase Required"
|
||||
|
||||
[msg.userfront.signup.password.rule]
|
||||
@@ -603,7 +684,7 @@ uppercase = "Uppercase"
|
||||
|
||||
[msg.userfront.signup.profile]
|
||||
affiliate_hint = "Affiliate Hint"
|
||||
title = "Title"
|
||||
title = "Profile Title"
|
||||
|
||||
[msg.userfront.signup.success]
|
||||
body = "Body"
|
||||
@@ -695,16 +776,30 @@ status = "STATUS"
|
||||
time = "TIME"
|
||||
|
||||
[ui.admin.groups]
|
||||
import_csv = "Import Csv"
|
||||
|
||||
[ui.admin.groups.create]
|
||||
description = "Adds a new organization unit such as a department or team."
|
||||
title = "Title"
|
||||
|
||||
[ui.admin.groups.detail]
|
||||
breadcrumb_org = "Breadcrumb Org"
|
||||
breadcrumb_tenant = "Tenant Details"
|
||||
breadcrumb_unit = "Breadcrumb Unit"
|
||||
members_subtitle = "Members Subtitle"
|
||||
members_title = "Members Title"
|
||||
permissions_subtitle = "Permissions Subtitle"
|
||||
permissions_title = "Permission Manage"
|
||||
|
||||
[ui.admin.groups.form]
|
||||
desc_label = "Description"
|
||||
desc_placeholder = "Desc Placeholder"
|
||||
name_label = "Group Name"
|
||||
name_placeholder = "Name Placeholder"
|
||||
parent_label = "Parent Unit"
|
||||
submit = "Submit"
|
||||
unit_level_label = "Unit Level Label"
|
||||
unit_level_placeholder = "Unit Level Placeholder"
|
||||
|
||||
[ui.admin.groups.list]
|
||||
title = "User Groups"
|
||||
@@ -724,6 +819,24 @@ name = "NAME"
|
||||
[ui.admin.header]
|
||||
plane = "Admin Plane"
|
||||
|
||||
[ui.admin.nav]
|
||||
api_keys = "API Keys"
|
||||
audit_logs = "Audit Logs"
|
||||
auth_guard = "Auth Guard"
|
||||
logout = "Logout"
|
||||
overview = "Overview"
|
||||
relying_parties = "Apps (RP)"
|
||||
tenant_dashboard = "Tenant Dashboard"
|
||||
user_groups = "User Groups"
|
||||
tenants = "Tenants"
|
||||
users = "Users"
|
||||
|
||||
[ui.admin.org]
|
||||
download_template = "Download Template"
|
||||
import_btn = "Import"
|
||||
import_title = "Bulk Organization Import"
|
||||
start_import = "Start Import"
|
||||
|
||||
[ui.admin.overview]
|
||||
kicker = "Global Overview"
|
||||
title = "Tenant-independent control plane"
|
||||
@@ -733,10 +846,20 @@ title = "Admin playbook"
|
||||
|
||||
[ui.admin.overview.quick_links]
|
||||
add_tenant = "Tenant Add"
|
||||
tenant_dashboard = "Tenant Dashboard"
|
||||
api_key_management = "API Key Management"
|
||||
user_management = "User Management"
|
||||
title = "Title"
|
||||
view_audit_logs = "View Audit Logs"
|
||||
|
||||
[ui.admin.overview.summary]
|
||||
audit_events_24h = "24h Events"
|
||||
oidc_clients = "OIDC Clients"
|
||||
policy_gate = "Policy Gate"
|
||||
total_tenants = "Total Tenants"
|
||||
|
||||
[ui.admin.profile]
|
||||
manageable_tenants = "Manageable Tenants"
|
||||
|
||||
[ui.admin.role]
|
||||
rp_admin = "RP ADMIN"
|
||||
super_admin = "SUPER ADMIN"
|
||||
@@ -745,8 +868,33 @@ tenant_member = "TENANT MEMBER"
|
||||
user = "TENANT MEMBER"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "Tenant Add"
|
||||
title = "Tenant List"
|
||||
add = "Add Tenant"
|
||||
title = "Tenant Registry"
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = "Add Button"
|
||||
already_admin = "Already Admin"
|
||||
dialog_description = "Dialog Description"
|
||||
dialog_no_results = "Dialog No Results"
|
||||
dialog_search_hint = "Dialog Search Hint"
|
||||
dialog_search_placeholder = "Dialog Search Placeholder"
|
||||
dialog_title = "Dialog Title"
|
||||
remove_title = "Remove Title"
|
||||
table_actions = "Table Actions"
|
||||
table_email = "Email"
|
||||
table_name = "Name"
|
||||
title = "Title"
|
||||
|
||||
[ui.admin.tenants.owners]
|
||||
add_button = "Add Owner"
|
||||
already_owner = "Already Owner"
|
||||
dialog_description = "Search users by name or email."
|
||||
dialog_title = "Add New Owner"
|
||||
remove_title = "Revoke Owner Permission"
|
||||
table_actions = "Actions"
|
||||
table_email = "Email"
|
||||
table_name = "Name"
|
||||
title = "Tenant Owners"
|
||||
|
||||
[ui.admin.tenants.breadcrumb]
|
||||
list = "List"
|
||||
@@ -764,9 +912,11 @@ description = "Description"
|
||||
domains_label = "Allowed Domains (Comma separated)"
|
||||
domains_placeholder = "example.com, example.kr"
|
||||
name = "Tenant name"
|
||||
parent = "Parent"
|
||||
slug = "Slug"
|
||||
slug_placeholder = "tenant-slug"
|
||||
status = "Status"
|
||||
type = "Type"
|
||||
|
||||
[ui.admin.tenants.create.memo]
|
||||
title = "Title"
|
||||
@@ -774,8 +924,28 @@ title = "Title"
|
||||
[ui.admin.tenants.create.profile]
|
||||
title = "Tenant Profile"
|
||||
|
||||
[ui.admin.tenants.detail]
|
||||
breadcrumb_list = "Tenant List"
|
||||
header_subtitle = "Header Subtitle"
|
||||
loading = "Loading"
|
||||
tab_federation = "Tab Federation"
|
||||
tab_organization = "Organization Manage"
|
||||
tab_permissions = "Permissions"
|
||||
tab_profile = "Profile"
|
||||
tab_schema = "Tab Schema"
|
||||
title = "Details"
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
select_placeholder = "Select Placeholder"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
descendants = "Descendant Members"
|
||||
direct = "Direct Members"
|
||||
direct_label = "Direct"
|
||||
list_title = "Member Management"
|
||||
title = "Tenant Members ({{count}})"
|
||||
total = "Total"
|
||||
total_label = "Total"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = "EMAIL"
|
||||
@@ -783,44 +953,50 @@ name = "NAME"
|
||||
role = "ROLE"
|
||||
status = "STATUS"
|
||||
|
||||
[ui.admin.tenants.profile]
|
||||
allowed_domains = "Allowed Domains"
|
||||
allowed_domains_help = "Users with these email domains will be automatically assigned to this tenant."
|
||||
approve_button = "Approve Tenant"
|
||||
description = "Description"
|
||||
name = "Tenant Name"
|
||||
slug = "Slug"
|
||||
status = "Status"
|
||||
subtitle = "Slug and status changes are applied immediately."
|
||||
title = "Tenant Profile"
|
||||
type = "Type"
|
||||
|
||||
[ui.admin.tenants.registry]
|
||||
title = "Tenant registry"
|
||||
|
||||
[ui.admin.tenants.schema]
|
||||
add_field = "Add Field"
|
||||
save = "Save Schema Changes"
|
||||
save = "Save Schema"
|
||||
title = "User Schema Extension"
|
||||
|
||||
[ui.admin.tenants.schema.field]
|
||||
admin_only = "Admin Only"
|
||||
key = "Field Key (ID)"
|
||||
key_placeholder = "e.g. employee_id"
|
||||
label = "Display Label"
|
||||
label_placeholder = "Label Placeholder"
|
||||
required = "Required"
|
||||
type = "Type"
|
||||
type_boolean = "Boolean"
|
||||
type_date = "Date"
|
||||
type_number = "Number"
|
||||
type_text = "Text"
|
||||
|
||||
[ui.admin.tenants.detail]
|
||||
breadcrumb_list = "Tenant List"
|
||||
header_subtitle = "Update tenant information or manage integration settings."
|
||||
loading = "Loading tenant information..."
|
||||
tab_admins = "Admin Settings"
|
||||
tab_federation = "External Integration"
|
||||
tab_organization = "Sub-tenant Management"
|
||||
tab_profile = "Profile"
|
||||
tab_schema = "User Schema"
|
||||
title = "Tenant Details"
|
||||
validation_placeholder = "Regex Pattern (Optional)"
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add = "Add Sub-tenant"
|
||||
add_existing = "Add Existing Tenant"
|
||||
add = "Add"
|
||||
add_dialog_desc = "Select a tenant to add as a sub-tenant."
|
||||
add_dialog_title = "Add Sub-tenant"
|
||||
add_dialog_desc = "Search existing tenants to add as sub-tenants."
|
||||
search_placeholder = "Search name or slug..."
|
||||
no_candidates = "No available tenants found."
|
||||
add_existing = "Add Existing Tenant"
|
||||
manage = "Manage"
|
||||
title = "Sub-tenant Management ({{count}})"
|
||||
no_candidates = "No available tenants to add."
|
||||
search_placeholder = "Search..."
|
||||
title = "Sub-tenants ({{count}})"
|
||||
tree_search_placeholder = "Search in tree..."
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
action = "ACTION"
|
||||
@@ -830,13 +1006,26 @@ status = "STATUS"
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = "ACTIONS"
|
||||
members = "Members"
|
||||
name = "NAME"
|
||||
slug = "SLUG"
|
||||
status = "STATUS"
|
||||
type = "TYPE"
|
||||
updated = "UPDATED"
|
||||
|
||||
[ui.admin.users]
|
||||
|
||||
[ui.admin.users.bulk]
|
||||
do_move = "Execute Move"
|
||||
download_template = "Download Template"
|
||||
move_group = "Bulk Tenant Move"
|
||||
move_title = "Bulk User Move"
|
||||
no_department = "No Department"
|
||||
select_group = "Select Target Tenant"
|
||||
selected_count = "{{count}} users selected"
|
||||
start_upload = "Start Upload"
|
||||
title = "Bulk Actions"
|
||||
|
||||
[ui.admin.users.create]
|
||||
back = "Back"
|
||||
go_list = "Go List"
|
||||
@@ -859,14 +1048,18 @@ department = "Department"
|
||||
department_placeholder = "Department Placeholder"
|
||||
email = "Email"
|
||||
email_placeholder = "user@example.com"
|
||||
job_title = "Job Title"
|
||||
job_title_placeholder = "e.g. Frontend Developer"
|
||||
name = "Name"
|
||||
name_placeholder = "Name Placeholder"
|
||||
password = "Password"
|
||||
password_placeholder = "********"
|
||||
phone = "Phone number"
|
||||
phone_placeholder = "010-1234-5678"
|
||||
position = "Position"
|
||||
position_placeholder = "e.g. Senior"
|
||||
role = "Role"
|
||||
tenant = "Tenant (Tenant)"
|
||||
tenant = "Tenant"
|
||||
tenant_global = "Tenant Global"
|
||||
|
||||
[ui.admin.users.create.password_generated]
|
||||
@@ -881,7 +1074,7 @@ title = "User Details"
|
||||
section = "Users"
|
||||
|
||||
[ui.admin.users.detail.custom_fields]
|
||||
title = "Title"
|
||||
multi_title = "Per-tenant Profile Management"
|
||||
|
||||
[ui.admin.users.detail.form]
|
||||
department = "Department"
|
||||
@@ -892,7 +1085,7 @@ phone = "Phone number"
|
||||
phone_placeholder = "010-1234-5678"
|
||||
role = "Role"
|
||||
status = "Status"
|
||||
tenant = "Tenant (Tenant)"
|
||||
tenant = "Representative Affiliated Tenant"
|
||||
tenant_global = "Tenant Global"
|
||||
|
||||
[ui.admin.users.detail.security]
|
||||
@@ -900,19 +1093,32 @@ password = "Password"
|
||||
password_placeholder = "Password Placeholder"
|
||||
title = "Security Settings"
|
||||
|
||||
[ui.admin.users.detail.tenants_section]
|
||||
additional = "Additional Affiliated/Manageable Tenants"
|
||||
primary = "Representative Affiliated Tenant"
|
||||
title = "Affiliation & Organization Info"
|
||||
|
||||
[ui.admin.users.list]
|
||||
add = "User Add"
|
||||
delete_aria = "User Delete: {{name}}"
|
||||
edit_aria = "User Edit: {{name}}"
|
||||
bulk_import = "Bulk Import"
|
||||
empty = "Empty"
|
||||
fetch_error = "Fetch Error"
|
||||
search_placeholder = "Search Placeholder"
|
||||
tenant_slug = "Slug: {{slug}}"
|
||||
subtitle = "Subtitle"
|
||||
title = "User Manage"
|
||||
|
||||
[ui.admin.users.list.breadcrumb]
|
||||
list = "List"
|
||||
section = "Users"
|
||||
|
||||
[ui.admin.users.list.columns]
|
||||
title = "Column Settings"
|
||||
|
||||
[ui.admin.users.list.filter]
|
||||
tenant = "Tenant Filter"
|
||||
|
||||
[ui.admin.users.list.registry]
|
||||
count = "Count"
|
||||
title = "User Registry"
|
||||
|
||||
[ui.admin.users.list.table]
|
||||
@@ -923,11 +1129,21 @@ role = "ROLE"
|
||||
status = "STATUS"
|
||||
tenant_dept = "TENANT / DEPT"
|
||||
|
||||
[ui.admin.users.table]
|
||||
email = "Email"
|
||||
name = "Name"
|
||||
role = "Role"
|
||||
|
||||
|
||||
[ui.common]
|
||||
add = "Add"
|
||||
all = "All"
|
||||
admin_only = "Admin Only"
|
||||
assign = "Assign"
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
change_file = "Change File"
|
||||
clear_search = "Clear Search"
|
||||
close = "Close"
|
||||
collapse = "Collapse"
|
||||
confirm = "Confirm"
|
||||
@@ -936,25 +1152,36 @@ create = "Create"
|
||||
delete = "Delete"
|
||||
details = "Details"
|
||||
edit = "Edit"
|
||||
export = "Export"
|
||||
fail = "Fail"
|
||||
go_home = "Go Home"
|
||||
view = "View"
|
||||
hyphen = "-"
|
||||
manage = "Manage"
|
||||
na = "N/A"
|
||||
never = "Never"
|
||||
next = "Next"
|
||||
none = "None"
|
||||
page_of = "Page {{page}} of {{total}}"
|
||||
prev = "Prev"
|
||||
previous = "Previous"
|
||||
qr = "QR"
|
||||
reset = "Reset"
|
||||
read_only = "Read Only"
|
||||
refresh = "Refresh"
|
||||
requesting = "Requesting"
|
||||
remove = "Remove"
|
||||
resend = "Resend"
|
||||
retry = "Retry"
|
||||
save = "Save"
|
||||
search = "Search"
|
||||
select = "Select"
|
||||
select_file = "Select File"
|
||||
select_placeholder = "Select Placeholder"
|
||||
show_more = "Show More"
|
||||
language = "Language"
|
||||
language_ko = "한국어"
|
||||
language_ko = "Korean"
|
||||
language_en = "English"
|
||||
success = "Success"
|
||||
theme_dark = "Dark"
|
||||
theme_light = "Light"
|
||||
theme_toggle = "Theme Toggle"
|
||||
@@ -982,16 +1209,50 @@ ok = "Ok"
|
||||
pending = "Pending"
|
||||
success = "Success"
|
||||
|
||||
[test]
|
||||
key = "Test"
|
||||
|
||||
[non.existent]
|
||||
key = "Non-existent key"
|
||||
|
||||
[ui.dev]
|
||||
brand = "Brand"
|
||||
console_title = "Developer Console"
|
||||
env_badge = "Env: dev"
|
||||
scope_badge = "Scoped to /dev"
|
||||
|
||||
[ui.dev.nav]
|
||||
clients = "Connected Application"
|
||||
logout = "Logout"
|
||||
|
||||
[ui.dev.audit]
|
||||
load_more = "Load more"
|
||||
title = "Audit Logs"
|
||||
|
||||
[ui.dev.audit.registry]
|
||||
title = "Audit registry"
|
||||
|
||||
[ui.dev.audit.filter]
|
||||
action = "Filter by Action (e.g. ROTATE_SECRET)"
|
||||
client_id = "Filter by Client ID"
|
||||
status_all = "All Status"
|
||||
|
||||
[ui.dev.audit.table]
|
||||
action = "Action"
|
||||
actor = "Actor"
|
||||
status = "Status"
|
||||
target = "Target"
|
||||
time = "Time"
|
||||
|
||||
[ui.dev.profile]
|
||||
menu_aria = "Open account menu"
|
||||
menu_title = "Account"
|
||||
unknown_email = "unknown@example.com"
|
||||
unknown_name = "Unknown User"
|
||||
|
||||
[ui.dev.clients]
|
||||
copy_client_id = "Copy client id"
|
||||
new = "New"
|
||||
search_placeholder = "Search Placeholder"
|
||||
new = "Add Connected Application"
|
||||
search_placeholder = "Search by app name or ID..."
|
||||
tenant_scoped = "Tenant-scoped"
|
||||
untitled = "Untitled"
|
||||
|
||||
@@ -999,9 +1260,16 @@ untitled = "Untitled"
|
||||
admin_session = "Admin Session"
|
||||
tenant_selected = "Tenant Selected"
|
||||
|
||||
[ui.dev.clients.filter]
|
||||
status_all = "All Statuses"
|
||||
type_all = "All Types"
|
||||
type_label = "Type:"
|
||||
|
||||
[ui.dev.clients.consents]
|
||||
export_csv = "Export CSV"
|
||||
revoke = "Revoke"
|
||||
revoked_at = "Revoked: "
|
||||
scope_label = "Scope:"
|
||||
search_placeholder = "Search Placeholder"
|
||||
status_all = "All Statuses"
|
||||
status_label = "Status:"
|
||||
@@ -1033,25 +1301,21 @@ user = "User"
|
||||
|
||||
[ui.dev.clients.details]
|
||||
|
||||
[ui.dev.clients.details.breadcrumb]
|
||||
current = "Current"
|
||||
section = "Relying Parties"
|
||||
|
||||
[ui.dev.clients.details.credentials]
|
||||
client_id = "Client ID"
|
||||
client_secret = "Client Secret"
|
||||
title = "Title"
|
||||
title = "Client Credentials"
|
||||
|
||||
[ui.dev.clients.details.endpoints]
|
||||
read_only = "Read Only"
|
||||
title = "Title"
|
||||
title = "OIDC Endpoints"
|
||||
|
||||
[ui.dev.clients.details.redirect]
|
||||
callback_label = "Callback Label"
|
||||
label = "Redirect URIs"
|
||||
placeholder = "https://your-app.com/callback, http://localhost:3000/auth/callback"
|
||||
save = "Save"
|
||||
title = "Title"
|
||||
title = "Redirection Settings"
|
||||
|
||||
[ui.dev.clients.details.secret]
|
||||
hide = "Hide"
|
||||
@@ -1059,26 +1323,23 @@ rotate = "Rotate"
|
||||
show = "Show"
|
||||
|
||||
[ui.dev.clients.details.security]
|
||||
title = "Title"
|
||||
title = "Security Note"
|
||||
|
||||
[ui.dev.clients.details.tab]
|
||||
connection = "Connection"
|
||||
connection = "Federation"
|
||||
consents = "Consent & Users"
|
||||
settings = "Settings"
|
||||
|
||||
[ui.dev.clients.general]
|
||||
create = "Create"
|
||||
display_new = "Display New"
|
||||
save = "Settings Save"
|
||||
create = "Create Application"
|
||||
display_new = "Add Connected Application"
|
||||
title_create = "Create Client"
|
||||
title_edit = "Client Settings"
|
||||
|
||||
[ui.dev.clients.general.breadcrumb]
|
||||
section = "Applications"
|
||||
|
||||
[ui.dev.clients.general.footer]
|
||||
client_id = "Client ID"
|
||||
created_on = "Created On"
|
||||
[ui.dev.clients.federation]
|
||||
title = "Identity Federation"
|
||||
add_title = "Add Identity Provider"
|
||||
add_btn = "Add Provider"
|
||||
|
||||
[ui.dev.clients.general.identity]
|
||||
description = "Description"
|
||||
@@ -1104,19 +1365,22 @@ title = "Scopes"
|
||||
description = "Description"
|
||||
mandatory = "Mandatory"
|
||||
name = "Scope Name"
|
||||
delete = "Delete"
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
confidential = "Confidential"
|
||||
public = "Public"
|
||||
private = "Server Side App"
|
||||
pkce = "PKCE"
|
||||
title = "Security Settings"
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||
docs_title = "Docs & Examples"
|
||||
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
|
||||
title = "Need help with OIDC configuration?"
|
||||
view_guides = "View guides"
|
||||
|
||||
[ui.dev.clients.list]
|
||||
title = "Title"
|
||||
title = "Connected Applications"
|
||||
|
||||
[ui.dev.clients.owner]
|
||||
avatar_alt = "ops user"
|
||||
@@ -1128,9 +1392,15 @@ subtitle = "Tenant admin on-call"
|
||||
title = "Owner"
|
||||
|
||||
[ui.dev.clients.registry]
|
||||
subtitle = "Relying Parties"
|
||||
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
||||
subtitle = "Applications"
|
||||
title = "RP registry"
|
||||
|
||||
[ui.dev.clients.scopes]
|
||||
email = "Email"
|
||||
openid = "Openid"
|
||||
profile = "Profile"
|
||||
|
||||
[ui.dev.clients.table]
|
||||
actions = "Actions"
|
||||
application = "Application"
|
||||
@@ -1140,8 +1410,8 @@ status = "Status"
|
||||
type = "Type"
|
||||
|
||||
[ui.dev.clients.type]
|
||||
confidential = "Confidential"
|
||||
public = "Public"
|
||||
pkce = "PKCE"
|
||||
private = "Server side App"
|
||||
|
||||
[ui.dev.dashboard]
|
||||
ready_badge = "devfront ready"
|
||||
@@ -1177,6 +1447,14 @@ title = "Stack readiness"
|
||||
plane = "Dev Plane"
|
||||
subtitle = "Manage your applications"
|
||||
|
||||
[ui.dev.session]
|
||||
active = "Checking expiration..."
|
||||
unknown = "Unknown"
|
||||
expired = "Session expired"
|
||||
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
|
||||
remaining = "Expires in: {{minutes}}m {{seconds}}s"
|
||||
refresh = "Refresh session expiry"
|
||||
refreshing = "Refreshing session expiry..."
|
||||
|
||||
[ui.userfront]
|
||||
app_title = "Baron SW Portal"
|
||||
@@ -1253,12 +1531,9 @@ login_id = "Emain or Phone Number"
|
||||
password = "Password"
|
||||
|
||||
[ui.userfront.login.link]
|
||||
action_label = "Action Label"
|
||||
code_only = "Code Only"
|
||||
page_title = "Page Title"
|
||||
resend_with_time = "Resend With Time"
|
||||
send = "Send"
|
||||
title = "Title"
|
||||
|
||||
[ui.userfront.login.qr]
|
||||
expired = "Expired"
|
||||
@@ -1328,9 +1603,10 @@ organization = "Organization"
|
||||
security = "Security"
|
||||
|
||||
[ui.userfront.qr]
|
||||
request_permission = "Request Permission"
|
||||
camera_error = "Camera Error"
|
||||
permission_error = "Permission Error"
|
||||
permission_required = "Permission Required"
|
||||
rescan = "Rescan"
|
||||
result_failure = "Result Failure"
|
||||
result_success = "Result Success"
|
||||
title = "Scan QR Code"
|
||||
|
||||
@@ -1390,15 +1666,3 @@ verify = "Verify"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "Action"
|
||||
|
||||
[ui.admin.nav]
|
||||
api_keys = "API Keys"
|
||||
audit_logs = "Audit Logs"
|
||||
auth_guard = "Auth Guard"
|
||||
logout = "Logout"
|
||||
overview = "Overview"
|
||||
relying_parties = "Apps (RP)"
|
||||
tenant_dashboard = "Tenant Dashboard"
|
||||
user_groups = "Organization"
|
||||
tenants = "Tenants"
|
||||
users = "Users"
|
||||
+414
-246
File diff suppressed because it is too large.
Load diff
@@ -13,11 +13,35 @@ jangheon = ""
|
||||
ptc = ""
|
||||
saman = ""
|
||||
|
||||
[domain.tenant_type]
|
||||
company = ""
|
||||
company_group = ""
|
||||
personal = ""
|
||||
user_group = ""
|
||||
|
||||
[err]
|
||||
|
||||
[err.common]
|
||||
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.auth_proxy]
|
||||
@@ -49,6 +73,9 @@ scope_admin = ""
|
||||
session_ttl = ""
|
||||
tenant_headers = ""
|
||||
|
||||
[msg.admin.common]
|
||||
forbidden = ""
|
||||
|
||||
[msg.admin.api_keys]
|
||||
|
||||
[msg.admin.api_keys.create]
|
||||
@@ -89,17 +116,40 @@ count = ""
|
||||
|
||||
[msg.admin.groups]
|
||||
|
||||
[msg.admin.groups.create]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[msg.admin.groups.list]
|
||||
create_error = ""
|
||||
create_success = ""
|
||||
delete_confirm = ""
|
||||
delete_error = ""
|
||||
delete_success = ""
|
||||
empty = ""
|
||||
import_error = ""
|
||||
import_success = ""
|
||||
loading = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.groups.members]
|
||||
add_success = ""
|
||||
count = ""
|
||||
empty = ""
|
||||
remove_confirm = ""
|
||||
remove_success = ""
|
||||
title = ""
|
||||
|
||||
[msg.admin.groups.prompt]
|
||||
user_id = ""
|
||||
|
||||
[msg.admin.groups.roles]
|
||||
assign_success = ""
|
||||
description = ""
|
||||
empty = ""
|
||||
remove_confirm = ""
|
||||
remove_success = ""
|
||||
|
||||
[msg.admin.header]
|
||||
subtitle = ""
|
||||
|
||||
@@ -107,6 +157,12 @@ subtitle = ""
|
||||
idp_policy = ""
|
||||
scope = ""
|
||||
|
||||
[msg.admin.org]
|
||||
hover_member_info = ""
|
||||
import_description = ""
|
||||
import_error = ""
|
||||
import_success = ""
|
||||
|
||||
[msg.admin.overview]
|
||||
description = ""
|
||||
idp_fallback = ""
|
||||
@@ -122,10 +178,36 @@ tenant_title = ""
|
||||
[msg.admin.overview.quick_links]
|
||||
description = ""
|
||||
|
||||
[msg.admin.overview.summary]
|
||||
audit_events_24h = ""
|
||||
oidc_clients = ""
|
||||
policy_gate = ""
|
||||
total_tenants = ""
|
||||
|
||||
[msg.admin.tenants]
|
||||
approve_confirm = ""
|
||||
approve_success = ""
|
||||
delete_confirm = ""
|
||||
delete_success = ""
|
||||
empty = ""
|
||||
fetch_error = ""
|
||||
missing_id = ""
|
||||
not_found = ""
|
||||
remove_sub_confirm = ""
|
||||
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]
|
||||
@@ -142,7 +224,9 @@ subtitle = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.tenants.members]
|
||||
desc = ""
|
||||
empty = ""
|
||||
limit_notice = ""
|
||||
|
||||
[msg.admin.tenants.registry]
|
||||
count = ""
|
||||
@@ -160,15 +244,28 @@ subtitle = ""
|
||||
|
||||
[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]
|
||||
error = ""
|
||||
password_required = ""
|
||||
success = ""
|
||||
|
||||
[msg.admin.users.create.account]
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.users.create.form]
|
||||
email_required = ""
|
||||
field_invalid = ""
|
||||
field_required = ""
|
||||
name_required = ""
|
||||
password_auto_help = ""
|
||||
password_manual_help = ""
|
||||
@@ -185,6 +282,7 @@ update_error = ""
|
||||
update_success = ""
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = ""
|
||||
name_required = ""
|
||||
|
||||
[msg.admin.users.detail.security]
|
||||
@@ -196,23 +294,40 @@ empty = ""
|
||||
fetch_error = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.users.list.columns]
|
||||
description = ""
|
||||
no_custom = ""
|
||||
|
||||
[msg.admin.users.list.registry]
|
||||
count = ""
|
||||
|
||||
[msg.common]
|
||||
error = ""
|
||||
loading = ""
|
||||
no_description = ""
|
||||
parsing = ""
|
||||
requesting = ""
|
||||
saving = ""
|
||||
unknown_error = ""
|
||||
|
||||
[msg.dev]
|
||||
logout_confirm = ""
|
||||
|
||||
[msg.dev.audit]
|
||||
empty = ""
|
||||
forbidden = ""
|
||||
load_error = ""
|
||||
loaded_count = ""
|
||||
loading = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.dev.clients]
|
||||
copy_client_id = ""
|
||||
load_error = ""
|
||||
loading = ""
|
||||
showing = ""
|
||||
status_update_error = ""
|
||||
status_updated = ""
|
||||
deleted = ""
|
||||
delete_error = ""
|
||||
delete_confirm = ""
|
||||
|
||||
[msg.dev.clients.consents]
|
||||
empty = ""
|
||||
@@ -220,6 +335,7 @@ load_error = ""
|
||||
loading = ""
|
||||
showing = ""
|
||||
subtitle = ""
|
||||
revoke_confirm = ""
|
||||
|
||||
[msg.dev.clients.details]
|
||||
copy_client_id = ""
|
||||
@@ -247,6 +363,13 @@ note = ""
|
||||
load_error = ""
|
||||
loading = ""
|
||||
saved = ""
|
||||
save_error = ""
|
||||
status_changed = ""
|
||||
|
||||
[msg.dev.clients.federation]
|
||||
subtitle = ""
|
||||
add_subtitle = ""
|
||||
empty = ""
|
||||
|
||||
[msg.dev.clients.general.identity]
|
||||
logo_help = ""
|
||||
@@ -260,8 +383,8 @@ empty = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.dev.clients.general.security]
|
||||
confidential_help = ""
|
||||
public_help = ""
|
||||
private_help = ""
|
||||
pkce_help = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.dev.clients.help]
|
||||
@@ -314,6 +437,7 @@ approved_device = ""
|
||||
approved_ip = ""
|
||||
audit_empty = ""
|
||||
audit_load_error = ""
|
||||
render_error = ""
|
||||
auth_method = ""
|
||||
client_id = ""
|
||||
client_id_missing = ""
|
||||
@@ -406,7 +530,6 @@ token_missing = ""
|
||||
verification_failed = ""
|
||||
|
||||
[msg.userfront.login.link]
|
||||
approved = ""
|
||||
helper = ""
|
||||
missing_login_id = ""
|
||||
missing_phone = ""
|
||||
@@ -469,8 +592,6 @@ organization = ""
|
||||
security = ""
|
||||
|
||||
[msg.userfront.qr]
|
||||
approve_error = ""
|
||||
approve_success = ""
|
||||
camera_error = ""
|
||||
permission_error = ""
|
||||
permission_required = ""
|
||||
@@ -655,16 +776,30 @@ status = ""
|
||||
time = ""
|
||||
|
||||
[ui.admin.groups]
|
||||
import_csv = ""
|
||||
|
||||
[ui.admin.groups.create]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.groups.detail]
|
||||
breadcrumb_org = ""
|
||||
breadcrumb_tenant = ""
|
||||
breadcrumb_unit = ""
|
||||
members_subtitle = ""
|
||||
members_title = ""
|
||||
permissions_subtitle = ""
|
||||
permissions_title = ""
|
||||
|
||||
[ui.admin.groups.form]
|
||||
desc_label = ""
|
||||
desc_placeholder = ""
|
||||
name_label = ""
|
||||
name_placeholder = ""
|
||||
parent_label = ""
|
||||
submit = ""
|
||||
unit_level_label = ""
|
||||
unit_level_placeholder = ""
|
||||
|
||||
[ui.admin.groups.list]
|
||||
title = ""
|
||||
@@ -696,6 +831,12 @@ user_groups = ""
|
||||
tenants = ""
|
||||
users = ""
|
||||
|
||||
[ui.admin.org]
|
||||
download_template = ""
|
||||
import_btn = ""
|
||||
import_title = ""
|
||||
start_import = ""
|
||||
|
||||
[ui.admin.overview]
|
||||
kicker = ""
|
||||
title = ""
|
||||
@@ -705,10 +846,20 @@ title = ""
|
||||
|
||||
[ui.admin.overview.quick_links]
|
||||
add_tenant = ""
|
||||
tenant_dashboard = ""
|
||||
api_key_management = ""
|
||||
user_management = ""
|
||||
title = ""
|
||||
view_audit_logs = ""
|
||||
|
||||
[ui.admin.overview.summary]
|
||||
audit_events_24h = ""
|
||||
oidc_clients = ""
|
||||
policy_gate = ""
|
||||
total_tenants = ""
|
||||
|
||||
[ui.admin.profile]
|
||||
manageable_tenants = ""
|
||||
|
||||
[ui.admin.role]
|
||||
rp_admin = ""
|
||||
super_admin = ""
|
||||
@@ -720,6 +871,31 @@ user = ""
|
||||
add = ""
|
||||
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]
|
||||
list = ""
|
||||
section = ""
|
||||
@@ -736,9 +912,11 @@ description = ""
|
||||
domains_label = ""
|
||||
domains_placeholder = ""
|
||||
name = ""
|
||||
parent = ""
|
||||
slug = ""
|
||||
slug_placeholder = ""
|
||||
status = ""
|
||||
type = ""
|
||||
|
||||
[ui.admin.tenants.create.memo]
|
||||
title = ""
|
||||
@@ -746,15 +924,47 @@ title = ""
|
||||
[ui.admin.tenants.create.profile]
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
[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]
|
||||
descendants = ""
|
||||
direct = ""
|
||||
direct_label = ""
|
||||
list_title = ""
|
||||
title = ""
|
||||
total = ""
|
||||
total_label = ""
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = ""
|
||||
name = ""
|
||||
role = ""
|
||||
status = ""
|
||||
|
||||
[ui.admin.tenants.profile]
|
||||
allowed_domains = ""
|
||||
allowed_domains_help = ""
|
||||
approve_button = ""
|
||||
description = ""
|
||||
name = ""
|
||||
slug = ""
|
||||
status = ""
|
||||
subtitle = ""
|
||||
title = ""
|
||||
type = ""
|
||||
|
||||
[ui.admin.tenants.registry]
|
||||
title = ""
|
||||
|
||||
@@ -764,19 +974,29 @@ save = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.schema.field]
|
||||
admin_only = ""
|
||||
key = ""
|
||||
key_placeholder = ""
|
||||
label = ""
|
||||
label_placeholder = ""
|
||||
required = ""
|
||||
type = ""
|
||||
type_boolean = ""
|
||||
type_date = ""
|
||||
type_number = ""
|
||||
type_text = ""
|
||||
validation_placeholder = ""
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add = ""
|
||||
add_dialog_desc = ""
|
||||
add_dialog_title = ""
|
||||
add_existing = ""
|
||||
manage = ""
|
||||
no_candidates = ""
|
||||
search_placeholder = ""
|
||||
title = ""
|
||||
tree_search_placeholder = ""
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
action = ""
|
||||
@@ -786,13 +1006,26 @@ status = ""
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = ""
|
||||
members = ""
|
||||
name = ""
|
||||
slug = ""
|
||||
status = ""
|
||||
type = ""
|
||||
updated = ""
|
||||
|
||||
[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]
|
||||
back = ""
|
||||
go_list = ""
|
||||
@@ -815,12 +1048,16 @@ department = ""
|
||||
department_placeholder = ""
|
||||
email = ""
|
||||
email_placeholder = ""
|
||||
job_title = ""
|
||||
job_title_placeholder = ""
|
||||
name = ""
|
||||
name_placeholder = ""
|
||||
password = ""
|
||||
password_placeholder = ""
|
||||
phone = ""
|
||||
phone_placeholder = ""
|
||||
position = ""
|
||||
position_placeholder = ""
|
||||
role = ""
|
||||
tenant = ""
|
||||
tenant_global = ""
|
||||
@@ -837,7 +1074,7 @@ title = ""
|
||||
section = ""
|
||||
|
||||
[ui.admin.users.detail.custom_fields]
|
||||
title = ""
|
||||
multi_title = ""
|
||||
|
||||
[ui.admin.users.detail.form]
|
||||
department = ""
|
||||
@@ -856,19 +1093,32 @@ password = ""
|
||||
password_placeholder = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.users.detail.tenants_section]
|
||||
additional = ""
|
||||
primary = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.users.list]
|
||||
add = ""
|
||||
delete_aria = ""
|
||||
edit_aria = ""
|
||||
bulk_import = ""
|
||||
empty = ""
|
||||
fetch_error = ""
|
||||
search_placeholder = ""
|
||||
tenant_slug = ""
|
||||
subtitle = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.users.list.breadcrumb]
|
||||
list = ""
|
||||
section = ""
|
||||
|
||||
[ui.admin.users.list.columns]
|
||||
title = ""
|
||||
|
||||
[ui.admin.users.list.filter]
|
||||
tenant = ""
|
||||
|
||||
[ui.admin.users.list.registry]
|
||||
count = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.users.list.table]
|
||||
@@ -879,11 +1129,21 @@ role = ""
|
||||
status = ""
|
||||
tenant_dept = ""
|
||||
|
||||
[ui.admin.users.table]
|
||||
email = ""
|
||||
name = ""
|
||||
role = ""
|
||||
|
||||
|
||||
[ui.common]
|
||||
add = ""
|
||||
all = ""
|
||||
admin_only = ""
|
||||
assign = ""
|
||||
back = ""
|
||||
cancel = ""
|
||||
change_file = ""
|
||||
clear_search = ""
|
||||
close = ""
|
||||
collapse = ""
|
||||
confirm = ""
|
||||
@@ -892,25 +1152,36 @@ create = ""
|
||||
delete = ""
|
||||
details = ""
|
||||
edit = ""
|
||||
export = ""
|
||||
fail = ""
|
||||
go_home = ""
|
||||
view = ""
|
||||
hyphen = ""
|
||||
manage = ""
|
||||
na = ""
|
||||
never = ""
|
||||
next = ""
|
||||
none = ""
|
||||
page_of = ""
|
||||
prev = ""
|
||||
previous = ""
|
||||
qr = ""
|
||||
reset = ""
|
||||
read_only = ""
|
||||
refresh = ""
|
||||
requesting = ""
|
||||
remove = ""
|
||||
resend = ""
|
||||
retry = ""
|
||||
save = ""
|
||||
search = ""
|
||||
select = ""
|
||||
select_file = ""
|
||||
select_placeholder = ""
|
||||
show_more = ""
|
||||
language = ""
|
||||
language_ko = ""
|
||||
language_en = ""
|
||||
success = ""
|
||||
theme_dark = ""
|
||||
theme_light = ""
|
||||
theme_toggle = ""
|
||||
@@ -938,14 +1209,48 @@ ok = ""
|
||||
pending = ""
|
||||
success = ""
|
||||
|
||||
[test]
|
||||
key = ""
|
||||
|
||||
[non.existent]
|
||||
key = ""
|
||||
|
||||
[ui.dev]
|
||||
brand = ""
|
||||
console_title = ""
|
||||
env_badge = ""
|
||||
scope_badge = ""
|
||||
|
||||
[ui.dev.nav]
|
||||
clients = ""
|
||||
logout = ""
|
||||
|
||||
[ui.dev.audit]
|
||||
load_more = ""
|
||||
title = ""
|
||||
|
||||
[ui.dev.audit.registry]
|
||||
title = ""
|
||||
|
||||
[ui.dev.audit.filter]
|
||||
action = ""
|
||||
client_id = ""
|
||||
status_all = ""
|
||||
|
||||
[ui.dev.audit.table]
|
||||
action = ""
|
||||
actor = ""
|
||||
status = ""
|
||||
target = ""
|
||||
time = ""
|
||||
|
||||
[ui.dev.profile]
|
||||
menu_aria = ""
|
||||
menu_title = ""
|
||||
unknown_email = ""
|
||||
unknown_name = ""
|
||||
|
||||
[ui.dev.clients]
|
||||
copy_client_id = ""
|
||||
new = ""
|
||||
search_placeholder = ""
|
||||
tenant_scoped = ""
|
||||
@@ -955,11 +1260,17 @@ untitled = ""
|
||||
admin_session = ""
|
||||
tenant_selected = ""
|
||||
|
||||
[ui.dev.clients.filter]
|
||||
status_all = ""
|
||||
type_all = ""
|
||||
type_label = ""
|
||||
|
||||
[ui.dev.clients.consents]
|
||||
export_csv = ""
|
||||
revoke = ""
|
||||
revoked_at = ""
|
||||
scope_label = ""
|
||||
search_placeholder = ""
|
||||
status_all = ""
|
||||
status_label = ""
|
||||
status_revoked = ""
|
||||
subject = ""
|
||||
@@ -989,10 +1300,6 @@ user = ""
|
||||
|
||||
[ui.dev.clients.details]
|
||||
|
||||
[ui.dev.clients.details.breadcrumb]
|
||||
current = ""
|
||||
section = ""
|
||||
|
||||
[ui.dev.clients.details.credentials]
|
||||
client_id = ""
|
||||
client_secret = ""
|
||||
@@ -1025,16 +1332,13 @@ settings = ""
|
||||
[ui.dev.clients.general]
|
||||
create = ""
|
||||
display_new = ""
|
||||
save = ""
|
||||
title_create = ""
|
||||
title_edit = ""
|
||||
|
||||
[ui.dev.clients.general.breadcrumb]
|
||||
section = ""
|
||||
|
||||
[ui.dev.clients.general.footer]
|
||||
client_id = ""
|
||||
created_on = ""
|
||||
[ui.dev.clients.federation]
|
||||
title = ""
|
||||
add_title = ""
|
||||
add_btn = ""
|
||||
|
||||
[ui.dev.clients.general.identity]
|
||||
description = ""
|
||||
@@ -1060,14 +1364,17 @@ title = ""
|
||||
description = ""
|
||||
mandatory = ""
|
||||
name = ""
|
||||
delete = ""
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
confidential = ""
|
||||
public = ""
|
||||
private = ""
|
||||
pkce = ""
|
||||
title = ""
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = ""
|
||||
docs_title = ""
|
||||
subtitle = ""
|
||||
title = ""
|
||||
view_guides = ""
|
||||
|
||||
@@ -1084,9 +1391,15 @@ subtitle = ""
|
||||
title = ""
|
||||
|
||||
[ui.dev.clients.registry]
|
||||
description = ""
|
||||
subtitle = ""
|
||||
title = ""
|
||||
|
||||
[ui.dev.clients.scopes]
|
||||
email = ""
|
||||
openid = ""
|
||||
profile = ""
|
||||
|
||||
[ui.dev.clients.table]
|
||||
actions = ""
|
||||
application = ""
|
||||
@@ -1096,8 +1409,8 @@ status = ""
|
||||
type = ""
|
||||
|
||||
[ui.dev.clients.type]
|
||||
confidential = ""
|
||||
public = ""
|
||||
pkce = ""
|
||||
private = ""
|
||||
|
||||
[ui.dev.dashboard]
|
||||
ready_badge = ""
|
||||
@@ -1133,6 +1446,14 @@ title = ""
|
||||
plane = ""
|
||||
subtitle = ""
|
||||
|
||||
[ui.dev.session]
|
||||
active = ""
|
||||
unknown = ""
|
||||
expired = ""
|
||||
expiring = ""
|
||||
remaining = ""
|
||||
refresh = ""
|
||||
refreshing = ""
|
||||
|
||||
[ui.userfront]
|
||||
app_title = ""
|
||||
@@ -1209,12 +1530,9 @@ login_id = ""
|
||||
password = ""
|
||||
|
||||
[ui.userfront.login.link]
|
||||
action_label = ""
|
||||
code_only = ""
|
||||
page_title = ""
|
||||
resend_with_time = ""
|
||||
send = ""
|
||||
title = ""
|
||||
|
||||
[ui.userfront.login.qr]
|
||||
expired = ""
|
||||
@@ -1284,9 +1602,7 @@ organization = ""
|
||||
security = ""
|
||||
|
||||
[ui.userfront.qr]
|
||||
request_permission = ""
|
||||
rescan = ""
|
||||
result_failure = ""
|
||||
result_success = ""
|
||||
title = ""
|
||||
|
||||
|
||||
Reference in new issue
Block a user