린트 적용

This commit is contained in:
chan committed 2026-03-05 17:50:34 +09:00
1 parent c2b55081a6
commit 45ae1bb1c0
21 files changed
+1114 -810

No files matched your search

+7 -3
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type * as React from "react";
import { fetchMe } from "../../lib/adminApi"; import { fetchMe } from "../../lib/adminApi";
interface RoleGuardProps { interface RoleGuardProps {
@@ -10,13 +10,17 @@ interface RoleGuardProps {
/** /**
* RoleGuard conditionally renders children based on the current user's role. * RoleGuard conditionally renders children based on the current user's role.
* *
* Usage: * Usage:
* <RoleGuard roles={['super_admin']}> * <RoleGuard roles={['super_admin']}>
* <button>System Only Action</button> * <button>System Only Action</button>
* </RoleGuard> * </RoleGuard>
*/ */
export function RoleGuard({ children, roles, fallback = null }: RoleGuardProps) { export function RoleGuard({
children,
roles,
fallback = null,
}: RoleGuardProps) {
const { data: profile, isLoading } = useQuery({ const { data: profile, isLoading } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: fetchMe, queryFn: fetchMe,
@@ -55,7 +55,7 @@ function AppLayout() {
const manageableCount = profile?.manageableTenants?.length ?? 0; const manageableCount = profile?.manageableTenants?.length ?? 0;
// Filter out restricted items for non-super admins // Filter out restricted items for non-super admins
const filteredItems = items.filter(item => { const filteredItems = items.filter((item) => {
if (item.to === "/api-keys") return isSuperAdmin; if (item.to === "/api-keys") return isSuperAdmin;
return true; return true;
}); });
@@ -7,6 +7,7 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
@@ -17,7 +18,6 @@ import {
CardTitle, CardTitle,
} from "../../components/ui/card"; } from "../../components/ui/card";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { RoleGuard } from "../../components/auth/RoleGuard";
import PermissionChecker from "./components/PermissionChecker"; import PermissionChecker from "./components/PermissionChecker";
function GlobalOverviewPage() { function GlobalOverviewPage() {
@@ -54,7 +54,9 @@ function GlobalOverviewPage() {
<RoleGuard roles={["super_admin"]}> <RoleGuard roles={["super_admin"]}>
<Card className="bg-[var(--color-panel)]"> <Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{t("ui.admin.overview.summary.total_tenants", "Total Tenants")}</CardDescription> <CardDescription>
{t("ui.admin.overview.summary.total_tenants", "Total Tenants")}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]"> <div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Users size={16} /> <Users size={16} />
</div> </div>
@@ -62,13 +64,18 @@ function GlobalOverviewPage() {
<CardContent> <CardContent>
<div className="text-2xl font-semibold">-</div> <div className="text-2xl font-semibold">-</div>
<p className="mt-1 text-xs text-[var(--color-muted)]"> <p className="mt-1 text-xs text-[var(--color-muted)]">
{t("msg.admin.overview.summary.total_tenants", "Tenant-aware core")} {t(
"msg.admin.overview.summary.total_tenants",
"Tenant-aware core",
)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-[var(--color-panel)]"> <Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{t("ui.admin.overview.summary.oidc_clients", "OIDC Clients")}</CardDescription> <CardDescription>
{t("ui.admin.overview.summary.oidc_clients", "OIDC Clients")}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]"> <div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<ShieldCheck size={16} /> <ShieldCheck size={16} />
</div> </div>
@@ -81,10 +88,15 @@ function GlobalOverviewPage() {
</CardContent> </CardContent>
</Card> </Card>
</RoleGuard> </RoleGuard>
<Card className="bg-[var(--color-panel)]"> <Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{t("ui.admin.overview.summary.audit_events_24h", "Audit Events (24h)")}</CardDescription> <CardDescription>
{t(
"ui.admin.overview.summary.audit_events_24h",
"Audit Events (24h)",
)}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]"> <div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Activity size={16} /> <Activity size={16} />
</div> </div>
@@ -92,14 +104,19 @@ function GlobalOverviewPage() {
<CardContent> <CardContent>
<div className="text-2xl font-semibold">-</div> <div className="text-2xl font-semibold">-</div>
<p className="mt-1 text-xs text-[var(--color-muted)]"> <p className="mt-1 text-xs text-[var(--color-muted)]">
{t("msg.admin.overview.summary.audit_events_24h", "ClickHouse stream")} {t(
"msg.admin.overview.summary.audit_events_24h",
"ClickHouse stream",
)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-[var(--color-panel)]"> <Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{t("ui.admin.overview.summary.policy_gate", "Policy Gate")}</CardDescription> <CardDescription>
{t("ui.admin.overview.summary.policy_gate", "Policy Gate")}
</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]"> <div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Database size={16} /> <Database size={16} />
</div> </div>
@@ -107,7 +124,10 @@ function GlobalOverviewPage() {
<CardContent> <CardContent>
<div className="text-2xl font-semibold">Planned</div> <div className="text-2xl font-semibold">Planned</div>
<p className="mt-1 text-xs text-[var(--color-muted)]"> <p className="mt-1 text-xs text-[var(--color-muted)]">
{t("msg.admin.overview.summary.policy_gate", "Keto + Admin checks")} {t(
"msg.admin.overview.summary.policy_gate",
"Keto + Admin checks",
)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -3,6 +3,7 @@ import type { AxiosError } from "axios";
import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -27,7 +28,6 @@ import {
fetchTenants, fetchTenants,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { RoleGuard } from "../../../components/auth/RoleGuard";
function TenantListPage() { function TenantListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -41,7 +41,10 @@ function TenantListPage() {
if (profile?.role === "tenant_admin") { if (profile?.role === "tenant_admin") {
const manageableCount = profile.manageableTenants?.length ?? 0; const manageableCount = profile.manageableTenants?.length ?? 0;
// If only 1 in array, OR array is empty but we have a primary tenantId // If only 1 in array, OR array is empty but we have a primary tenantId
if ((manageableCount === 1 || manageableCount === 0) && profile.tenantId) { if (
(manageableCount === 1 || manageableCount === 0) &&
profile.tenantId
) {
navigate(`/tenants/${profile.tenantId}`, { replace: true }); navigate(`/tenants/${profile.tenantId}`, { replace: true });
} }
} }
@@ -50,7 +53,10 @@ function TenantListPage() {
const query = useQuery({ const query = useQuery({
queryKey: ["tenants", { limit: 1000, offset: 0 }], queryKey: ["tenants", { limit: 1000, offset: 0 }],
queryFn: () => fetchTenants(1000, 0), queryFn: () => fetchTenants(1000, 0),
enabled: profile?.role === "super_admin" || (profile?.role === "tenant_admin" && (profile.manageableTenants?.length ?? 0) > 1), enabled:
profile?.role === "super_admin" ||
(profile?.role === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) > 1),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -60,17 +66,28 @@ function TenantListPage() {
}, },
}); });
if (profile && profile.role !== "super_admin" && profile.role !== "tenant_admin") { if (
profile &&
profile.role !== "super_admin" &&
profile.role !== "tenant_admin"
) {
return ( return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4"> <div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-xl font-bold">{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}</h3> <h3 className="text-xl font-bold">
<Button onClick={() => navigate("/")}>{t("ui.common.go_home", "홈으로 이동")}</Button> {t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div> </div>
); );
} }
// While redirecting (only if exactly one manageable tenant) // While redirecting (only if exactly one manageable tenant)
if (profile?.role === "tenant_admin" && (profile.manageableTenants?.length ?? 0) <= 1) { if (
profile?.role === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) <= 1
) {
return null; return null;
} }
@@ -71,7 +71,8 @@ export function TenantSchemaPage() {
: "text", : "text",
required: Boolean(field?.required), required: Boolean(field?.required),
adminOnly: Boolean(field?.adminOnly), adminOnly: Boolean(field?.adminOnly),
validation: typeof field?.validation === "string" ? field.validation : "", validation:
typeof field?.validation === "string" ? field.validation : "",
})), })),
); );
} }
@@ -170,7 +171,9 @@ export function TenantSchemaPage() {
</Label> </Label>
<Input <Input
value={field.key} value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })} onChange={(e) =>
updateField(index, { key: e.target.value })
}
placeholder={t( placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder", "ui.admin.tenants.schema.field.key_placeholder",
"예: employee_id", "예: employee_id",
@@ -315,4 +318,3 @@ export function TenantSchemaPage() {
</div> </div>
); );
} }
@@ -144,7 +144,7 @@ const MemberListDialog: React.FC<{
{node.name}{" "} {node.name}{" "}
{t("ui.admin.tenants.members.list_title", "구성원 관리")} {t("ui.admin.tenants.members.list_title", "구성원 관리")}
<span className="text-sm font-normal text-muted-foreground ml-1"> <span className="text-sm font-normal text-muted-foreground ml-1">
({isDirectLoading ? "..." : directData?.total ?? 0}) ({isDirectLoading ? "..." : (directData?.total ?? 0)})
</span> </span>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
@@ -167,7 +167,7 @@ const MemberListDialog: React.FC<{
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-0 py-2" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-0 py-2"
> >
{t("ui.admin.tenants.members.direct", "소속 멤버")} ( {t("ui.admin.tenants.members.direct", "소속 멤버")} (
{isDirectLoading ? "..." : directData?.total ?? 0}) {isDirectLoading ? "..." : (directData?.total ?? 0)})
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="descendants" value="descendants"
@@ -715,7 +715,10 @@ const TenantTreeRow: React.FC<{
type="button" type="button"
className="flex items-center gap-2 cursor-pointer hover:bg-muted p-1.5 rounded-md transition-all group/members w-full text-left" className="flex items-center gap-2 cursor-pointer hover:bg-muted p-1.5 rounded-md transition-all group/members w-full text-left"
onClick={() => setIsMemberListOpen(true)} onClick={() => setIsMemberListOpen(true)}
title={t("msg.admin.org.hover_member_info", "클릭하여 멤버 상세 조회")} title={t(
"msg.admin.org.hover_member_info",
"클릭하여 멤버 상세 조회",
)}
> >
<div className="bg-primary/10 p-1.5 rounded text-primary"> <div className="bg-primary/10 p-1.5 rounded text-primary">
<Users size={16} /> <Users size={16} />
@@ -872,10 +875,7 @@ function TenantUserGroupsTab() {
const tree = buildTenantFullTree(allTenants, tenantId); const tree = buildTenantFullTree(allTenants, tenantId);
if (tree.currentBase) { if (tree.currentBase) {
// Merge backend-provided UserGroups into the tree as virtual children // Merge backend-provided UserGroups into the tree as virtual children
tree.currentBase.children = [ tree.currentBase.children = [...tree.currentBase.children, ...groupNodes];
...tree.currentBase.children,
...groupNodes,
];
} }
return tree; return tree;
}, [allTenants, tenantId, groupNodes]); }, [allTenants, tenantId, groupNodes]);
@@ -1092,9 +1092,9 @@ function TenantUserGroupsTab() {
/> />
</div> </div>
{treeSearchTerm && ( {treeSearchTerm && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setTreeSearchTerm("")} onClick={() => setTreeSearchTerm("")}
className="text-xs" className="text-xs"
> >
@@ -103,9 +103,13 @@ function UserCreatePage() {
const registerMetadata = (field: UserSchemaField) => const registerMetadata = (field: UserSchemaField) =>
register(`metadata.${field.key}` as `metadata.${string}`, { register(`metadata.${field.key}` as `metadata.${string}`, {
required: field.required required: field.required
? t("msg.admin.users.create.form.field_required", "{{label}}은(는) 필수입니다.", { ? t(
label: field.label || field.key, "msg.admin.users.create.form.field_required",
}) "{{label}}은(는) 필수입니다.",
{
label: field.label || field.key,
},
)
: false, : false,
pattern: field.validation pattern: field.validation
? { ? {
@@ -537,5 +541,4 @@ function UserCreatePage() {
); );
} }
export default UserCreatePage; export default UserCreatePage;
@@ -1,6 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { ArrowLeft, BadgeCheck, Building2, Loader2, Save, Users } from "lucide-react"; import {
ArrowLeft,
BadgeCheck,
Building2,
Loader2,
Save,
Users,
} from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
@@ -36,16 +43,16 @@ type UserSchemaField = {
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> }; type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
// [New] Component for per-tenant profile/schema management // [New] Component for per-tenant profile/schema management
function TenantProfileCard({ function TenantProfileCard({
tenant, tenant,
register, register,
errors, errors,
isAdmin isAdmin,
}: { }: {
tenant: any, tenant: any;
register: any, register: any;
errors: any, errors: any;
isAdmin: boolean isAdmin: boolean;
}) { }) {
const { data: detail, isLoading } = useQuery({ const { data: detail, isLoading } = useQuery({
queryKey: ["tenant", tenant.id], queryKey: ["tenant", tenant.id],
@@ -56,7 +63,12 @@ function TenantProfileCard({
? (detail?.config?.userSchema as UserSchemaField[]) ? (detail?.config?.userSchema as UserSchemaField[])
: []; : [];
if (isLoading) return <div className="p-4 border rounded-lg animate-pulse bg-muted/20">Loading schema...</div>; if (isLoading)
return (
<div className="p-4 border rounded-lg animate-pulse bg-muted/20">
Loading schema...
</div>
);
if (schema.length === 0) return null; if (schema.length === 0) return null;
return ( return (
@@ -64,16 +76,23 @@ function TenantProfileCard({
<div className="bg-muted/50 px-4 py-2 border-b border-border flex items-center justify-between"> <div className="bg-muted/50 px-4 py-2 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Building2 size={14} className="text-primary" /> <Building2 size={14} className="text-primary" />
<span className="text-xs font-bold uppercase tracking-tight">{tenant.name}</span> <span className="text-xs font-bold uppercase tracking-tight">
{tenant.name}
</span>
</div> </div>
<span className="text-[10px] font-mono opacity-50">{tenant.slug}</span> <span className="text-[10px] font-mono opacity-50">{tenant.slug}</span>
</div> </div>
<div className="p-4 grid gap-4 md:grid-cols-2"> <div className="p-4 grid gap-4 md:grid-cols-2">
{schema.map((field) => ( {schema.map((field) => (
<div key={field.key} className="space-y-2"> <div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${tenant.id}.${field.key}`} className="text-xs"> <Label
htmlFor={`metadata.${tenant.id}.${field.key}`}
className="text-xs"
>
{field.label} {field.label}
{field.required && <span className="ml-1 text-destructive">*</span>} {field.required && (
<span className="ml-1 text-destructive">*</span>
)}
{field.adminOnly && ( {field.adminOnly && (
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold"> <span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
Admin Only Admin Only
@@ -83,13 +102,24 @@ function TenantProfileCard({
<Input <Input
id={`metadata.${tenant.id}.${field.key}`} id={`metadata.${tenant.id}.${field.key}`}
type={ type={
field.type === "number" ? "number" : field.type === "number"
field.type === "date" ? "date" : ? "number"
field.type === "boolean" ? "checkbox" : "text" : field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={
field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"
} }
className={field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"}
{...register(`metadata.${tenant.id}.${field.key}`, { {...register(`metadata.${tenant.id}.${field.key}`, {
required: field.required ? t("msg.admin.users.detail.form.field_required", "필수입니다.") : false, required: field.required
? t(
"msg.admin.users.detail.form.field_required",
"필수입니다.",
)
: false,
})} })}
/> />
{errors.metadata?.[tenant.id]?.[field.key] && ( {errors.metadata?.[tenant.id]?.[field.key] && (
@@ -154,7 +184,8 @@ function UserDetailPage() {
}, },
}); });
const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin"; const isAdmin =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
React.useEffect(() => { React.useEffect(() => {
if (user) { if (user) {
@@ -224,7 +255,10 @@ function UserDetailPage() {
// Combined affiliated tenants // Combined affiliated tenants
const userAffiliatedTenants = [...(user.joinedTenants || [])]; const userAffiliatedTenants = [...(user.joinedTenants || [])];
if (user.tenant && !userAffiliatedTenants.find(t => t.id === user.tenant?.id)) { if (
user.tenant &&
!userAffiliatedTenants.find((t) => t.id === user.tenant?.id)
) {
userAffiliatedTenants.push(user.tenant); userAffiliatedTenants.push(user.tenant);
} }
@@ -281,35 +315,55 @@ function UserDetailPage() {
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4"> <div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
<h3 className="text-sm font-semibold flex items-center gap-2"> <h3 className="text-sm font-semibold flex items-center gap-2">
<Building2 size={16} className="text-primary" /> <Building2 size={16} className="text-primary" />
{t("ui.admin.users.detail.tenants_section.title", "소속 및 조직 정보")} {t(
"ui.admin.users.detail.tenants_section.title",
"소속 및 조직 정보",
)}
</h3> </h3>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold"> <Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
{t("ui.admin.users.detail.tenants_section.primary", "대표 소속 테넌트")} {t(
"ui.admin.users.detail.tenants_section.primary",
"대표 소속 테넌트",
)}
</Label> </Label>
{/* Select box to specify representative tenant from joined ones */} {/* Select box to specify representative tenant from joined ones */}
{userAffiliatedTenants.length > 0 ? ( {userAffiliatedTenants.length > 0 ? (
<div className="relative"> <div className="relative">
<select <select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
{...register("companyCode")} {...register("companyCode")}
disabled={profile?.role === "tenant_admin" && userAffiliatedTenants.length <= 1} disabled={
profile?.role === "tenant_admin" &&
userAffiliatedTenants.length <= 1
}
> >
<option value="">{t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}</option> <option value="">
{t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역",
)}
</option>
{userAffiliatedTenants.map((t) => ( {userAffiliatedTenants.map((t) => (
<option key={t.id} value={t.slug}> <option key={t.id} value={t.slug}>
{t.name} ({t.slug}) {t.name} ({t.slug})
</option> </option>
))} ))}
</select> </select>
<BadgeCheck size={14} className="absolute right-8 top-3 text-primary pointer-events-none" /> <BadgeCheck
size={14}
className="absolute right-8 top-3 text-primary pointer-events-none"
/>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 p-2 rounded-md bg-background border border-dashed border-border text-muted-foreground italic text-xs"> <div className="flex items-center gap-2 p-2 rounded-md bg-background border border-dashed border-border text-muted-foreground italic text-xs">
{t("ui.admin.users.detail.form.tenant_global", "시스템 전역 (소속 없음)")} {t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</div> </div>
)} )}
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
@@ -320,15 +374,22 @@ function UserDetailPage() {
{userAffiliatedTenants.length > 1 && ( {userAffiliatedTenants.length > 1 && (
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold"> <Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
{t("ui.admin.users.detail.tenants_section.additional", "전체 소속 목록")} {t(
"ui.admin.users.detail.tenants_section.additional",
"전체 소속 목록",
)}
</Label> </Label>
<div className="flex flex-wrap gap-1.5 pt-1"> <div className="flex flex-wrap gap-1.5 pt-1">
{userAffiliatedTenants.map(jt => ( {userAffiliatedTenants.map((jt) => (
<Link <Link
key={jt.id} key={jt.id}
to={`/tenants/${jt.id}`} to={`/tenants/${jt.id}`}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
jt.id === tenants.find(t => t.slug === watch("companyCode"))?.id ? "bg-primary/10 border-primary/30 text-primary font-bold" : "bg-background border-border text-muted-foreground hover:border-primary/50" jt.id ===
tenants.find((t) => t.slug === watch("companyCode"))
?.id
? "bg-primary/10 border-primary/30 text-primary font-bold"
: "bg-background border-border text-muted-foreground hover:border-primary/50"
}`} }`}
> >
<Users size={10} /> <Users size={10} />
@@ -453,7 +514,10 @@ function UserDetailPage() {
<div className="border-t pt-6 space-y-6"> <div className="border-t pt-6 space-y-6">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider"> <h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
{t("ui.admin.users.detail.custom_fields.multi_title", "테넌트별 프로필 관리")} {t(
"ui.admin.users.detail.custom_fields.multi_title",
"테넌트별 프로필 관리",
)}
</h3> </h3>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
사용자가 소속된 각 테넌트별 맞춤 정보를 관리합니다. 사용자가 소속된 각 테넌트별 맞춤 정보를 관리합니다.
+94 -47
View File
@@ -45,15 +45,15 @@ import {
bulkDeleteUsers, bulkDeleteUsers,
bulkUpdateUsers, bulkUpdateUsers,
deleteUser, deleteUser,
exportUsersCSVUrl,
fetchMe, fetchMe,
fetchTenant, fetchTenant,
fetchTenants, fetchTenants,
fetchUsers, fetchUsers,
exportUsersCSVUrl,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal"; import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
type UserSchemaField = { type UserSchemaField = {
key: string; key: string;
@@ -67,7 +67,9 @@ function UserListPage() {
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const [searchDraft, setSearchDraft] = React.useState(""); const [searchDraft, setSearchDraft] = React.useState("");
const [selectedCompany, setSelectedCompany] = React.useState<string>(""); const [selectedCompany, setSelectedCompany] = React.useState<string>("");
const [visibleColumns, setVisibleColumns] = React.useState<Record<string, boolean>>({}); const [visibleColumns, setVisibleColumns] = React.useState<
Record<string, boolean>
>({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]); const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const limit = 50; const limit = 50;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
@@ -129,7 +131,10 @@ function UserListPage() {
}; };
const query = useQuery({ const query = useQuery({
queryKey: ["users", { limit, offset, search, companyCode: selectedCompany }], queryKey: [
"users",
{ limit, offset, search, companyCode: selectedCompany },
],
queryFn: () => fetchUsers(limit, offset, search, selectedCompany), queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
}); });
@@ -190,7 +195,12 @@ function UserListPage() {
onSuccess: () => { onSuccess: () => {
query.refetch(); query.refetch();
setSelectedUserIds([]); setSelectedUserIds([]);
toast.success(t("msg.admin.users.bulk.delete_success", "선택한 사용자들이 삭제되었습니다.")); toast.success(
t(
"msg.admin.users.bulk.delete_success",
"선택한 사용자들이 삭제되었습니다.",
),
);
}, },
}); });
@@ -199,7 +209,12 @@ function UserListPage() {
onSuccess: () => { onSuccess: () => {
query.refetch(); query.refetch();
setSelectedUserIds([]); setSelectedUserIds([]);
toast.success(t("msg.admin.users.bulk.update_success", "선택한 사용자들의 정보가 수정되었습니다.")); toast.success(
t(
"msg.admin.users.bulk.update_success",
"선택한 사용자들의 정보가 수정되었습니다.",
),
);
}, },
}); });
@@ -210,7 +225,15 @@ function UserListPage() {
const handleBulkDelete = () => { const handleBulkDelete = () => {
if (selectedUserIds.length === 0) return; if (selectedUserIds.length === 0) return;
if (window.confirm(t("msg.admin.users.bulk.delete_confirm", "{{count}}명의 사용자를 정말 삭제하시겠습니까?", { count: selectedUserIds.length }))) { if (
window.confirm(
t(
"msg.admin.users.bulk.delete_confirm",
"{{count}}명의 사용자를 정말 삭제하시겠습니까?",
{ count: selectedUserIds.length },
),
)
) {
bulkDeleteMutation.mutate(selectedUserIds); bulkDeleteMutation.mutate(selectedUserIds);
} }
}; };
@@ -273,19 +296,30 @@ function UserListPage() {
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}</DialogTitle> <DialogTitle>
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
</DialogTitle>
<DialogDescription> <DialogDescription>
{t("msg.admin.users.list.columns.description", "사용자 목록에 표시할 커스텀 필드를 선택하세요.")} {t(
"msg.admin.users.list.columns.description",
"사용자 목록에 표시할 커스텀 필드를 선택하세요.",
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
{userSchema.length === 0 && ( {userSchema.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4"> <p className="text-sm text-muted-foreground text-center py-4">
{t("msg.admin.users.list.columns.no_custom", "현재 테넌트에 정의된 커스텀 필드가 없습니다.")} {t(
"msg.admin.users.list.columns.no_custom",
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
)}
</p> </p>
)} )}
{userSchema.map((field) => ( {userSchema.map((field) => (
<label key={field.key} className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer"> <label
key={field.key}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer"
>
<input <input
type="checkbox" type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
@@ -294,14 +328,18 @@ function UserListPage() {
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium">{field.label}</span> <span className="text-sm font-medium">{field.label}</span>
<span className="text-xs text-muted-foreground font-mono">{field.key}</span> <span className="text-xs text-muted-foreground font-mono">
{field.key}
</span>
</div> </div>
</label> </label>
))} ))}
</div> </div>
<DialogFooter> <DialogFooter>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="secondary">{t("ui.common.close", "닫기")}</Button> <Button variant="secondary">
{t("ui.common.close", "닫기")}
</Button>
</DialogTrigger> </DialogTrigger>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -387,7 +425,10 @@ function UserListPage() {
<input <input
type="checkbox" type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer" className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={items.length > 0 && selectedUserIds.length === items.length} checked={
items.length > 0 &&
selectedUserIds.length === items.length
}
onChange={toggleSelectAll} onChange={toggleSelectAll}
/> />
</TableHead> </TableHead>
@@ -407,13 +448,14 @@ function UserListPage() {
)} )}
</TableHead> </TableHead>
{/* Dynamic Columns from Schema */} {/* Dynamic Columns from Schema */}
{userSchema.map((field) => ( {userSchema.map(
visibleColumns[field.key] !== false && ( (field) =>
<TableHead key={field.key} className="uppercase"> visibleColumns[field.key] !== false && (
{field.label} <TableHead key={field.key} className="uppercase">
</TableHead> {field.label}
) </TableHead>
))} ),
)}
<TableHead> <TableHead>
{t("ui.admin.users.list.table.created", "CREATED")} {t("ui.admin.users.list.table.created", "CREATED")}
</TableHead> </TableHead>
@@ -444,9 +486,11 @@ function UserListPage() {
</TableRow> </TableRow>
)} )}
{items.map((user) => ( {items.map((user) => (
<TableRow <TableRow
key={user.id} key={user.id}
className={selectedUserIds.includes(user.id) ? "bg-primary/5" : ""} className={
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
}
> >
<TableCell> <TableCell>
<input <input
@@ -494,13 +538,14 @@ function UserListPage() {
</div> </div>
</TableCell> </TableCell>
{/* Dynamic Metadata Cells */} {/* Dynamic Metadata Cells */}
{userSchema.map((field) => ( {userSchema.map(
visibleColumns[field.key] !== false && ( (field) =>
<TableCell key={field.key} className="text-sm"> visibleColumns[field.key] !== false && (
{String(user.metadata?.[field.key] ?? "-")} <TableCell key={field.key} className="text-sm">
</TableCell> {String(user.metadata?.[field.key] ?? "-")}
) </TableCell>
))} ),
)}
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleDateString()}
</TableCell> </TableCell>
@@ -534,36 +579,38 @@ function UserListPage() {
{selectedUserIds.length > 0 && ( {selectedUserIds.length > 0 && (
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300"> <div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300">
<span className="text-sm font-medium border-r border-background/20 pr-4 mr-2"> <span className="text-sm font-medium border-r border-background/20 pr-4 mr-2">
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", { count: selectedUserIds.length })} {t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", {
count: selectedUserIds.length,
})}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-background hover:bg-background/10 h-8" className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("active")} onClick={() => handleBulkStatusChange("active")}
> >
{t("ui.common.status.active", "활성화")} {t("ui.common.status.active", "활성화")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-background hover:bg-background/10 h-8" className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("inactive")} onClick={() => handleBulkStatusChange("inactive")}
> >
{t("ui.common.status.inactive", "비활성화")} {t("ui.common.status.inactive", "비활성화")}
</Button> </Button>
<UserBulkMoveGroupModal <UserBulkMoveGroupModal
userIds={selectedUserIds} userIds={selectedUserIds}
onSuccess={() => { onSuccess={() => {
query.refetch(); query.refetch();
setSelectedUserIds([]); setSelectedUserIds([]);
}} }}
/> />
<div className="w-px h-4 bg-background/20 mx-1" /> <div className="w-px h-4 bg-background/20 mx-1" />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5" className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
onClick={handleBulkDelete} onClick={handleBulkDelete}
> >
@@ -571,9 +618,9 @@ function UserListPage() {
{t("ui.common.delete", "삭제")} {t("ui.common.delete", "삭제")}
</Button> </Button>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-background/50 hover:text-background h-8 w-8 ml-2" className="text-background/50 hover:text-background h-8 w-8 ml-2"
onClick={() => setSelectedUserIds([])} onClick={() => setSelectedUserIds([])}
> >
@@ -14,12 +14,12 @@ import {
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area"; import { ScrollArea } from "../../../components/ui/scroll-area";
import { import {
bulkUpdateUsers, type GroupSummary,
fetchGroups,
fetchTenants,
type TenantSummary, type TenantSummary,
type GroupSummary bulkUpdateUsers,
fetchGroups,
fetchTenants,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
@@ -28,12 +28,16 @@ interface UserBulkMoveGroupModalProps {
onSuccess?: () => void; onSuccess?: () => void;
} }
export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroupModalProps) { export function UserBulkMoveGroupModal({
userIds,
onSuccess,
}: UserBulkMoveGroupModalProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [selectedTenantSlug, setSelectedTenantSlug] = React.useState<string>(""); const [selectedTenantSlug, setSelectedTenantSlug] =
React.useState<string>("");
const [selectedGroupName, setSelectedGroupName] = React.useState<string>(""); const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
const [searchTerm, setSearchTerm] = React.useState(""); const [searchTerm, setSearchTerm] = React.useState("");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: tenantsData } = useQuery({ const { data: tenantsData } = useQuery({
@@ -43,9 +47,10 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
}); });
const tenants = tenantsData?.items ?? []; const tenants = tenantsData?.items ?? [];
const selectedTenantId = React.useMemo(() => const selectedTenantId = React.useMemo(
tenants.find(t => t.slug === selectedTenantSlug)?.id ?? "", () => tenants.find((t) => t.slug === selectedTenantSlug)?.id ?? "",
[tenants, selectedTenantSlug]); [tenants, selectedTenantSlug],
);
const { data: groups, isLoading: isGroupsLoading } = useQuery({ const { data: groups, isLoading: isGroupsLoading } = useQuery({
queryKey: ["tenant-groups", selectedTenantId], queryKey: ["tenant-groups", selectedTenantId],
@@ -56,7 +61,12 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
const mutation = useMutation({ const mutation = useMutation({
mutationFn: bulkUpdateUsers, mutationFn: bulkUpdateUsers,
onSuccess: () => { onSuccess: () => {
toast.success(t("msg.admin.users.bulk.move_success", "사용자들의 부서가 이동되었습니다.")); toast.success(
t(
"msg.admin.users.bulk.move_success",
"사용자들의 부서가 이동되었습니다.",
),
);
setOpen(false); setOpen(false);
onSuccess?.(); onSuccess?.();
}, },
@@ -79,27 +89,41 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
const filteredGroups = React.useMemo(() => { const filteredGroups = React.useMemo(() => {
if (!groups) return []; if (!groups) return [];
if (!searchTerm) return groups; if (!searchTerm) return groups;
return groups.filter(g => g.name.toLowerCase().includes(searchTerm.toLowerCase())); return groups.filter((g) =>
g.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [groups, searchTerm]); }, [groups, searchTerm]);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-background hover:bg-background/10 h-8"> <Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
>
{t("ui.admin.users.bulk.move_group", "부서 이동")} {t("ui.admin.users.bulk.move_group", "부서 이동")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}</DialogTitle> <DialogTitle>
{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}
</DialogTitle>
<DialogDescription> <DialogDescription>
{t("msg.admin.users.bulk.move_description", "선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.", { count: userIds.length })} {t(
"msg.admin.users.bulk.move_description",
"선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.",
{ count: userIds.length },
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">{t("ui.admin.users.create.form.tenant", "테넌트 선택")}</label> <label className="text-sm font-medium">
{t("ui.admin.users.create.form.tenant", "테넌트 선택")}
</label>
<select <select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={selectedTenantSlug} value={selectedTenantSlug}
@@ -110,14 +134,18 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
> >
<option value="">{t("ui.common.select", "선택하세요...")}</option> <option value="">{t("ui.common.select", "선택하세요...")}</option>
{tenants.map((t) => ( {tenants.map((t) => (
<option key={t.id} value={t.slug}>{t.name} ({t.slug})</option> <option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))} ))}
</select> </select>
</div> </div>
{selectedTenantSlug && ( {selectedTenantSlug && (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">{t("ui.admin.users.bulk.select_group", "부서 선택")}</label> <label className="text-sm font-medium">
{t("ui.admin.users.bulk.select_group", "부서 선택")}
</label>
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
@@ -133,14 +161,18 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
type="button" type="button"
onClick={() => setSelectedGroupName("")} onClick={() => setSelectedGroupName("")}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${ className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === "" ? "bg-primary text-primary-foreground" : "hover:bg-muted" selectedGroupName === ""
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`} }`}
> >
<FolderTree size={14} /> <FolderTree size={14} />
{t("ui.admin.users.bulk.no_department", "(부서 없음)")} {t("ui.admin.users.bulk.no_department", "(부서 없음)")}
</button> </button>
{isGroupsLoading ? ( {isGroupsLoading ? (
<div className="flex justify-center py-4"><Loader2 className="animate-spin text-muted-foreground" /></div> <div className="flex justify-center py-4">
<Loader2 className="animate-spin text-muted-foreground" />
</div>
) : ( ) : (
filteredGroups.map((group) => ( filteredGroups.map((group) => (
<button <button
@@ -148,7 +180,9 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
type="button" type="button"
onClick={() => setSelectedGroupName(group.name)} onClick={() => setSelectedGroupName(group.name)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${ className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === group.name ? "bg-primary text-primary-foreground" : "hover:bg-muted" selectedGroupName === group.name
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`} }`}
> >
<FolderTree size={14} /> <FolderTree size={14} />
@@ -166,11 +200,13 @@ export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroup
<Button variant="outline" onClick={() => setOpen(false)}> <Button variant="outline" onClick={() => setOpen(false)}>
{t("ui.common.cancel", "취소")} {t("ui.common.cancel", "취소")}
</Button> </Button>
<Button <Button
onClick={handleMove} onClick={handleMove}
disabled={!selectedTenantSlug || mutation.isPending} disabled={!selectedTenantSlug || mutation.isPending}
> >
{mutation.isPending && <Loader2 size={16} className="mr-2 animate-spin" />} {mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.do_move", "이동 실행")} {t("ui.admin.users.bulk.do_move", "이동 실행")}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -1,5 +1,12 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { AlertCircle, CheckCircle2, Download, FileText, Loader2, Upload } from "lucide-react"; import {
AlertCircle,
CheckCircle2,
Download,
FileText,
Loader2,
Upload,
} from "lucide-react";
import * as React from "react"; import * as React from "react";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -12,7 +19,11 @@ import {
DialogTrigger, DialogTrigger,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { ScrollArea } from "../../../components/ui/scroll-area"; import { ScrollArea } from "../../../components/ui/scroll-area";
import { bulkCreateUsers, type BulkUserItem, type BulkUserResult } from "../../../lib/adminApi"; import {
type BulkUserItem,
type BulkUserResult,
bulkCreateUsers,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { parseUserCSV } from "../utils/csvParser"; import { parseUserCSV } from "../utils/csvParser";
@@ -64,9 +75,15 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const downloadTemplate = () => { const downloadTemplate = () => {
const headers = "email,name,phone,role,companyCode,department,employee_id"; const headers = "email,name,phone,role,companyCode,department,employee_id";
const example = "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001"; const example =
const blob = new Blob([`${headers} "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
${example}`], { type: "text/csv" }); const blob = new Blob(
[
`${headers}
${example}`,
],
{ type: "text/csv" },
);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
@@ -82,11 +99,17 @@ ${example}`], { type: "text/csv" });
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}; };
const successCount = results?.filter(r => r.success).length ?? 0; const successCount = results?.filter((r) => r.success).length ?? 0;
const failCount = results ? results.length - successCount : 0; const failCount = results ? results.length - successCount : 0;
return ( return (
<Dialog open={open} onOpenChange={(val) => { setOpen(val); if (!val) reset(); }}> <Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) reset();
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant="outline" className="gap-2">
<Upload size={16} /> <Upload size={16} />
@@ -95,16 +118,26 @@ ${example}`], { type: "text/csv" });
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}</DialogTitle> <DialogTitle>
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
</DialogTitle>
<DialogDescription> <DialogDescription>
{t("msg.admin.users.bulk.description", "CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.")} {t(
"msg.admin.users.bulk.description",
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{!results ? ( {!results ? (
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Button variant="ghost" size="sm" onClick={downloadTemplate} className="gap-2"> <Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
>
<Download size={14} /> <Download size={14} />
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")} {t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
</Button> </Button>
@@ -115,8 +148,13 @@ ${example}`], { type: "text/csv" });
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
/> />
<Button onClick={() => fileInputRef.current?.click()} variant="secondary"> <Button
{file ? t("ui.common.change_file", "파일 변경") : t("ui.common.select_file", "파일 선택")} onClick={() => fileInputRef.current?.click()}
variant="secondary"
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button> </Button>
</div> </div>
@@ -125,7 +163,9 @@ ${example}`], { type: "text/csv" });
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<FileText className="text-primary" /> <FileText className="text-primary" />
<span className="font-medium">{file.name}</span> <span className="font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span> <span className="text-xs text-muted-foreground">
({(file.size / 1024).toFixed(1)} KB)
</span>
</div> </div>
{parsing ? ( {parsing ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -134,7 +174,11 @@ ${example}`], { type: "text/csv" });
</div> </div>
) : ( ) : (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("msg.admin.users.bulk.parsed_count", "{{count}}명의 사용자가 감지되었습니다.", { count: previewData.length })} {t(
"msg.admin.users.bulk.parsed_count",
"{{count}}명의 사용자가 감지되었습니다.",
{ count: previewData.length },
)}
</div> </div>
)} )}
</div> </div>
@@ -160,7 +204,10 @@ ${example}`], { type: "text/csv" });
))} ))}
{previewData.length > 10 && ( {previewData.length > 10 && (
<tr> <tr>
<td colSpan={3} className="p-2 text-center text-muted-foreground italic"> <td
colSpan={3}
className="p-2 text-center text-muted-foreground italic"
>
... and {previewData.length - 10} more users ... and {previewData.length - 10} more users
</td> </td>
</tr> </tr>
@@ -174,28 +221,49 @@ ${example}`], { type: "text/csv" });
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border"> <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<div className="text-2xl font-bold text-green-600">{successCount}</div> <div className="text-2xl font-bold text-green-600">
<div className="text-xs text-muted-foreground uppercase">{t("ui.common.success", "성공")}</div> {successCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.success", "성공")}
</div>
</div> </div>
<div className="w-px h-10 bg-border" /> <div className="w-px h-10 bg-border" />
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<div className="text-2xl font-bold text-destructive">{failCount}</div> <div className="text-2xl font-bold text-destructive">
<div className="text-xs text-muted-foreground uppercase">{t("ui.common.fail", "실패")}</div> {failCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.fail", "실패")}
</div>
</div> </div>
</div> </div>
<ScrollArea className="h-[250px] rounded-md border"> <ScrollArea className="h-[250px] rounded-md border">
<div className="p-2 space-y-2"> <div className="p-2 space-y-2">
{results.map((r, i) => ( {results.map((r, i) => (
<div key={i} className="flex items-start gap-3 p-2 rounded border bg-card text-sm"> <div
key={i}
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
>
{r.success ? ( {r.success ? (
<CheckCircle2 size={16} className="text-green-500 mt-0.5" /> <CheckCircle2
size={16}
className="text-green-500 mt-0.5"
/>
) : ( ) : (
<AlertCircle size={16} className="text-destructive mt-0.5" /> <AlertCircle
size={16}
className="text-destructive mt-0.5"
/>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate">{r.email}</div> <div className="font-medium truncate">{r.email}</div>
{!r.success && <div className="text-xs text-destructive">{r.message}</div>} {!r.success && (
<div className="text-xs text-destructive">
{r.message}
</div>
)}
</div> </div>
</div> </div>
))} ))}
@@ -206,12 +274,14 @@ ${example}`], { type: "text/csv" });
<DialogFooter> <DialogFooter>
{!results ? ( {!results ? (
<Button <Button
onClick={handleUpload} onClick={handleUpload}
disabled={previewData.length === 0 || mutation.isPending} disabled={previewData.length === 0 || mutation.isPending}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
{mutation.isPending && <Loader2 size={16} className="mr-2 animate-spin" />} {mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.start_upload", "등록 시작")} {t("ui.admin.users.bulk.start_upload", "등록 시작")}
</Button> </Button>
) : ( ) : (
@@ -1,4 +1,4 @@
import { type BulkUserItem } from "../../../lib/adminApi"; import type { BulkUserItem } from "../../../lib/adminApi";
export function parseUserCSV(text: string): BulkUserItem[] { export function parseUserCSV(text: string): BulkUserItem[] {
const lines = text.split(/\r?\n/); const lines = text.split(/\r?\n/);
@@ -20,9 +20,14 @@ export function parseUserCSV(text: string): BulkUserItem[] {
if (value === undefined || value === "") return; if (value === undefined || value === "") return;
if ( if (
["email", "name", "phone", "role", "companycode", "department"].includes( [
header, "email",
) "name",
"phone",
"role",
"companycode",
"department",
].includes(header)
) { ) {
const key = header === "companycode" ? "companyCode" : header; const key = header === "companycode" ? "companyCode" : header;
item[key] = value; item[key] = value;
+1 -1
View File
@@ -450,7 +450,7 @@ export function exportUsersCSVUrl(search?: string, companyCode?: string) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (search) params.append("search", search); if (search) params.append("search", search);
if (companyCode) params.append("companyCode", companyCode); if (companyCode) params.append("companyCode", companyCode);
// Get mock role from storage if exists for dev environment // Get mock role from storage if exists for dev environment
const mockRole = window.localStorage.getItem("X-Mock-Role"); const mockRole = window.localStorage.getItem("X-Mock-Role");
if (mockRole) params.append("x-test-role", mockRole); if (mockRole) params.append("x-test-role", mockRole);
+5 -3
View File
@@ -41,7 +41,9 @@ export function buildTenantFullTree(
// Function to calculate recursive counts with cycle protection // Function to calculate recursive counts with cycle protection
const calculateRecursive = (node: TenantNode): number => { const calculateRecursive = (node: TenantNode): number => {
if (visitedForCalc.has(node.id)) { if (visitedForCalc.has(node.id)) {
console.warn(`Circular dependency detected in tenant tree for ID: ${node.id}`); console.warn(
`Circular dependency detected in tenant tree for ID: ${node.id}`,
);
return 0; // Prevent infinite loop return 0; // Prevent infinite loop
} }
visitedForCalc.add(node.id); visitedForCalc.add(node.id);
@@ -51,8 +53,8 @@ export function buildTenantFullTree(
total += calculateRecursive(child); total += calculateRecursive(child);
} }
node.recursiveMemberCount = total; node.recursiveMemberCount = total;
// We don't remove from visitedForCalc here because a tree shouldn't have // We don't remove from visitedForCalc here because a tree shouldn't have
// multiple paths to the same node anyway (it's a tree, not a graph). // multiple paths to the same node anyway (it's a tree, not a graph).
// If it were a DAG, we'd need different logic, but for a tree with parentIds, // If it were a DAG, we'd need different logic, but for a tree with parentIds,
// a node should only be visited once. // a node should only be visited once.
+59 -23
View File
@@ -7,9 +7,14 @@ test.describe("Bulk Actions and Tree Search", () => {
const authority = "http://localhost:5000/oidc"; const authority = "http://localhost:5000/oidc";
const client_id = "adminfront"; const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`; const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(key, JSON.stringify({ window.localStorage.setItem(
access_token: "fake", profile: { sub: "admin", role: "super_admin" }, expires_at: 9999999999 key,
})); JSON.stringify({
access_token: "fake",
profile: { sub: "admin", role: "super_admin" },
expires_at: 9999999999,
}),
);
}); });
// Mock APIs // Mock APIs
@@ -18,34 +23,61 @@ test.describe("Bulk Actions and Tree Search", () => {
}); });
await page.route("**/api/v1/admin/users?*", async (route) => { await page.route("**/api/v1/admin/users?*", async (route) => {
await route.fulfill({ json: { await route.fulfill({
items: [ json: {
{ id: "u-1", name: "User One", email: "u1@test.com", status: "active", role: "user", createdAt: new Date().toISOString() }, items: [
{ id: "u-2", name: "User Two", email: "u2@test.com", status: "active", role: "user", createdAt: new Date().toISOString() }, {
], id: "u-1",
total: 2 name: "User One",
}}); email: "u1@test.com",
status: "active",
role: "user",
createdAt: new Date().toISOString(),
},
{
id: "u-2",
name: "User Two",
email: "u2@test.com",
status: "active",
role: "user",
createdAt: new Date().toISOString(),
},
],
total: 2,
},
});
}); });
await page.route("**/api/v1/admin/tenants/t-1", async (route) => { await page.route("**/api/v1/admin/tenants/t-1", async (route) => {
await route.fulfill({ json: { id: "t-1", name: "Main Tenant", slug: "main" } }); await route.fulfill({
json: { id: "t-1", name: "Main Tenant", slug: "main" },
});
}); });
await page.route("**/api/v1/admin/tenants/t-1/organization", async (route) => { await page.route(
await route.fulfill({ json: [ "**/api/v1/admin/tenants/t-1/organization",
{ id: "g-1", name: "Engineering", slug: "eng", tenantId: "t-1" }, async (route) => {
{ id: "g-2", name: "Sales", slug: "sales", tenantId: "t-1" }, await route.fulfill({
]}); json: [
}); { id: "g-1", name: "Engineering", slug: "eng", tenantId: "t-1" },
{ id: "g-2", name: "Sales", slug: "sales", tenantId: "t-1" },
],
});
},
);
}); });
test("should show bulk action bar when users are selected", async ({ page }) => { test("should show bulk action bar when users are selected", async ({
page,
}) => {
await page.goto("/users"); await page.goto("/users");
// Check individual row // Check individual row
await page.locator('input[type="checkbox"]').nth(1).check(); await page.locator('input[type="checkbox"]').nth(1).check();
await expect(page.getByText("1명 선택됨")).toBeVisible(); await expect(page.getByText("1명 선택됨")).toBeVisible();
await expect(page.getByRole("button", { name: /활성화|Active/i })).toBeVisible(); await expect(
page.getByRole("button", { name: /활성화|Active/i }),
).toBeVisible();
// Check select all // Check select all
await page.locator('input[type="checkbox"]').first().check(); await page.locator('input[type="checkbox"]').first().check();
@@ -56,15 +88,19 @@ test.describe("Bulk Actions and Tree Search", () => {
await expect(page.getByText("명 선택됨")).not.toBeVisible(); await expect(page.getByText("명 선택됨")).not.toBeVisible();
}); });
test("should filter and highlight nodes in organization tree", async ({ page }) => { test("should filter and highlight nodes in organization tree", async ({
page,
}) => {
await page.goto("/tenants/t-1"); await page.goto("/tenants/t-1");
await page.getByRole("link", { name: /하위 테넌트 관리|Sub-tenant/i }).click(); await page
.getByRole("link", { name: /하위 테넌트 관리|Sub-tenant/i })
.click();
const searchInput = page.getByPlaceholder(/조직도 내 검색|Search in tree/i); const searchInput = page.getByPlaceholder(/조직도 내 검색|Search in tree/i);
await expect(searchInput).toBeVisible(); await expect(searchInput).toBeVisible();
await searchInput.fill("Eng"); await searchInput.fill("Eng");
// Check if Engineering row is highlighted // Check if Engineering row is highlighted
const engRow = page.locator('tr:has-text("Engineering")'); const engRow = page.locator('tr:has-text("Engineering")');
await expect(engRow).toHaveClass(/bg-primary\/10/); await expect(engRow).toHaveClass(/bg-primary\/10/);
+30 -10
View File
@@ -21,14 +21,22 @@ test.describe("Users Bulk Upload", () => {
}); });
// Mock OIDC config // Mock OIDC config
await page.route("**/oidc/.well-known/openid-configuration", async (route) => { await page.route(
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); "**/oidc/.well-known/openid-configuration",
}); async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
},
);
// Mock user profile // Mock user profile
await page.route("**/api/v1/user/me", async (route) => { await page.route("**/api/v1/user/me", async (route) => {
await route.fulfill({ await route.fulfill({
json: { id: "admin-user", name: "Admin User", email: "admin@example.com", role: "super_admin" }, json: {
id: "admin-user",
name: "Admin User",
email: "admin@example.com",
role: "super_admin",
},
}); });
}); });
@@ -42,13 +50,19 @@ test.describe("Users Bulk Upload", () => {
test("should open bulk upload modal and show preview", async ({ page }) => { test("should open bulk upload modal and show preview", async ({ page }) => {
await page.goto("/users"); await page.goto("/users");
const bulkBtn = page.getByRole("button", { name: /일괄 등록|Bulk Import/i }); const bulkBtn = page.getByRole("button", {
name: /일괄 등록|Bulk Import/i,
});
await expect(bulkBtn).toBeVisible(); await expect(bulkBtn).toBeVisible();
await bulkBtn.click(); await bulkBtn.click();
await expect(page.getByText(/사용자 일괄 등록|User Bulk Upload/i)).toBeVisible(); await expect(
await expect(page.getByRole("button", { name: /템플릿 다운로드|Download Template/i })).toBeVisible(); page.getByText(/사용자 일괄 등록|User Bulk Upload/i),
).toBeVisible();
await expect(
page.getByRole("button", { name: /템플릿 다운로드|Download Template/i }),
).toBeVisible();
}); });
test("should show success results after mock upload", async ({ page }) => { test("should show success results after mock upload", async ({ page }) => {
@@ -58,7 +72,11 @@ test.describe("Users Bulk Upload", () => {
json: { json: {
results: [ results: [
{ email: "success@test.com", success: true, userId: "u-1" }, { email: "success@test.com", success: true, userId: "u-1" },
{ email: "fail@test.com", success: false, message: "Invalid format" }, {
email: "fail@test.com",
success: false,
message: "Invalid format",
},
], ],
}, },
}); });
@@ -69,7 +87,9 @@ test.describe("Users Bulk Upload", () => {
// Directly set internal state for testing results view if file simulation is hard // Directly set internal state for testing results view if file simulation is hard
// But let's assume we want to see the "Start Upload" button disabled initially // But let's assume we want to see the "Start Upload" button disabled initially
const uploadBtn = page.getByRole("button", { name: /등록 시작|Start Upload/i }); const uploadBtn = page.getByRole("button", {
name: /등록 시작|Start Upload/i,
});
await expect(uploadBtn).toBeDisabled(); await expect(uploadBtn).toBeDisabled();
}); });
}); });
+54 -19
View File
@@ -10,19 +10,31 @@ test.describe("User Schema Dynamic Form", () => {
const authData = { const authData = {
access_token: "fake-token", access_token: "fake-token",
token_type: "Bearer", token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin User", email: "admin@example.com" }, profile: {
sub: "admin-user",
name: "Admin User",
email: "admin@example.com",
},
expires_at: Math.floor(Date.now() / 1000) + 3600, expires_at: Math.floor(Date.now() / 1000) + 3600,
}; };
window.localStorage.setItem(key, JSON.stringify(authData)); window.localStorage.setItem(key, JSON.stringify(authData));
}); });
await page.route("**/oidc/.well-known/openid-configuration", async (route) => { await page.route(
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); "**/oidc/.well-known/openid-configuration",
}); async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
},
);
await page.route("**/api/v1/user/me", async (route) => { await page.route("**/api/v1/user/me", async (route) => {
await route.fulfill({ await route.fulfill({
json: { id: "admin-user", name: "Admin User", email: "admin@example.com", role: "super_admin" }, json: {
id: "admin-user",
name: "Admin User",
email: "admin@example.com",
role: "super_admin",
},
}); });
}); });
@@ -34,11 +46,21 @@ test.describe("User Schema Dynamic Form", () => {
slug: "test-tenant", slug: "test-tenant",
config: { config: {
userSchema: [ userSchema: [
{ key: "emp_id", label: "Employee ID", required: true, validation: "^E[0-9]{3}$" }, {
{ key: "salary", label: "Salary", adminOnly: true, type: "number" } key: "emp_id",
] label: "Employee ID",
} required: true,
} validation: "^E[0-9]{3}$",
},
{
key: "salary",
label: "Salary",
adminOnly: true,
type: "number",
},
],
},
},
}); });
}); });
@@ -50,38 +72,51 @@ test.describe("User Schema Dynamic Form", () => {
name: "John Doe", name: "John Doe",
email: "john@test.com", email: "john@test.com",
companyCode: "test-tenant", companyCode: "test-tenant",
metadata: { emp_id: "E123", salary: 1000 } metadata: { emp_id: "E123", salary: 1000 },
} },
}); });
}); });
await page.route("**/api/v1/admin/tenants**", async (route) => { await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() === "GET") { if (route.request().method() === "GET") {
await route.fulfill({ json: { items: [{id: "t-1", slug: "test-tenant", name: "Test Tenant"}], total: 1 } }); await route.fulfill({
json: {
items: [{ id: "t-1", slug: "test-tenant", name: "Test Tenant" }],
total: 1,
},
});
} }
}); });
}); });
test("should render custom fields from schema in user detail", async ({ page }) => { test("should render custom fields from schema in user detail", async ({
page,
}) => {
await page.goto("/users/u-1"); await page.goto("/users/u-1");
await expect(page.getByText("테넌트 확장 정보 (Custom Fields)")).toBeVisible(); await expect(
page.getByText("테넌트 확장 정보 (Custom Fields)"),
).toBeVisible();
await expect(page.getByLabel("Employee ID")).toHaveValue("E123"); await expect(page.getByLabel("Employee ID")).toHaveValue("E123");
await expect(page.getByLabel("Salary")).toHaveValue("1000"); await expect(page.getByLabel("Salary")).toHaveValue("1000");
// Check for Admin Only badge // Check for Admin Only badge
await expect(page.getByText("Admin Only")).toBeVisible(); await expect(page.getByText("Admin Only")).toBeVisible();
}); });
test("should show regex validation error for custom field", async ({ page }) => { test("should show regex validation error for custom field", async ({
page,
}) => {
await page.goto("/users/u-1"); await page.goto("/users/u-1");
const empIdInput = page.getByLabel("Employee ID"); const empIdInput = page.getByLabel("Employee ID");
await empIdInput.fill("invalid"); await empIdInput.fill("invalid");
// Click somewhere to trigger blur/validation // Click somewhere to trigger blur/validation
await page.getByLabel("이름").click(); await page.getByLabel("이름").click();
await expect(page.getByText("Employee ID 형식이 올바르지 않습니다.")).toBeVisible(); await expect(
page.getByText("Employee ID 형식이 올바르지 않습니다."),
).toBeVisible();
}); });
}); });
@@ -25,8 +25,8 @@ func (m *MockAuditRepository) Create(log *domain.AuditLog) error {
return args.Error(0) return args.Error(0)
} }
func (m *MockAuditRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) { func (m *MockAuditRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) {
args := m.Called(ctx, limit, cursor) args := m.Called(ctx, limit, cursor, tenantID)
return args.Get(0).([]domain.AuditLog), args.Error(1) return args.Get(0).([]domain.AuditLog), args.Error(1)
} }
+141 -227
View File
@@ -73,6 +73,9 @@ scope_admin = "Scoped to /admin"
session_ttl = "Session TTL: 15m admin" session_ttl = "Session TTL: 15m admin"
tenant_headers = "Tenant-aware headers" 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]
[msg.admin.api_keys.create] [msg.admin.api_keys.create]
@@ -140,8 +143,8 @@ user_id = "User Id"
assign_success = "Assign Success" assign_success = "Assign Success"
description = "Description" description = "Description"
empty = "Empty" empty = "Empty"
remove_confirm = "msg.admin.groups.roles.remove_confirm" remove_confirm = "Are you sure you want to revoke this role?"
remove_success = "Remove Success" remove_success = "Role revoked successfully."
[msg.admin.header] [msg.admin.header]
subtitle = "Tenant isolation & least privilege by default" subtitle = "Tenant isolation & least privilege by default"
@@ -150,6 +153,12 @@ subtitle = "Tenant isolation & least privilege by default"
idp_policy = "IDP Policy" idp_policy = "IDP Policy"
scope = "Scope" 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] [msg.admin.overview]
description = "Description" description = "Description"
idp_fallback = "Fallback: Descope" idp_fallback = "Fallback: Descope"
@@ -165,6 +174,12 @@ tenant_title = "Tenant isolation"
[msg.admin.overview.quick_links] [msg.admin.overview.quick_links]
description = "Description" 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] [msg.admin.tenants]
approve_confirm = "Approve Confirm" approve_confirm = "Approve Confirm"
approve_success = "Approve Success" approve_success = "Approve Success"
@@ -173,6 +188,8 @@ delete_success = "Tenant deleted."
empty = "Empty" empty = "Empty"
fetch_error = "Fetch Error" fetch_error = "Fetch Error"
missing_id = "No Tenant ID." missing_id = "No Tenant ID."
not_found = "Tenant not found."
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
subtitle = "Subtitle" subtitle = "Subtitle"
[msg.admin.tenants.admins] [msg.admin.tenants.admins]
@@ -203,8 +220,8 @@ subtitle = "Subtitle"
subtitle = "Subtitle" subtitle = "Subtitle"
[msg.admin.tenants.members] [msg.admin.tenants.members]
empty = "No members found."
desc = "View the list of users belonging to this organization." 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." limit_notice = "Showing members from the first 10 descendant organizations due to size limits."
[msg.admin.tenants.registry] [msg.admin.tenants.registry]
@@ -223,15 +240,28 @@ subtitle = "Subtitle"
[msg.admin.users] [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] [msg.admin.users.create]
error = "Failed to User Create." error = "Failed to User Create."
password_required = "Password Required" password_required = "Password Required"
success = "User created successfully."
[msg.admin.users.create.account] [msg.admin.users.create.account]
subtitle = "Subtitle" subtitle = "Subtitle"
[msg.admin.users.create.form] [msg.admin.users.create.form]
email_required = "Email Required" email_required = "Email Required"
field_invalid = "Invalid {{label}} format."
field_required = "{{label}} is required."
name_required = "Name Required" name_required = "Name Required"
password_auto_help = "Password Auto Help" password_auto_help = "Password Auto Help"
password_manual_help = "Password Manual Help" password_manual_help = "Password Manual Help"
@@ -248,6 +278,7 @@ update_error = "Failed to User Edit."
update_success = "Update Success" update_success = "Update Success"
[msg.admin.users.detail.form] [msg.admin.users.detail.form]
field_required = "Required."
name_required = "Name Required" name_required = "Name Required"
[msg.admin.users.detail.security] [msg.admin.users.detail.security]
@@ -259,6 +290,10 @@ empty = "Empty"
fetch_error = "Fetch Error" fetch_error = "Fetch Error"
subtitle = "Subtitle" 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] [msg.admin.users.list.registry]
count = "Count" count = "Count"
@@ -266,8 +301,9 @@ count = "Count"
error = "Error" error = "Error"
loading = "Loading..." loading = "Loading..."
no_description = "No Description." no_description = "No Description."
saving = "Saving..." parsing = "Parsing data..."
requesting = "Requesting..." requesting = "Requesting..."
saving = "Saving..."
unknown_error = "unknown error" unknown_error = "unknown error"
[msg.dev] [msg.dev]
@@ -282,12 +318,9 @@ loading = "Loading audit logs..."
subtitle = "Shows DevFront activity history within current tenant/app scope." subtitle = "Shows DevFront activity history within current tenant/app scope."
[msg.dev.clients] [msg.dev.clients]
copy_client_id = "Copy Client Id"
load_error = "Error loading clients: {{error}}" load_error = "Error loading clients: {{error}}"
loading = "Loading apps..." loading = "Loading apps..."
showing = "Showing {{shown}} of {{total}} apps" showing = "Showing {{shown}} of {{total}} apps"
status_update_error = "Failed to update client status"
status_updated = "The app has been {{status}}."
deleted = "App deleted." deleted = "App deleted."
delete_error = "Failed to delete: {{error}}" delete_error = "Failed to delete: {{error}}"
delete_confirm = "Are you sure you want to delete this app? This action cannot be undone." delete_confirm = "Are you sure you want to delete this app? This action cannot be undone."
@@ -432,7 +465,7 @@ empty = "Empty"
load_error = "Load Error" load_error = "Load Error"
[msg.userfront.error] [msg.userfront.error]
detail_contact = "msg.userfront.error.detail_contact" detail_contact = "Please contact administrator."
detail_generic = "Detail Generic" detail_generic = "Detail Generic"
detail_request = "Detail Request" detail_request = "Detail Request"
id = "Id" id = "Id"
@@ -441,6 +474,18 @@ title_generic = "Title Generic"
title_with_code = "Title With Code" title_with_code = "Title With Code"
type = "Type" type = "Type"
[msg.userfront.error.whitelist]
"$normalizedCode" = "{{error}}"
settings_disabled = "Account settings are currently unavailable."
invalid_session = "Your session has expired. Please sign in again."
verification_required = "Additional verification is required. Please follow the instructions."
recovery_expired = "The recovery link has expired. Please request a new one."
recovery_invalid = "The recovery link is invalid."
rate_limited = "Too many requests. Please try again later."
not_found = "The requested page could not be found."
bad_request = "Please check your input."
password_or_email_mismatch = "Email or password does not match."
[msg.userfront.error.ory] [msg.userfront.error.ory]
"$normalizedCode" = "{{error}}" "$normalizedCode" = "{{error}}"
access_denied = "The user denied the consent request." access_denied = "The user denied the consent request."
@@ -457,18 +502,6 @@ temporarily_unavailable = "The authentication server is temporarily unavailable.
unauthorized_client = "The client is not authorized for this request." unauthorized_client = "The client is not authorized for this request."
unsupported_response_type = "The response type is not supported." unsupported_response_type = "The response type is not supported."
[msg.userfront.error.whitelist]
"$normalizedCode" = "{{error}}"
bad_request = "Please check your input."
invalid_session = "Your session has expired. Please sign in again."
not_found = "The requested page could not be found."
password_or_email_mismatch = "Email or password does not match."
rate_limited = "Too many requests. Please try again later."
recovery_expired = "The recovery link has expired. Please request a new one."
recovery_invalid = "The recovery link is invalid."
settings_disabled = "Account settings are currently unavailable."
verification_required = "Additional verification is required. Please follow the instructions."
[msg.userfront.forgot] [msg.userfront.forgot]
description = "Description" description = "Description"
dry_send = "Dry Send" dry_send = "Dry Send"
@@ -493,7 +526,6 @@ token_missing = "Token Missing"
verification_failed = "Verification Failed" verification_failed = "Verification Failed"
[msg.userfront.login.link] [msg.userfront.login.link]
approved = "Approved"
helper = "Sending you a login link" helper = "Sending you a login link"
missing_login_id = "Missing Login Id" missing_login_id = "Missing Login Id"
missing_phone = "Missing Phone" missing_phone = "Missing Phone"
@@ -556,104 +588,15 @@ organization = "Organization"
security = "Security" security = "Security"
[msg.userfront.qr] [msg.userfront.qr]
approve_error = "Approve Error" rescan = "Rescan"
approve_success = "Approve Success" result_success = "Result Success"
camera_error = "Camera Error" title = "Scan QR Code"
permission_error = "Permission Error"
permission_required = "Permission Required"
[msg.userfront.reset] [msg.userfront.reset]
invalid_body = "Invalid Body" confirm_password = "Confirm Password"
invalid_link = "Invalid Link" new_password = "New Password"
invalid_title = "Invalid Title" submit = "Submit"
policy_loading = "Policy Loading" subtitle = "Subtitle"
success = "Success"
[msg.userfront.reset.error]
empty_password = "Please enter Password."
generic = "Generic"
lowercase = "Lowercase"
min_length = "Min Length"
min_types = "Min Types"
mismatch = "Mismatch"
number = "Number"
symbol = "Symbol"
uppercase = "Uppercase"
[msg.userfront.reset.policy]
lowercase = "Lowercase"
min_length = "Min Length"
min_types = "Min Types"
number = "Number"
symbol = "Symbol"
uppercase = "Uppercase"
[msg.userfront.sections]
apps_subtitle = "Apps Subtitle"
audit_subtitle = "Audit Subtitle"
[msg.userfront.settings]
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② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관 장소를 달리하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
[msg.userfront.signup.agreement]
title = "Title"
[msg.userfront.signup.auth]
affiliate_notice = "Affiliate Notice"
title = "Title"
[msg.userfront.signup.email]
code_mismatch = "Code Mismatch"
duplicate = "Duplicate"
invalid = "Invalid"
send_failed = "Send Failed"
verified = "Verified"
verify_failed = "Verify Failed"
[msg.userfront.signup.password]
length_required = "Length Required"
lowercase_required = "Lowercase Required"
mismatch = "Mismatch"
number_required = "Number Required"
symbol_required = "Symbol Required"
title = "Title"
uppercase_required = "Uppercase Required"
[msg.userfront.signup.password.rule]
lowercase = "Lowercase"
min_length = "Min Length"
min_types = "Min Types"
number = "Number"
symbol = "Symbol"
uppercase = "Uppercase"
[msg.userfront.signup.phone]
code_mismatch = "Code Mismatch"
send_failed = "Send Failed"
verified = "Verified"
verify_failed = "Verify Failed"
[msg.userfront.signup.policy]
loading = "Loading"
lowercase = "Lowercase"
min_length = "Min Length"
min_types = "Min Types"
number = "Number"
summary = "Summary"
symbol = "Symbol"
uppercase = "Uppercase"
[msg.userfront.signup.profile]
affiliate_hint = "Affiliate Hint"
title = "Title"
[msg.userfront.signup.success]
body = "Body"
title = "Title" title = "Title"
[ui] [ui]
@@ -742,11 +685,9 @@ status = "STATUS"
time = "TIME" time = "TIME"
[ui.admin.groups] [ui.admin.groups]
add_unit = "Organization Add"
import_csv = "Import Csv" import_csv = "Import Csv"
[ui.admin.groups.create] [ui.admin.groups.create]
description = "Description"
title = "Title" title = "Title"
[ui.admin.groups.detail] [ui.admin.groups.detail]
@@ -763,8 +704,6 @@ desc_label = "Description"
desc_placeholder = "Desc Placeholder" desc_placeholder = "Desc Placeholder"
name_label = "Group Name" name_label = "Group Name"
name_placeholder = "Name Placeholder" name_placeholder = "Name Placeholder"
parent_label = "Parent Label"
parent_none = "Parent None"
submit = "Submit" submit = "Submit"
unit_level_label = "Unit Level Label" unit_level_label = "Unit Level Label"
unit_level_placeholder = "Unit Level Placeholder" unit_level_placeholder = "Unit Level Placeholder"
@@ -781,8 +720,6 @@ remove = "Remove"
[ui.admin.groups.table] [ui.admin.groups.table]
actions = "ACTIONS" actions = "ACTIONS"
created_at = "Created At"
level = "Level"
members = "MEMBERS" members = "MEMBERS"
name = "NAME" name = "NAME"
@@ -797,10 +734,16 @@ logout = "Logout"
overview = "Overview" overview = "Overview"
relying_parties = "Apps (RP)" relying_parties = "Apps (RP)"
tenant_dashboard = "Tenant Dashboard" tenant_dashboard = "Tenant Dashboard"
tenants = "Tenants"
user_groups = "User Groups" user_groups = "User Groups"
tenants = "Tenants"
users = "Users" users = "Users"
[ui.admin.org]
download_template = "Download Template"
import_btn = "Import"
import_title = "Bulk Organization Import"
start_import = "Start Import"
[ui.admin.overview] [ui.admin.overview]
kicker = "Global Overview" kicker = "Global Overview"
title = "Tenant-independent control plane" title = "Tenant-independent control plane"
@@ -810,10 +753,17 @@ title = "Admin playbook"
[ui.admin.overview.quick_links] [ui.admin.overview.quick_links]
add_tenant = "Tenant Add" add_tenant = "Tenant Add"
tenant_dashboard = "Tenant Dashboard" api_key_management = "API Key Management"
user_management = "User Management"
title = "Title" title = "Title"
view_audit_logs = "View Audit Logs" 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] [ui.admin.profile]
manageable_tenants = "Manageable Tenants" manageable_tenants = "Manageable Tenants"
@@ -895,12 +845,13 @@ title = "Details"
select_placeholder = "Select Placeholder" select_placeholder = "Select Placeholder"
[ui.admin.tenants.members] [ui.admin.tenants.members]
title = "Tenant Members ({{count}})"
direct_label = "Direct"
total_label = "Total"
list_title = "Member Management"
direct = "Direct Members"
descendants = "Descendant 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] [ui.admin.tenants.members.table]
email = "EMAIL" email = "EMAIL"
@@ -908,40 +859,38 @@ name = "NAME"
role = "ROLE" role = "ROLE"
status = "STATUS" status = "STATUS"
[ui.admin.tenants.profile]
allowed_domains = "Allowed Domains"
allowed_domains_help = "Allowed Domains Help"
approve_button = "Tenant Approve"
description = "Description"
name = "Tenant Name"
slug = "Slug"
status = "Status"
subtitle = "Subtitle"
title = "Tenant Profile"
type = "Type"
[ui.admin.tenants.registry] [ui.admin.tenants.registry]
title = "Tenant registry" title = "Tenant registry"
[ui.admin.tenants.schema] [ui.admin.tenants.schema]
add_field = "Add Field" add_field = "Add Field"
save = "Save Schema Changes" save = "Save Schema"
title = "User Schema Extension" title = "User Schema Extension"
[ui.admin.tenants.schema.field] [ui.admin.tenants.schema.field]
admin_only = "Admin Only"
key = "Field Key (ID)" key = "Field Key (ID)"
key_placeholder = "e.g. employee_id" key_placeholder = "e.g. employee_id"
label = "Display Label" label = "Display Label"
label_placeholder = "Label Placeholder" label_placeholder = "Label Placeholder"
required = "Required"
type = "Type" type = "Type"
type_boolean = "Boolean" type_boolean = "Boolean"
type_date = "Date"
type_number = "Number" type_number = "Number"
type_text = "Text" type_text = "Text"
validation_placeholder = "Regex Pattern (Optional)"
[ui.admin.tenants.sub] [ui.admin.tenants.sub]
add = "Add" add = "Add"
add_dialog_desc = "Select a tenant to add as a sub-tenant."
add_dialog_title = "Add Sub-tenant"
add_existing = "Add Existing Tenant"
manage = "Manage" manage = "Manage"
no_candidates = "No available tenants to add."
search_placeholder = "Search..."
title = "Sub-tenants ({{count}})" title = "Sub-tenants ({{count}})"
tree_search_placeholder = "Search in tree..."
[ui.admin.tenants.sub.table] [ui.admin.tenants.sub.table]
action = "ACTION" action = "ACTION"
@@ -951,6 +900,7 @@ status = "STATUS"
[ui.admin.tenants.table] [ui.admin.tenants.table]
actions = "ACTIONS" actions = "ACTIONS"
members = "Members"
name = "NAME" name = "NAME"
slug = "SLUG" slug = "SLUG"
status = "STATUS" status = "STATUS"
@@ -959,6 +909,17 @@ updated = "UPDATED"
[ui.admin.users] [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] [ui.admin.users.create]
back = "Back" back = "Back"
go_list = "Go List" go_list = "Go List"
@@ -981,18 +942,14 @@ department = "Department"
department_placeholder = "Department Placeholder" department_placeholder = "Department Placeholder"
email = "Email" email = "Email"
email_placeholder = "user@example.com" email_placeholder = "user@example.com"
job_title = "Job Title"
job_title_placeholder = "Job Title Placeholder"
name = "Name" name = "Name"
name_placeholder = "Name Placeholder" name_placeholder = "Name Placeholder"
password = "Password" password = "Password"
password_placeholder = "********" password_placeholder = "********"
phone = "Phone number" phone = "Phone number"
phone_placeholder = "010-1234-5678" phone_placeholder = "010-1234-5678"
position = "Position"
position_placeholder = "Position Placeholder"
role = "Role" role = "Role"
tenant = "Tenant (Tenant)" tenant = "Tenant"
tenant_global = "Tenant Global" tenant_global = "Tenant Global"
[ui.admin.users.create.password_generated] [ui.admin.users.create.password_generated]
@@ -1007,22 +964,15 @@ title = "User Details"
section = "Users" section = "Users"
[ui.admin.users.detail.custom_fields] [ui.admin.users.detail.custom_fields]
title = "Title" multi_title = "Per-tenant Profile Management"
[ui.admin.users.detail.form] [ui.admin.users.detail.form]
department = "Department" name_required = "Name is required."
department_placeholder = "Department Placeholder"
job_title = "Job Title"
job_title_placeholder = "Job Title Placeholder"
name = "Name"
name_placeholder = "Name Placeholder"
phone = "Phone number" phone = "Phone number"
phone_placeholder = "010-1234-5678" phone_placeholder = "010-1234-5678"
position = "Position"
position_placeholder = "Position Placeholder"
role = "Role" role = "Role"
status = "Status" status = "Status"
tenant = "Tenant (Tenant)" tenant = "Representative Affiliated Tenant"
tenant_global = "Tenant Global" tenant_global = "Tenant Global"
[ui.admin.users.detail.security] [ui.admin.users.detail.security]
@@ -1037,34 +987,48 @@ title = "Affiliation & Organization Info"
[ui.admin.users.list] [ui.admin.users.list]
add = "User Add" add = "User Add"
delete_aria = "User Delete: {{name}}" bulk_import = "Bulk Import"
edit_aria = "User Edit: {{name}}" empty = "Empty"
fetch_error = "Fetch Error"
search_placeholder = "Search Placeholder" search_placeholder = "Search Placeholder"
tenant_slug = "Slug: {{slug}}" subtitle = "Subtitle"
title = "User Manage"
[ui.admin.users.list.breadcrumb] [ui.admin.users.list.breadcrumb]
list = "List" list = "List"
section = "Users" section = "Users"
[ui.admin.users.list.columns]
title = "Column Settings"
[ui.admin.users.list.filter]
tenant = "Tenant Filter"
[ui.admin.users.list.registry] [ui.admin.users.list.registry]
title = "User Registry" count = "Count"
[ui.admin.users.list.table] [ui.admin.users.list.table]
actions = "ACTIONS" actions = "ACTIONS"
created = "CREATED" created = "CREATED"
name_email = "NAME / EMAIL" name_email = "NAME / EMAIL"
position_job = "POSITION / JOB"
role = "ROLE" role = "ROLE"
status = "STATUS" status = "STATUS"
tenant_dept = "TENANT / DEPT" tenant_dept = "TENANT / DEPT"
[ui.admin.users.table]
email = "Email"
name = "Name"
role = "Role"
[ui.common] [ui.common]
add = "Add" add = "Add"
all = "All"
admin_only = "Admin Only" admin_only = "Admin Only"
assign = "Assign" assign = "Assign"
back = "Back" back = "Back"
cancel = "Cancel" cancel = "Cancel"
change_file = "Change File"
clear_search = "Clear Search"
close = "Close" close = "Close"
collapse = "Collapse" collapse = "Collapse"
confirm = "Confirm" confirm = "Confirm"
@@ -1073,10 +1037,12 @@ create = "Create"
delete = "Delete" delete = "Delete"
details = "Details" details = "Details"
edit = "Edit" edit = "Edit"
export = "Export"
fail = "Fail"
go_home = "Go Home"
view = "View"
hyphen = "-" hyphen = "-"
language = "Language" manage = "Manage"
language_en = "English"
language_ko = "Language Ko"
na = "N/A" na = "N/A"
never = "Never" never = "Never"
next = "Next" next = "Next"
@@ -1085,34 +1051,32 @@ page_of = "Page {{page}} of {{total}}"
prev = "Prev" prev = "Prev"
previous = "Previous" previous = "Previous"
qr = "QR" qr = "QR"
reset = "Reset"
read_only = "Read Only" read_only = "Read Only"
refresh = "Refresh" refresh = "Refresh"
reset = "Reset" remove = "Remove"
requesting = "Requesting"
resend = "Resend" resend = "Resend"
retry = "Retry" retry = "Retry"
save = "Save" save = "Save"
search = "Search" search = "Search"
select = "User Optional" select = "Select"
select_file = "Select File"
select_placeholder = "Select Placeholder" select_placeholder = "Select Placeholder"
show_more = "Show More" show_more = "Show More"
language = "Language"
language_ko = "Korean"
language_en = "English"
success = "Success"
theme_dark = "Dark" theme_dark = "Dark"
theme_light = "Light" theme_light = "Light"
theme_toggle = "Theme Toggle" theme_toggle = "Theme Toggle"
unknown = "Unknown" unknown = "Unknown"
view = "View"
manage = "Manage"
remove = "Remove"
[ui.common.badge] [ui.common.badge]
admin_only = "Admin only" admin_only = "Admin only"
command_only = "Command only" command_only = "Command only"
system = "System" system = "System"
[ui.common.role]
admin = "Admin"
user = "User"
[ui.common.status] [ui.common.status]
active = "Active" active = "Active"
blocked = "Blocked" blocked = "Blocked"
@@ -1135,7 +1099,6 @@ env_badge = "Env: dev"
scope_badge = "Scoped to /dev" scope_badge = "Scoped to /dev"
[ui.dev.nav] [ui.dev.nav]
audit_logs = "Audit Logs"
clients = "Connected Application" clients = "Connected Application"
logout = "Logout" logout = "Logout"
@@ -1165,7 +1128,6 @@ unknown_email = "unknown@example.com"
unknown_name = "Unknown User" unknown_name = "Unknown User"
[ui.dev.clients] [ui.dev.clients]
copy_client_id = "Copy client id"
new = "Add Connected Application" new = "Add Connected Application"
search_placeholder = "Search by app name or ID..." search_placeholder = "Search by app name or ID..."
tenant_scoped = "Tenant-scoped" tenant_scoped = "Tenant-scoped"
@@ -1184,10 +1146,8 @@ type_label = "Type:"
export_csv = "Export CSV" export_csv = "Export CSV"
revoke = "Revoke" revoke = "Revoke"
revoked_at = "Revoked: " revoked_at = "Revoked: "
scope_all = "All Scopes"
scope_label = "Scope:" scope_label = "Scope:"
search_placeholder = "Search Placeholder" search_placeholder = "Search Placeholder"
status_all = "All Statuses"
status_label = "Status:" status_label = "Status:"
status_revoked = "Revoked" status_revoked = "Revoked"
subject = "Subject" subject = "Subject"
@@ -1195,7 +1155,6 @@ title = "User Consent Grants"
[ui.dev.clients.consents.breadcrumb] [ui.dev.clients.consents.breadcrumb]
clients = "Clients" clients = "Clients"
current = "User Consent Grants"
home = "Home" home = "Home"
[ui.dev.clients.consents.filters] [ui.dev.clients.consents.filters]
@@ -1206,13 +1165,6 @@ active_grants = "Active Grants"
avg_scopes = "Avg. Scopes per User" avg_scopes = "Avg. Scopes per User"
total_scopes = "Total Scopes Issued" total_scopes = "Total Scopes Issued"
[ui.dev.clients.stats]
total = "Total Applications"
active_sessions = "Active Sessions"
auth_failures = "Auth Failures (24h)"
realtime = "Realtime"
stable = "Stable"
[ui.dev.clients.consents.table] [ui.dev.clients.consents.table]
action = "Action" action = "Action"
first_granted = "First Granted" first_granted = "First Granted"
@@ -1224,10 +1176,6 @@ user = "User"
[ui.dev.clients.details] [ui.dev.clients.details]
[ui.dev.clients.details.breadcrumb]
current = "App Details"
section = "Connected Applications"
[ui.dev.clients.details.credentials] [ui.dev.clients.details.credentials]
client_id = "Client ID" client_id = "Client ID"
client_secret = "Client Secret" client_secret = "Client Secret"
@@ -1268,13 +1216,6 @@ title = "Identity Federation"
add_title = "Add Identity Provider" add_title = "Add Identity Provider"
add_btn = "Add Provider" add_btn = "Add Provider"
[ui.dev.clients.general.breadcrumb]
section = "Applications"
[ui.dev.clients.general.footer]
client_id = "Client ID"
created_on = "Created On"
[ui.dev.clients.general.identity] [ui.dev.clients.general.identity]
description = "Description" description = "Description"
description_placeholder = "Description Placeholder" description_placeholder = "Description Placeholder"
@@ -1457,12 +1398,9 @@ login_id = "Emain or Phone Number"
password = "Password" password = "Password"
[ui.userfront.login.link] [ui.userfront.login.link]
action_label = "Action Label"
code_only = "Code Only" code_only = "Code Only"
page_title = "Page Title"
resend_with_time = "Resend With Time" resend_with_time = "Resend With Time"
send = "Send" send = "Send"
title = "Title"
[ui.userfront.login.qr] [ui.userfront.login.qr]
expired = "Expired" expired = "Expired"
@@ -1532,9 +1470,7 @@ organization = "Organization"
security = "Security" security = "Security"
[ui.userfront.qr] [ui.userfront.qr]
request_permission = "Request Permission"
rescan = "Rescan" rescan = "Rescan"
result_failure = "Result Failure"
result_success = "Result Success" result_success = "Result Success"
title = "Scan QR Code" title = "Scan QR Code"
@@ -1594,25 +1530,3 @@ verify = "Verify"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "Action" action = "Action"
[msg.admin.tenants]
not_found = "Tenant not found."
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
[msg.admin.users.create]
success = "User created successfully."
[ui.admin.tenants.sub]
add_dialog_desc = "Select a tenant to add as a sub-tenant."
add_dialog_title = "Add Sub-tenant"
add_existing = "Add Existing Tenant"
no_candidates = "No available tenants to add."
search_placeholder = "Search by name or slug..."
[ui.admin.tenants.table]
members = "Members"
[ui.admin.users.table]
email = "Email"
name = "Name"
role = "Role"
+168 -167
View File
@@ -73,6 +73,9 @@ scope_admin = "Scoped to /admin"
session_ttl = "Session TTL: 15m admin" session_ttl = "Session TTL: 15m admin"
tenant_headers = "Tenant-aware headers" tenant_headers = "Tenant-aware headers"
[msg.admin.common]
forbidden = "이 작업을 수행할 권한이 없습니다."
[msg.admin.api_keys] [msg.admin.api_keys]
[msg.admin.api_keys.create] [msg.admin.api_keys.create]
@@ -140,7 +143,7 @@ user_id = "추가할 사용자의 UUID를 입력하세요:"
assign_success = "역할이 할당되었습니다." assign_success = "역할이 할당되었습니다."
description = "이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요." description = "이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요."
empty = "할당된 역할이 없습니다." empty = "할당된 역할이 없습니다."
remove_confirm = "msg.admin.groups.roles.remove_confirm" remove_confirm = "역할을 회수하시겠습니까?"
remove_success = "역할이 회수되었습니다." remove_success = "역할이 회수되었습니다."
[msg.admin.header] [msg.admin.header]
@@ -150,6 +153,12 @@ subtitle = "Tenant isolation & least privilege by default"
idp_policy = "IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다." idp_policy = "IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다."
scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다." scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다."
[msg.admin.org]
hover_member_info = "마우스를 올리면 상세 정보를 확인할 수 있습니다."
import_description = "CSV 파일을 업로드하여 조직도를 일괄 등록합니다."
import_error = "조직도 임포트 중 오류가 발생했습니다."
import_success = "조직도가 성공적으로 임포트되었습니다."
[msg.admin.overview] [msg.admin.overview]
description = "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다." description = "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다."
idp_fallback = "Fallback: Descope" idp_fallback = "Fallback: Descope"
@@ -165,6 +174,12 @@ tenant_title = "Tenant isolation"
[msg.admin.overview.quick_links] [msg.admin.overview.quick_links]
description = "주요 운영 화면으로 바로 이동합니다." description = "주요 운영 화면으로 바로 이동합니다."
[msg.admin.overview.summary]
audit_events_24h = "최근 24시간 감사 로그"
oidc_clients = "등록된 OIDC 클라이언트"
policy_gate = "정책 가이트 상태"
total_tenants = "전체 테넌트 수"
[msg.admin.tenants] [msg.admin.tenants]
approve_confirm = "이 테넌트를 승인하시겠습니까?" approve_confirm = "이 테넌트를 승인하시겠습니까?"
approve_success = "테넌트가 승인되었습니다." approve_success = "테넌트가 승인되었습니다."
@@ -173,6 +188,8 @@ delete_success = "테넌트가 삭제되었습니다."
empty = "아직 등록된 테넌트가 없습니다." empty = "아직 등록된 테넌트가 없습니다."
fetch_error = "테넌트 목록 조회에 실패했습니다." fetch_error = "테넌트 목록 조회에 실패했습니다."
missing_id = "테넌트 ID가 없습니다." missing_id = "테넌트 ID가 없습니다."
not_found = "테넌트를 찾을 수 없습니다."
remove_sub_confirm = "테넌트 \"{{name}}\"을(를) 하위 조직에서 제외할까요?"
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다." subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
[msg.admin.tenants.admins] [msg.admin.tenants.admins]
@@ -193,7 +210,7 @@ subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다." subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
[msg.admin.tenants.create.form] [msg.admin.tenants.create.form]
domains_help = "Users with these email domains will be automatically assigned to this tenant." domains_help = "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다."
[msg.admin.tenants.create.memo] [msg.admin.tenants.create.memo]
body = "생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요." body = "생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요."
@@ -203,19 +220,19 @@ subtitle = "Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다
subtitle = "필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다." subtitle = "필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다."
[msg.admin.tenants.members] [msg.admin.tenants.members]
empty = "소속된 사용자가 없습니다."
desc = "조직에 소속된 사용자 목록을 확인합니다." desc = "조직에 소속된 사용자 목록을 확인합니다."
empty = "소속된 사용자가 없습니다."
limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다." limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다."
[msg.admin.tenants.registry] [msg.admin.tenants.registry]
count = "총 {{count}}개 테넌트" count = "총 {{count}}개 테넌트"
[msg.admin.tenants.schema] [msg.admin.tenants.schema]
empty = "No custom fields defined. Click \"Add Field\" to begin." empty = "등록된 커스텀 필드가 없습니다. 필드 추가를 눌러 시작하세요."
missing_id = "Tenant ID missing" missing_id = "테넌트 ID가 없습니다."
subtitle = "Define custom attributes for users in this tenant." subtitle = "이 테넌트의 사용자에게 적용할 커스텀 속성을 정의합니다."
update_error = "Failed to update schema" update_error = "스키마 업데이트에 실패했습니다."
update_success = "Schema updated successfully" update_success = "스키마가 성공적으로 업데이트되었습니다."
[msg.admin.tenants.sub] [msg.admin.tenants.sub]
empty = "하위 테넌트가 없습니다." empty = "하위 테넌트가 없습니다."
@@ -223,15 +240,28 @@ subtitle = "현재 테넌트 하위에 생성된 조직입니다."
[msg.admin.users] [msg.admin.users]
[msg.admin.users.bulk]
delete_confirm = "선택한 {{count}}명의 사용자를 정말로 삭제하시겠습니까?"
delete_success = "{{count}}명의 사용자가 삭제되었습니다."
description = "CSV 파일을 통해 사용자를 일괄 등록하거나 관리합니다."
move_description = "선택한 사용자를 다른 테넌트로 일괄 이동합니다."
move_error = "사용자 이동 중 오류가 발생했습니다."
move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니다."
parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
update_success = "사용자 정보가 일괄 업데이트되었습니다."
[msg.admin.users.create] [msg.admin.users.create]
error = "사용자 생성에 실패했습니다." error = "사용자 생성에 실패했습니다."
password_required = "비밀번호를 입력하거나 자동 생성을 사용해 주세요." password_required = "비밀번호를 입력하거나 자동 생성을 사용해 주세요."
success = "사용자가 성공적으로 생성되었습니다."
[msg.admin.users.create.account] [msg.admin.users.create.account]
subtitle = "새로운 사용자를 시스템에 등록합니다." subtitle = "새로운 사용자를 시스템에 등록합니다."
[msg.admin.users.create.form] [msg.admin.users.create.form]
email_required = "이메일은 필수입니다." email_required = "이메일은 필수입니다."
field_invalid = "{{label}} 형식이 올바르지 않습니다."
field_required = "{{label}}은(는) 필수입니다."
name_required = "이름은 필수입니다." name_required = "이름은 필수입니다."
password_auto_help = "비워두면 시스템이 초기 비밀번호를 자동 생성합니다." password_auto_help = "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
password_manual_help = "초기 비밀번호를 직접 설정합니다." password_manual_help = "초기 비밀번호를 직접 설정합니다."
@@ -248,6 +278,7 @@ update_error = "사용자 수정에 실패했습니다."
update_success = "사용자 정보가 수정되었습니다." update_success = "사용자 정보가 수정되었습니다."
[msg.admin.users.detail.form] [msg.admin.users.detail.form]
field_required = "필수입니다."
name_required = "이름은 필수입니다." name_required = "이름은 필수입니다."
[msg.admin.users.detail.security] [msg.admin.users.detail.security]
@@ -259,6 +290,10 @@ empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다." fetch_error = "사용자 목록 조회에 실패했습니다."
subtitle = "시스템 사용자를 조회하고 관리합니다. (Local DB)" subtitle = "시스템 사용자를 조회하고 관리합니다. (Local DB)"
[msg.admin.users.list.columns]
description = "테이블에 표시할 컬럼을 선택합니다."
no_custom = "이 테넌트에 정의된 커스텀 필드가 없습니다."
[msg.admin.users.list.registry] [msg.admin.users.list.registry]
count = "총 {{count}}명의 사용자가 등록되어 있습니다." count = "총 {{count}}명의 사용자가 등록되어 있습니다."
@@ -266,9 +301,10 @@ count = "총 {{count}}명의 사용자가 등록되어 있습니다."
error = "오류가 발생했습니다." error = "오류가 발생했습니다."
loading = "로딩 중..." loading = "로딩 중..."
no_description = "설명이 없습니다." no_description = "설명이 없습니다."
saving = "저장 중..." parsing = "데이터 파싱 중..."
requesting = "요청 중..." requesting = "요청 중..."
unknown_error = "unknown error" saving = "저장 중..."
unknown_error = "알 수 없는 오류"
[msg.dev] [msg.dev]
logout_confirm = "로그아웃 하시겠습니까?" logout_confirm = "로그아웃 하시겠습니까?"
@@ -282,23 +318,20 @@ loading = "감사 로그를 불러오는 중..."
subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다." subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다."
[msg.dev.clients] [msg.dev.clients]
copy_client_id = "Client ID가 복사되었습니다." deleted = "앱이 삭제되었습니다."
delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
delete_error = "삭제 실패: {{error}}"
load_error = "Error loading clients: {{error}}" load_error = "Error loading clients: {{error}}"
loading = "Loading apps..." loading = "Loading apps..."
showing = "Showing {{shown}} of {{total}} apps" showing = "Showing {{shown}} of {{total}} apps"
status_update_error = "Failed to update client status"
status_updated = "앱이 {{status}}되었습니다."
deleted = "앱이 삭제되었습니다."
delete_error = "삭제 실패: {{error}}"
delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
[msg.dev.clients.consents] [msg.dev.clients.consents]
empty = "No consents found." empty = "No consents found."
load_error = "Error loading consents: {{error}}" load_error = "Error loading consents: {{error}}"
loading = "Loading consents..." loading = "Loading consents..."
revoke_confirm = "정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다."
showing = "Showing {{from}} to {{to}} of {{total}} users" showing = "Showing {{from}} to {{to}} of {{total}} users"
subtitle = "OIDC Relying Party 사용자 권한을 검토·관리합니다." subtitle = "OIDC Relying Party 사용자 권한을 검토·관리합니다."
revoke_confirm = "정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다."
[msg.dev.clients.details] [msg.dev.clients.details]
copy_client_id = "Client ID가 복사되었습니다." copy_client_id = "Client ID가 복사되었습니다."
@@ -325,14 +358,14 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행
[msg.dev.clients.general] [msg.dev.clients.general]
load_error = "Error loading client: {{error}}" load_error = "Error loading client: {{error}}"
loading = "Loading client..." loading = "Loading client..."
saved = "설정이 저장되었습니다."
save_error = "저장 실패: {{error}}" save_error = "저장 실패: {{error}}"
saved = "설정이 저장되었습니다."
status_changed = "상태가 {{status}}로 변경되었습니다." status_changed = "상태가 {{status}}로 변경되었습니다."
[msg.dev.clients.federation] [msg.dev.clients.federation]
subtitle = "이 애플리케이션의 외부 IdP 설정을 관리합니다."
add_subtitle = "외부 OIDC 제공자를 연결합니다." add_subtitle = "외부 OIDC 제공자를 연결합니다."
empty = "등록된 IdP 설정이 없습니다." empty = "등록된 IdP 설정이 없습니다."
subtitle = "이 애플리케이션의 외부 IdP 설정을 관리합니다."
[msg.dev.clients.general.identity] [msg.dev.clients.general.identity]
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다." logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
@@ -346,8 +379,8 @@ empty = "등록된 스코프가 없습니다."
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다." subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
[msg.dev.clients.general.security] [msg.dev.clients.general.security]
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다." pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다." subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
[msg.dev.clients.help] [msg.dev.clients.help]
@@ -400,7 +433,6 @@ approved_device = "승인 기기: {{device}}"
approved_ip = "승인 IP: {{ip}}" approved_ip = "승인 IP: {{ip}}"
audit_empty = "최근 접속 이력이 없습니다." audit_empty = "최근 접속 이력이 없습니다."
audit_load_error = "접속이력을 불러오지 못했습니다." audit_load_error = "접속이력을 불러오지 못했습니다."
render_error = "대시보드 렌더링 오류: {{error}}"
auth_method = "인증수단: {{method}}" auth_method = "인증수단: {{method}}"
client_id = "Client ID: {{id}}" client_id = "Client ID: {{id}}"
client_id_missing = "Client ID 없음" client_id_missing = "Client ID 없음"
@@ -408,6 +440,7 @@ current_status = "현재 상태: {{status}}"
last_auth = "최근 인증: {{value}}" last_auth = "최근 인증: {{value}}"
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
link_open_error = "해당 링크를 열 수 없습니다." link_open_error = "해당 링크를 열 수 없습니다."
render_error = "대시보드 렌더링 오류: {{error}}"
session_id_copied = "세션 ID가 복사되었습니다." session_id_copied = "세션 ID가 복사되었습니다."
[msg.userfront.dashboard.activities] [msg.userfront.dashboard.activities]
@@ -432,7 +465,7 @@ empty = "요청된 권한이 없습니다."
load_error = "접속이력을 불러오지 못했습니다." load_error = "접속이력을 불러오지 못했습니다."
[msg.userfront.error] [msg.userfront.error]
detail_contact = "msg.userfront.error.detail_contact" detail_contact = "관리자에게 문의해 주세요."
detail_generic = "오류가 발생했습니다." detail_generic = "오류가 발생했습니다."
detail_request = "요청을 처리하는 중 문제가 발생했습니다." detail_request = "요청을 처리하는 중 문제가 발생했습니다."
id = "오류 ID: {{id}}" id = "오류 ID: {{id}}"
@@ -441,6 +474,18 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {{code}}" title_with_code = "오류: {{code}}"
type = "오류 종류: {{type}}" type = "오류 종류: {{type}}"
[msg.userfront.error.whitelist]
"$normalizedCode" = "{{error}}"
bad_request = "입력값을 확인해 주세요."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
not_found = "요청한 페이지를 찾을 수 없습니다."
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
recovery_invalid = "재설정 링크가 유효하지 않습니다."
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
[msg.userfront.error.ory] [msg.userfront.error.ory]
"$normalizedCode" = "{{error}}" "$normalizedCode" = "{{error}}"
access_denied = "사용자가 동의를 거부했습니다." access_denied = "사용자가 동의를 거부했습니다."
@@ -457,18 +502,6 @@ temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다." unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다." unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.error.whitelist]
"$normalizedCode" = "{{error}}"
bad_request = "입력값을 확인해 주세요."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
not_found = "요청한 페이지를 찾을 수 없습니다."
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
recovery_invalid = "재설정 링크가 유효하지 않습니다."
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
[msg.userfront.forgot] [msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
@@ -493,7 +526,6 @@ token_missing = "로그인 토큰을 확인할 수 없습니다."
verification_failed = "승인 처리에 실패했습니다: {{error}}" verification_failed = "승인 처리에 실패했습니다: {{error}}"
[msg.userfront.login.link] [msg.userfront.login.link]
approved = "링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다."
helper = "입력하신 정보로 로그인 링크를 전송합니다." helper = "입력하신 정보로 로그인 링크를 전송합니다."
missing_login_id = "이메일 또는 휴대폰 번호를 입력해 주세요." missing_login_id = "이메일 또는 휴대폰 번호를 입력해 주세요."
missing_phone = "휴대폰 번호를 입력해 주세요." missing_phone = "휴대폰 번호를 입력해 주세요."
@@ -556,8 +588,6 @@ organization = "소속 및 구분 정보입니다."
security = "비밀번호를 안전하게 관리합니다." security = "비밀번호를 안전하게 관리합니다."
[msg.userfront.qr] [msg.userfront.qr]
approve_error = "QR 승인 실패: {{error}}"
approve_success = "QR 승인 완료! PC 화면에서 로그인이 진행됩니다."
camera_error = "카메라 오류: {{error}}" camera_error = "카메라 오류: {{error}}"
permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요." permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요."
permission_required = "카메라 권한이 필요합니다." permission_required = "카메라 권한이 필요합니다."
@@ -597,8 +627,8 @@ disabled = "현재 계정 설정 화면은 준비 중입니다."
[msg.userfront.signup] [msg.userfront.signup]
failed = "가입 실패: {{error}}" failed = "가입 실패: {{error}}"
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② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관 장소를 달리하Line truncated privacy_full = "개인정보 수집 및 이용 동의 전문..."
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 tos_full = "서비스 이용약관 전문..."
[msg.userfront.signup.agreement] [msg.userfront.signup.agreement]
title = "서비스 이용을 위해\n약관에 동의해주세요" title = "서비스 이용을 위해\n약관에 동의해주세요"
@@ -742,11 +772,9 @@ status = "STATUS"
time = "TIME" time = "TIME"
[ui.admin.groups] [ui.admin.groups]
add_unit = "조직 추가"
import_csv = "CSV 임포트" import_csv = "CSV 임포트"
[ui.admin.groups.create] [ui.admin.groups.create]
description = "부서나 팀과 같은 새로운 조직 단위를 추가합니다."
title = "새 그룹 생성" title = "새 그룹 생성"
[ui.admin.groups.detail] [ui.admin.groups.detail]
@@ -763,8 +791,6 @@ desc_label = "설명"
desc_placeholder = "그룹 용도 설명" desc_placeholder = "그룹 용도 설명"
name_label = "그룹 이름" name_label = "그룹 이름"
name_placeholder = "예: 개발팀, 인사팀" name_placeholder = "예: 개발팀, 인사팀"
parent_label = "상위 조직"
parent_none = "없음 (최상위)"
submit = "생성하기" submit = "생성하기"
unit_level_label = "조직 레벨" unit_level_label = "조직 레벨"
unit_level_placeholder = "예: 본부, 팀" unit_level_placeholder = "예: 본부, 팀"
@@ -781,8 +807,6 @@ remove = "제거"
[ui.admin.groups.table] [ui.admin.groups.table]
actions = "ACTIONS" actions = "ACTIONS"
created_at = "생성일"
level = "레벨"
members = "MEMBERS" members = "MEMBERS"
name = "NAME" name = "NAME"
@@ -797,10 +821,16 @@ logout = "로그아웃"
overview = "개요" overview = "개요"
relying_parties = "애플리케이션(RP)" relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드" tenant_dashboard = "테넌트 대시보드"
tenants = "테넌트"
user_groups = "유저 그룹" user_groups = "유저 그룹"
tenants = "테넌트"
users = "사용자" users = "사용자"
[ui.admin.org]
download_template = "템플릿 다운로드"
import_btn = "임포트"
import_title = "조직도 대량 등록"
start_import = "임포트 시작"
[ui.admin.overview] [ui.admin.overview]
kicker = "Global Overview" kicker = "Global Overview"
title = "Tenant-independent control plane" title = "Tenant-independent control plane"
@@ -810,10 +840,17 @@ title = "Admin playbook"
[ui.admin.overview.quick_links] [ui.admin.overview.quick_links]
add_tenant = "테넌트 추가" add_tenant = "테넌트 추가"
tenant_dashboard = "테넌트 대시보드" api_key_management = "API 키 관리"
user_management = "사용자 관리"
title = "빠른 이동" title = "빠른 이동"
view_audit_logs = "감사 로그 보기" view_audit_logs = "감사 로그 보기"
[ui.admin.overview.summary]
audit_events_24h = "24시간 이벤트"
oidc_clients = "OIDC 클라이언트"
policy_gate = "정책 게이트"
total_tenants = "전체 테넌트"
[ui.admin.profile] [ui.admin.profile]
manageable_tenants = "관리 가능한 테넌트" manageable_tenants = "관리 가능한 테넌트"
@@ -864,15 +901,15 @@ action = "Create"
section = "Tenants" section = "Tenants"
[ui.admin.tenants.create.form] [ui.admin.tenants.create.form]
description = "Description" description = "설명"
domains_label = "Allowed Domains (Comma separated)" domains_label = "Allowed Domains (Comma separated)"
domains_placeholder = "example.com, example.kr" domains_placeholder = "example.com, example.kr"
name = "Tenant name" name = "테넌트 이름"
parent = "상위 테넌트 (선택)" parent = "상위 테넌트"
slug = "Slug" slug = "Slug"
slug_placeholder = "tenant-slug" slug_placeholder = "tenant-slug"
status = "Status" status = "상태"
type = "테넌트 유형" type = "유형"
[ui.admin.tenants.create.memo] [ui.admin.tenants.create.memo]
title = "정책 메모" title = "정책 메모"
@@ -895,12 +932,13 @@ title = "상세"
select_placeholder = "테넌트를 선택하세요" select_placeholder = "테넌트를 선택하세요"
[ui.admin.tenants.members] [ui.admin.tenants.members]
title = "Tenant Members ({{count}})"
direct_label = "소속"
total_label = "전체"
list_title = "구성원 관리"
direct = "소속 멤버"
descendants = "하위 조직 멤버" descendants = "하위 조직 멤버"
direct = "소속 멤버"
direct_label = "직속"
list_title = "구성원 관리"
title = "테넌트 구성원 ({{count}})"
total = "전체"
total_label = "전체"
[ui.admin.tenants.members.table] [ui.admin.tenants.members.table]
email = "EMAIL" email = "EMAIL"
@@ -908,40 +946,38 @@ name = "NAME"
role = "ROLE" role = "ROLE"
status = "STATUS" status = "STATUS"
[ui.admin.tenants.profile]
allowed_domains = "허용된 도메인 (콤마로 구분)"
allowed_domains_help = "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다."
approve_button = "테넌트 승인"
description = "설명"
name = "테넌트 이름"
slug = "슬러그 (Slug)"
status = "상태"
subtitle = "슬러그 및 상태 변경은 즉시 적용됩니다."
title = "테넌트 프로필"
type = "테넌트 유형"
[ui.admin.tenants.registry] [ui.admin.tenants.registry]
title = "Tenant registry" title = "Tenant registry"
[ui.admin.tenants.schema] [ui.admin.tenants.schema]
add_field = "Add Field" add_field = "필드 추가"
save = "Save Schema Changes" save = "스키마 저장"
title = "User Schema Extension" title = "User Schema Extension"
[ui.admin.tenants.schema.field] [ui.admin.tenants.schema.field]
admin_only = "관리자 전용"
key = "Field Key (ID)" key = "Field Key (ID)"
key_placeholder = "e.g. employee_id" key_placeholder = "e.g. employee_id"
label = "Display Label" label = "표시 레이블"
label_placeholder = "e.g. 사번" label_placeholder = "예: 사번"
type = "Type" required = "필수 여부"
type = "타입"
type_boolean = "Boolean" type_boolean = "Boolean"
type_date = "Date"
type_number = "Number" type_number = "Number"
type_text = "Text" type_text = "Text"
validation_placeholder = "정규표현식 (선택 사항)"
[ui.admin.tenants.sub] [ui.admin.tenants.sub]
add = "하위 테넌트 추가" add = "하위 테넌트 추가"
add_dialog_desc = "하위 테넌트로 추가할 테넌트를 선택하세요."
add_dialog_title = "하위 테넌트 추가"
add_existing = "기존 테넌트 추가"
manage = "관리" manage = "관리"
title = "Sub-tenants ({{count}})" no_candidates = "추가 가능한 테넌트가 없습니다."
search_placeholder = "검색..."
title = "하위 테넌트 ({{count}})"
tree_search_placeholder = "트리에서 검색..."
[ui.admin.tenants.sub.table] [ui.admin.tenants.sub.table]
action = "ACTION" action = "ACTION"
@@ -951,14 +987,26 @@ status = "STATUS"
[ui.admin.tenants.table] [ui.admin.tenants.table]
actions = "ACTIONS" actions = "ACTIONS"
members = "멤버수"
name = "NAME" name = "NAME"
slug = "SLUG" slug = "SLUG"
status = "STATUS" status = "STATUS"
type = "TYPE" type = "유형"
updated = "UPDATED" updated = "UPDATED"
[ui.admin.users] [ui.admin.users]
[ui.admin.users.bulk]
do_move = "이동 실행"
download_template = "템플릿 받기"
move_group = "테넌트 일괄 이동"
move_title = "사용자 일괄 이동"
no_department = "부서 없음"
select_group = "대상 테넌트 선택"
selected_count = "{{count}}명 선택됨"
start_upload = "업로드 시작"
title = "일괄 작업"
[ui.admin.users.create] [ui.admin.users.create]
back = "목록으로 돌아가기" back = "목록으로 돌아가기"
go_list = "목록으로 이동" go_list = "목록으로 이동"
@@ -981,19 +1029,15 @@ department = "부서"
department_placeholder = "개발팀" department_placeholder = "개발팀"
email = "이메일" email = "이메일"
email_placeholder = "user@example.com" email_placeholder = "user@example.com"
job_title = "직무"
job_title_placeholder = "프론트엔드 개발"
name = "이름" name = "이름"
name_placeholder = "홍길동" name_placeholder = "홍길동"
password = "비밀번호" password = "비밀번호"
password_placeholder = "********" password_placeholder = "********"
phone = "전화번호" phone = "전화번호"
phone_placeholder = "010-1234-5678" phone_placeholder = "010-1234-5678"
position = "직급" role = "역할"
position_placeholder = "수석/책임/선임" tenant = "테넌트"
role = "역할 (Role)" tenant_global = "시스템 전역"
tenant = "테넌트 (Tenant)"
tenant_global = "시스템 전역 (소속 없음)"
[ui.admin.users.create.password_generated] [ui.admin.users.create.password_generated]
title = "초기 비밀번호 생성 완료" title = "초기 비밀번호 생성 완료"
@@ -1007,23 +1051,16 @@ title = "사용자 상세"
section = "Users" section = "Users"
[ui.admin.users.detail.custom_fields] [ui.admin.users.detail.custom_fields]
title = "테넌트 확장 정보 (Custom Fields)" multi_title = "테넌트별 프로필 관리"
[ui.admin.users.detail.form] [ui.admin.users.detail.form]
department = "부서" name_required = "이름은 필수입니다."
department_placeholder = "개발팀"
job_title = "직무"
job_title_placeholder = "프론트엔드 개발"
name = "이름"
name_placeholder = "홍길동"
phone = "전화번호" phone = "전화번호"
phone_placeholder = "010-1234-5678" phone_placeholder = "010-1234-5678"
position = "직급" role = "역할"
position_placeholder = "수석/책임/선임"
role = "역할 (Role)"
status = "상태" status = "상태"
tenant = "테넌트 (Tenant)" tenant = "대표 소속 테넌트"
tenant_global = "시스템 전역 (소속 없음)" tenant_global = "시스템 전역"
[ui.admin.users.detail.security] [ui.admin.users.detail.security]
password = "비밀번호 변경" password = "비밀번호 변경"
@@ -1037,34 +1074,48 @@ title = "소속 및 조직 정보"
[ui.admin.users.list] [ui.admin.users.list]
add = "사용자 추가" add = "사용자 추가"
delete_aria = "사용자 삭제: {{name}}" bulk_import = "일괄 임포트"
edit_aria = "사용자 수정: {{name}}" empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다."
search_placeholder = "이름 또는 이메일 검색..." search_placeholder = "이름 또는 이메일 검색..."
tenant_slug = "Slug: {{slug}}" subtitle = "시스템 사용자를 조회하고 관리합니다."
title = "사용자 관리"
[ui.admin.users.list.breadcrumb] [ui.admin.users.list.breadcrumb]
list = "List" list = "List"
section = "Users" section = "Users"
[ui.admin.users.list.columns]
title = "컬럼 설정"
[ui.admin.users.list.filter]
tenant = "테넌트 필터"
[ui.admin.users.list.registry] [ui.admin.users.list.registry]
title = "User Registry" count = "총 {{count}}명의 사용자가 등록되어 있습니다."
[ui.admin.users.list.table] [ui.admin.users.list.table]
actions = "ACTIONS" actions = "ACTIONS"
created = "CREATED" created = "CREATED"
name_email = "NAME / EMAIL" name_email = "NAME / EMAIL"
position_job = "POSITION / JOB"
role = "ROLE" role = "ROLE"
status = "STATUS" status = "STATUS"
tenant_dept = "TENANT / DEPT" tenant_dept = "TENANT / DEPT"
[ui.admin.users.table]
email = "이메일"
name = "이름"
role = "역할"
[ui.common] [ui.common]
add = "추가" add = "추가"
all = "전체"
admin_only = "관리자 전용" admin_only = "관리자 전용"
assign = "할당" assign = "할당"
back = "돌아가기" back = "돌아가기"
cancel = "취소" cancel = "취소"
change_file = "파일 변경"
clear_search = "검색 초기화"
close = "닫기" close = "닫기"
collapse = "접기" collapse = "접기"
confirm = "확인" confirm = "확인"
@@ -1073,46 +1124,46 @@ create = "생성"
delete = "삭제" delete = "삭제"
details = "상세정보" details = "상세정보"
edit = "편집" edit = "편집"
export = "내보내기"
fail = "실패"
go_home = "홈으로"
view = "보기"
hyphen = "-" hyphen = "-"
language = "언어" manage = "관리"
language_en = "English"
language_ko = "한국어"
na = "N/A" na = "N/A"
never = "Never" never = "Never"
next = "Next" next = "다음"
none = "없음" none = "없음"
page_of = "Page {{page}} of {{total}}" page_of = "Page {{page}} of {{total}}"
prev = "이전" prev = "이전"
previous = "Previous" previous = "이전"
qr = "QR" qr = "QR"
reset = "초기화"
read_only = "읽기 전용" read_only = "읽기 전용"
refresh = "새로고침" refresh = "새로고침"
reset = "초기화" remove = "제외"
requesting = "요청 중..."
resend = "재발송" resend = "재발송"
retry = "다시 시도" retry = "다시 시도"
save = "저장" save = "저장"
search = "검색" search = "검색"
select = "사용자 선택" select = "선택"
select_placeholder = "사용자를 선택하세요" select_file = "파일 선택"
select_placeholder = "선택하세요"
show_more = "+ 더보기" show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
success = "성공"
theme_dark = "Dark" theme_dark = "Dark"
theme_light = "Light" theme_light = "Light"
theme_toggle = "테마 전환" theme_toggle = "테마 전환"
unknown = "Unknown" unknown = "Unknown"
view = "보기"
manage = "관리"
remove = "제외"
[ui.common.badge] [ui.common.badge]
admin_only = "Admin only" admin_only = "Admin only"
command_only = "Command only" command_only = "Command only"
system = "System" system = "System"
[ui.common.role]
admin = "Admin"
user = "User"
[ui.common.status] [ui.common.status]
active = "활성" active = "활성"
blocked = "차단됨" blocked = "차단됨"
@@ -1135,7 +1186,6 @@ env_badge = "Env: dev"
scope_badge = "Scoped to /dev" scope_badge = "Scoped to /dev"
[ui.dev.nav] [ui.dev.nav]
audit_logs = "감사 로그"
clients = "연동 앱" clients = "연동 앱"
logout = "로그아웃" logout = "로그아웃"
@@ -1165,7 +1215,6 @@ unknown_email = "unknown@example.com"
unknown_name = "Unknown User" unknown_name = "Unknown User"
[ui.dev.clients] [ui.dev.clients]
copy_client_id = "Copy client id"
new = "연동 앱 추가" new = "연동 앱 추가"
search_placeholder = "연동 앱 이름/ID로 검색..." search_placeholder = "연동 앱 이름/ID로 검색..."
tenant_scoped = "Tenant-scoped" tenant_scoped = "Tenant-scoped"
@@ -1184,10 +1233,8 @@ type_label = "유형:"
export_csv = "Export CSV" export_csv = "Export CSV"
revoke = "Revoke" revoke = "Revoke"
revoked_at = "철회일: " revoked_at = "철회일: "
scope_all = "모든 권한"
scope_label = "권한:" scope_label = "권한:"
search_placeholder = "사용자 ID, 이름, 이메일로 검색" search_placeholder = "사용자 ID, 이름, 이메일로 검색"
status_all = "All Statuses"
status_label = "Status:" status_label = "Status:"
status_revoked = "Revoked" status_revoked = "Revoked"
subject = "Subject" subject = "Subject"
@@ -1195,7 +1242,6 @@ title = "User Consent Grants"
[ui.dev.clients.consents.breadcrumb] [ui.dev.clients.consents.breadcrumb]
clients = "Clients" clients = "Clients"
current = "User Consent Grants"
home = "Home" home = "Home"
[ui.dev.clients.consents.filters] [ui.dev.clients.consents.filters]
@@ -1206,13 +1252,6 @@ active_grants = "Active Grants"
avg_scopes = "Avg. Scopes per User" avg_scopes = "Avg. Scopes per User"
total_scopes = "Total Scopes Issued" total_scopes = "Total Scopes Issued"
[ui.dev.clients.stats]
total = "총 애플리케이션"
active_sessions = "활성 세션"
auth_failures = "인증 실패 (24h)"
realtime = "실시간"
stable = "안정"
[ui.dev.clients.consents.table] [ui.dev.clients.consents.table]
action = "Action" action = "Action"
first_granted = "First Granted" first_granted = "First Granted"
@@ -1224,10 +1263,6 @@ user = "User"
[ui.dev.clients.details] [ui.dev.clients.details]
[ui.dev.clients.details.breadcrumb]
current = "연동 앱 상세"
section = "연동 앱"
[ui.dev.clients.details.credentials] [ui.dev.clients.details.credentials]
client_id = "Client ID" client_id = "Client ID"
client_secret = "Client Secret" client_secret = "Client Secret"
@@ -1268,13 +1303,6 @@ title = "Identity Federation"
add_title = "Add Identity Provider" add_title = "Add Identity Provider"
add_btn = "Add Provider" add_btn = "Add Provider"
[ui.dev.clients.general.breadcrumb]
section = "Applications"
[ui.dev.clients.general.footer]
client_id = "Client ID"
created_on = "Created On"
[ui.dev.clients.general.identity] [ui.dev.clients.general.identity]
description = "Description" description = "Description"
description_placeholder = "앱에 대한 간단한 설명을 입력하세요." description_placeholder = "앱에 대한 간단한 설명을 입력하세요."
@@ -1457,12 +1485,9 @@ login_id = "이메일 또는 휴대폰 번호"
password = "비밀번호" password = "비밀번호"
[ui.userfront.login.link] [ui.userfront.login.link]
action_label = "로그인 화면으로 이동"
code_only = "코드만 받기({{time}})" code_only = "코드만 받기({{time}})"
page_title = "링크 로그인"
resend_with_time = "재발송 ({{time}})" resend_with_time = "재발송 ({{time}})"
send = "로그인 링크 전송" send = "로그인 링크 전송"
title = "링크 로그인 완료"
[ui.userfront.login.qr] [ui.userfront.login.qr]
expired = "QR 코드 만료됨" expired = "QR 코드 만료됨"
@@ -1532,9 +1557,7 @@ organization = "조직 정보"
security = "보안" security = "보안"
[ui.userfront.qr] [ui.userfront.qr]
request_permission = "카메라 권한 요청하기"
rescan = "다시 스캔" rescan = "다시 스캔"
result_failure = "승인 실패"
result_success = "승인 완료" result_success = "승인 완료"
title = "Scan QR Code" title = "Scan QR Code"
@@ -1594,25 +1617,3 @@ verify = "본인인증"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "로그인하기" action = "로그인하기"
[msg.admin.tenants]
not_found = "테넌트를 찾을 수 없습니다."
remove_sub_confirm = '테넌트 "{{name}}"을(를) 하위 조직에서 제외할까요?'
[msg.admin.users.create]
success = "사용자가 생성되었습니다."
[ui.admin.tenants.sub]
add_dialog_desc = "하위 조직으로 추가할 테넌트를 선택하세요."
add_dialog_title = "하위 조직 추가"
add_existing = "기존 테넌트 추가"
no_candidates = "추가 가능한 테넌트가 없습니다."
search_placeholder = "테넌트 이름 또는 슬러그로 검색..."
[ui.admin.tenants.table]
members = "멤버수"
[ui.admin.users.table]
email = "이메일"
name = "이름"
role = "역할"
+211 -183
View File
@@ -13,11 +13,35 @@ jangheon = ""
ptc = "" ptc = ""
saman = "" saman = ""
[domain.tenant_type]
company = ""
company_group = ""
personal = ""
user_group = ""
[err] [err]
[err.common] [err.common]
unknown = "" unknown = ""
[err.backend]
authorization_pending = ""
bad_request = ""
conflict = ""
expired_token = ""
forbidden = ""
internal_error = ""
invalid_code = ""
invalid_or_expired_code = ""
invalid_session = ""
invalid_session_reference = ""
not_found = ""
not_supported = ""
password_or_email_mismatch = ""
rate_limited = ""
service_unavailable = ""
slow_down = ""
[err.userfront] [err.userfront]
[err.userfront.auth_proxy] [err.userfront.auth_proxy]
@@ -49,6 +73,9 @@ scope_admin = ""
session_ttl = "" session_ttl = ""
tenant_headers = "" tenant_headers = ""
[msg.admin.common]
forbidden = ""
[msg.admin.api_keys] [msg.admin.api_keys]
[msg.admin.api_keys.create] [msg.admin.api_keys.create]
@@ -90,16 +117,35 @@ count = ""
[msg.admin.groups] [msg.admin.groups]
[msg.admin.groups.list] [msg.admin.groups.list]
create_error = ""
create_success = ""
delete_confirm = ""
delete_error = ""
delete_success = ""
empty = ""
import_error = ""
import_success = ""
loading = ""
subtitle = "" subtitle = ""
[msg.admin.groups.members] [msg.admin.groups.members]
add_success = ""
count = "" count = ""
empty = "" empty = ""
remove_confirm = ""
remove_success = ""
title = "" title = ""
[msg.admin.groups.prompt] [msg.admin.groups.prompt]
user_id = "" user_id = ""
[msg.admin.groups.roles]
assign_success = ""
description = ""
empty = ""
remove_confirm = ""
remove_success = ""
[msg.admin.header] [msg.admin.header]
subtitle = "" subtitle = ""
@@ -107,6 +153,12 @@ subtitle = ""
idp_policy = "" idp_policy = ""
scope = "" scope = ""
[msg.admin.org]
hover_member_info = ""
import_description = ""
import_error = ""
import_success = ""
[msg.admin.overview] [msg.admin.overview]
description = "" description = ""
idp_fallback = "" idp_fallback = ""
@@ -122,14 +174,38 @@ tenant_title = ""
[msg.admin.overview.quick_links] [msg.admin.overview.quick_links]
description = "" description = ""
[msg.admin.overview.summary]
audit_events_24h = ""
oidc_clients = ""
policy_gate = ""
total_tenants = ""
[msg.admin.tenants] [msg.admin.tenants]
approve_confirm = ""
approve_success = ""
delete_confirm = "" delete_confirm = ""
delete_success = ""
empty = "" empty = ""
fetch_error = "" fetch_error = ""
missing_id = ""
not_found = "" not_found = ""
remove_sub_confirm = "" remove_sub_confirm = ""
subtitle = "" subtitle = ""
[msg.admin.tenants.admins]
add_success = ""
empty = ""
remove_confirm = ""
remove_success = ""
subtitle = ""
[msg.admin.tenants.owners]
add_success = ""
empty = ""
remove_confirm = ""
remove_success = ""
subtitle = ""
[msg.admin.tenants.create] [msg.admin.tenants.create]
subtitle = "" subtitle = ""
@@ -164,6 +240,16 @@ subtitle = ""
[msg.admin.users] [msg.admin.users]
[msg.admin.users.bulk]
delete_confirm = ""
delete_success = ""
description = ""
move_description = ""
move_error = ""
move_success = ""
parsed_count = ""
update_success = ""
[msg.admin.users.create] [msg.admin.users.create]
error = "" error = ""
password_required = "" password_required = ""
@@ -174,6 +260,8 @@ subtitle = ""
[msg.admin.users.create.form] [msg.admin.users.create.form]
email_required = "" email_required = ""
field_invalid = ""
field_required = ""
name_required = "" name_required = ""
password_auto_help = "" password_auto_help = ""
password_manual_help = "" password_manual_help = ""
@@ -190,6 +278,7 @@ update_error = ""
update_success = "" update_success = ""
[msg.admin.users.detail.form] [msg.admin.users.detail.form]
field_required = ""
name_required = "" name_required = ""
[msg.admin.users.detail.security] [msg.admin.users.detail.security]
@@ -201,13 +290,20 @@ empty = ""
fetch_error = "" fetch_error = ""
subtitle = "" subtitle = ""
[msg.admin.users.list.columns]
description = ""
no_custom = ""
[msg.admin.users.list.registry] [msg.admin.users.list.registry]
count = "" count = ""
[msg.common] [msg.common]
error = ""
loading = "" loading = ""
saving = "" no_description = ""
parsing = ""
requesting = "" requesting = ""
saving = ""
unknown_error = "" unknown_error = ""
[msg.dev] [msg.dev]
@@ -676,16 +772,28 @@ status = ""
time = "" time = ""
[ui.admin.groups] [ui.admin.groups]
import_csv = ""
[ui.admin.groups.create] [ui.admin.groups.create]
title = "" title = ""
[ui.admin.groups.detail]
breadcrumb_org = ""
breadcrumb_tenant = ""
breadcrumb_unit = ""
members_subtitle = ""
members_title = ""
permissions_subtitle = ""
permissions_title = ""
[ui.admin.groups.form] [ui.admin.groups.form]
desc_label = "" desc_label = ""
desc_placeholder = "" desc_placeholder = ""
name_label = "" name_label = ""
name_placeholder = "" name_placeholder = ""
submit = "" submit = ""
unit_level_label = ""
unit_level_placeholder = ""
[ui.admin.groups.list] [ui.admin.groups.list]
title = "" title = ""
@@ -717,6 +825,12 @@ user_groups = ""
tenants = "" tenants = ""
users = "" users = ""
[ui.admin.org]
download_template = ""
import_btn = ""
import_title = ""
start_import = ""
[ui.admin.overview] [ui.admin.overview]
kicker = "" kicker = ""
title = "" title = ""
@@ -726,10 +840,20 @@ title = ""
[ui.admin.overview.quick_links] [ui.admin.overview.quick_links]
add_tenant = "" add_tenant = ""
tenant_dashboard = "" api_key_management = ""
user_management = ""
title = "" title = ""
view_audit_logs = "" view_audit_logs = ""
[ui.admin.overview.summary]
audit_events_24h = ""
oidc_clients = ""
policy_gate = ""
total_tenants = ""
[ui.admin.profile]
manageable_tenants = ""
[ui.admin.role] [ui.admin.role]
rp_admin = "" rp_admin = ""
super_admin = "" super_admin = ""
@@ -740,6 +864,31 @@ user = ""
add = "" add = ""
title = "" title = ""
[ui.admin.tenants.admins]
add_button = ""
already_admin = ""
dialog_description = ""
dialog_no_results = ""
dialog_search_hint = ""
dialog_search_placeholder = ""
dialog_title = ""
remove_title = ""
table_actions = ""
table_email = ""
table_name = ""
title = ""
[ui.admin.tenants.owners]
add_button = ""
already_owner = ""
dialog_description = ""
dialog_title = ""
remove_title = ""
table_actions = ""
table_email = ""
table_name = ""
title = ""
[ui.admin.tenants.breadcrumb] [ui.admin.tenants.breadcrumb]
list = "" list = ""
section = "" section = ""
@@ -756,9 +905,11 @@ description = ""
domains_label = "" domains_label = ""
domains_placeholder = "" domains_placeholder = ""
name = "" name = ""
parent = ""
slug = "" slug = ""
slug_placeholder = "" slug_placeholder = ""
status = "" status = ""
type = ""
[ui.admin.tenants.create.memo] [ui.admin.tenants.create.memo]
title = "" title = ""
@@ -766,12 +917,27 @@ title = ""
[ui.admin.tenants.create.profile] [ui.admin.tenants.create.profile]
title = "" title = ""
[ui.admin.tenants.detail]
breadcrumb_list = ""
header_subtitle = ""
loading = ""
tab_federation = ""
tab_organization = ""
tab_permissions = ""
tab_profile = ""
tab_schema = ""
title = ""
[ui.admin.tenants.list]
select_placeholder = ""
[ui.admin.tenants.members] [ui.admin.tenants.members]
descendants = "" descendants = ""
direct = "" direct = ""
direct_label = "" direct_label = ""
list_title = "" list_title = ""
title = "" title = ""
total = ""
total_label = "" total_label = ""
[ui.admin.tenants.members.table] [ui.admin.tenants.members.table]
@@ -789,14 +955,18 @@ save = ""
title = "" title = ""
[ui.admin.tenants.schema.field] [ui.admin.tenants.schema.field]
admin_only = ""
key = "" key = ""
key_placeholder = "" key_placeholder = ""
label = "" label = ""
label_placeholder = "" label_placeholder = ""
required = ""
type = "" type = ""
type_boolean = "" type_boolean = ""
type_date = ""
type_number = "" type_number = ""
type_text = "" type_text = ""
validation_placeholder = ""
[ui.admin.tenants.sub] [ui.admin.tenants.sub]
add = "" add = ""
@@ -807,6 +977,7 @@ manage = ""
no_candidates = "" no_candidates = ""
search_placeholder = "" search_placeholder = ""
title = "" title = ""
tree_search_placeholder = ""
[ui.admin.tenants.sub.table] [ui.admin.tenants.sub.table]
action = "" action = ""
@@ -820,10 +991,22 @@ members = ""
name = "" name = ""
slug = "" slug = ""
status = "" status = ""
type = ""
updated = "" updated = ""
[ui.admin.users] [ui.admin.users]
[ui.admin.users.bulk]
do_move = ""
download_template = ""
move_group = ""
move_title = ""
no_department = ""
select_group = ""
selected_count = ""
start_upload = ""
title = ""
[ui.admin.users.create] [ui.admin.users.create]
back = "" back = ""
go_list = "" go_list = ""
@@ -868,13 +1051,10 @@ title = ""
section = "" section = ""
[ui.admin.users.detail.custom_fields] [ui.admin.users.detail.custom_fields]
title = "" multi_title = ""
[ui.admin.users.detail.form] [ui.admin.users.detail.form]
department = "" name_required = ""
department_placeholder = ""
name = ""
name_placeholder = ""
phone = "" phone = ""
phone_placeholder = "" phone_placeholder = ""
role = "" role = ""
@@ -887,21 +1067,32 @@ password = ""
password_placeholder = "" password_placeholder = ""
title = "" title = ""
[ui.admin.users.detail.tenants_section]
additional = ""
primary = ""
title = ""
[ui.admin.users.list] [ui.admin.users.list]
add = "" add = ""
delete_aria = "" bulk_import = ""
edit_aria = "" empty = ""
fetch_error = ""
search_placeholder = "" search_placeholder = ""
tenant_slug = "" subtitle = ""
title = ""
[ui.admin.users.list.breadcrumb] [ui.admin.users.list.breadcrumb]
list = "" list = ""
section = "" section = ""
[ui.admin.users.list.registry] [ui.admin.users.list.columns]
title = "" title = ""
[ui.admin.users.list.filter]
tenant = ""
[ui.admin.users.list.registry]
count = ""
[ui.admin.users.list.table] [ui.admin.users.list.table]
actions = "" actions = ""
created = "" created = ""
@@ -918,10 +1109,13 @@ role = ""
[ui.common] [ui.common]
add = "" add = ""
all = ""
admin_only = "" admin_only = ""
assign = "" assign = ""
back = "" back = ""
cancel = "" cancel = ""
change_file = ""
clear_search = ""
close = "" close = ""
collapse = "" collapse = ""
confirm = "" confirm = ""
@@ -930,6 +1124,9 @@ create = ""
delete = "" delete = ""
details = "" details = ""
edit = "" edit = ""
export = ""
fail = ""
go_home = ""
view = "" view = ""
hyphen = "" hyphen = ""
manage = "" manage = ""
@@ -945,17 +1142,18 @@ reset = ""
read_only = "" read_only = ""
refresh = "" refresh = ""
remove = "" remove = ""
requesting = ""
resend = "" resend = ""
retry = "" retry = ""
save = "" save = ""
search = "" search = ""
select = "" select = ""
select_file = ""
select_placeholder = "" select_placeholder = ""
show_more = "" show_more = ""
language = "" language = ""
language_ko = "" language_ko = ""
language_en = "" language_en = ""
success = ""
theme_dark = "" theme_dark = ""
theme_light = "" theme_light = ""
theme_toggle = "" theme_toggle = ""
@@ -966,10 +1164,6 @@ admin_only = ""
command_only = "" command_only = ""
system = "" system = ""
[ui.common.role]
admin = ""
user = ""
[ui.common.status] [ui.common.status]
active = "" active = ""
blocked = "" blocked = ""
@@ -992,7 +1186,6 @@ env_badge = ""
scope_badge = "" scope_badge = ""
[ui.dev.nav] [ui.dev.nav]
audit_logs = ""
clients = "" clients = ""
logout = "" logout = ""
@@ -1040,10 +1233,8 @@ type_label = ""
export_csv = "" export_csv = ""
revoke = "" revoke = ""
revoked_at = "" revoked_at = ""
scope_all = ""
scope_label = "" scope_label = ""
search_placeholder = "" search_placeholder = ""
status_all = ""
status_label = "" status_label = ""
status_revoked = "" status_revoked = ""
subject = "" subject = ""
@@ -1051,7 +1242,6 @@ title = ""
[ui.dev.clients.consents.breadcrumb] [ui.dev.clients.consents.breadcrumb]
clients = "" clients = ""
current = ""
home = "" home = ""
[ui.dev.clients.consents.filters] [ui.dev.clients.consents.filters]
@@ -1062,13 +1252,6 @@ active_grants = ""
avg_scopes = "" avg_scopes = ""
total_scopes = "" total_scopes = ""
[ui.dev.clients.stats]
total = ""
active_sessions = ""
auth_failures = ""
realtime = ""
stable = ""
[ui.dev.clients.consents.table] [ui.dev.clients.consents.table]
action = "" action = ""
first_granted = "" first_granted = ""
@@ -1080,10 +1263,6 @@ user = ""
[ui.dev.clients.details] [ui.dev.clients.details]
[ui.dev.clients.details.breadcrumb]
current = ""
section = ""
[ui.dev.clients.details.credentials] [ui.dev.clients.details.credentials]
client_id = "" client_id = ""
client_secret = "" client_secret = ""
@@ -1124,9 +1303,6 @@ title = ""
add_title = "" add_title = ""
add_btn = "" add_btn = ""
[ui.dev.clients.general.breadcrumb]
section = ""
[ui.dev.clients.general.identity] [ui.dev.clients.general.identity]
description = "" description = ""
description_placeholder = "" description_placeholder = ""
@@ -1441,151 +1617,3 @@ verify = ""
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "" action = ""
# Auto-added missing keys
[domain.tenant_type]
company = ""
company_group = ""
personal = ""
user_group = ""
[msg.admin.groups.list]
create_error = ""
create_success = ""
delete_confirm = ""
delete_error = ""
delete_success = ""
empty = ""
loading = ""
[msg.admin.groups.members]
add_success = ""
remove_confirm = ""
remove_success = ""
[msg.admin.groups.roles]
assign_success = ""
description = ""
empty = ""
remove_confirm = ""
remove_success = ""
[msg.admin.tenants.admins]
add_success = ""
empty = ""
remove_confirm = ""
remove_success = ""
subtitle = ""
[msg.admin.tenants.owners]
add_success = ""
empty = ""
remove_confirm = ""
remove_success = ""
subtitle = ""
[msg.admin.tenants]
approve_confirm = ""
approve_success = ""
delete_success = ""
missing_id = ""
[msg.common]
error = ""
no_description = ""
[ui.admin.groups]
add_unit = ""
[ui.admin.groups.create]
description = ""
[ui.admin.groups.detail]
breadcrumb_org = ""
breadcrumb_tenant = ""
breadcrumb_unit = ""
members_subtitle = ""
members_title = ""
permissions_subtitle = ""
permissions_title = ""
[ui.admin.groups.form]
parent_label = ""
parent_none = ""
unit_level_label = ""
unit_level_placeholder = ""
[ui.admin.tenants.admins]
add_button = ""
already_admin = ""
dialog_description = ""
dialog_no_results = ""
dialog_search_hint = ""
dialog_search_placeholder = ""
dialog_title = ""
remove_title = ""
table_actions = ""
table_email = ""
table_name = ""
title = ""
[ui.admin.tenants.owners]
add_button = ""
already_owner = ""
dialog_description = ""
dialog_title = ""
remove_title = ""
table_actions = ""
table_email = ""
table_name = ""
title = ""
[ui.admin.tenants.create.form]
parent = ""
type = ""
[ui.admin.tenants.detail]
breadcrumb_list = ""
header_subtitle = ""
loading = ""
tab_federation = ""
tab_organization = ""
tab_permissions = ""
tab_profile = ""
tab_schema = ""
title = ""
[ui.admin.tenants.list]
select_placeholder = ""
[ui.admin.tenants.profile]
allowed_domains = ""
allowed_domains_help = ""
approve_button = ""
description = ""
name = ""
slug = ""
status = ""
subtitle = ""
title = ""
type = ""
[ui.admin.tenants.table]
type = ""
[ui.admin.users.create.form]
job_title = ""
job_title_placeholder = ""
position = ""
position_placeholder = ""
[ui.admin.users.detail.form]
job_title = ""
job_title_placeholder = ""
position = ""
position_placeholder = ""
[ui.admin.users.list.table]
position_job = ""