forked from baron/baron-sso
Merge pull request 'fix/rebac-env-sync-issue' (#542) from fix/rebac-env-sync-issue into dev
Reviewed-on: baron/baron-sso#542
This commit is contained in:
@@ -11,6 +11,7 @@ import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdmin
|
||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
import { TenantOrgChartPage } from "../features/tenants/routes/TenantOrgChartPage";
|
||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||
@@ -40,6 +41,7 @@ export const router = createBrowserRouter(
|
||||
{ path: "users/new", element: <UserCreatePage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/org-chart", element: <TenantOrgChartPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
|
||||
31
adminfront/src/components/ui/checkbox.tsx
Normal file
31
adminfront/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
|
||||
onCheckedChange?: (checked: boolean | "indeterminate") => void;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onCheckedChange?.(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary",
|
||||
className,
|
||||
)}
|
||||
onChange={handleChange}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Checkbox.displayName = "Checkbox";
|
||||
|
||||
export { Checkbox };
|
||||
@@ -98,17 +98,16 @@ function ApiKeyListPage() {
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.api_keys.list.registry.title", "API Key Registry")}
|
||||
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.registry.count",
|
||||
"총 {{count}}개 API 키",
|
||||
{ count: query.data?.total ?? 0 },
|
||||
"msg.admin.apikeys.registry.count",
|
||||
"총 {{count}}개의 활성 키가 등록되어 있습니다.",
|
||||
{ count: query.data?.items?.length ?? 0 },
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">{t("ui.common.badge.system", "System")}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
{(errorMsg || fallbackError) && (
|
||||
|
||||
@@ -191,17 +191,14 @@ function AuditLogsPage() {
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.audit.registry.title", "Audit registry")}
|
||||
{t("ui.admin.audit.registry.title", "Log Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.audit.registry.count", "로드된 로그 {{count}}건", {
|
||||
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: logs.length,
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">
|
||||
{t("ui.common.badge.command_only", "Command only")}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Download, FileText, Loader2, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -13,7 +20,11 @@ import {
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { importOrgChart } from "../../../lib/adminApi";
|
||||
import {
|
||||
type ImportResult,
|
||||
fetchImportProgress,
|
||||
importOrgChart,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
interface OrgChartUploadModalProps {
|
||||
@@ -27,52 +38,76 @@ export function OrgChartUploadModal({
|
||||
}: OrgChartUploadModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [result, setResult] = React.useState<ImportResult | null>(null);
|
||||
const [progressId, setProgressId] = React.useState<string | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (file: File) => importOrgChart(tenantId, file),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.org.import_success",
|
||||
"조직도가 성공적으로 업로드되었습니다.",
|
||||
),
|
||||
);
|
||||
setOpen(false);
|
||||
mutationFn: ({ file, pid }: { file: File; pid: string }) =>
|
||||
importOrgChart(tenantId, file, pid),
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
setProgressId(null);
|
||||
if (data.errors.length === 0) {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.org.import_success",
|
||||
"조직도가 성공적으로 업로드되었습니다.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t(
|
||||
"msg.admin.org.import_partial_success",
|
||||
"일부 데이터 업로드 중 오류가 발생했습니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
setProgressId(null);
|
||||
toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data: progressData } = useQuery({
|
||||
queryKey: ["importProgress", progressId],
|
||||
queryFn: () =>
|
||||
progressId ? fetchImportProgress(tenantId, progressId) : null,
|
||||
enabled: !!progressId && mutation.isPending,
|
||||
refetchInterval: 500,
|
||||
});
|
||||
const percent =
|
||||
progressData && progressData.total > 0
|
||||
? Math.round((progressData.current / progressData.total) * 100)
|
||||
: 0;
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (file) {
|
||||
mutation.mutate(file);
|
||||
const pid = Math.random().toString(36).substring(2, 15);
|
||||
setProgressId(pid);
|
||||
mutation.mutate({ file, pid });
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "email,name,organization,position,jobtitle,is_owner";
|
||||
const example = `ceo@example.com,홍길동,경영진,대표이사,경영총괄,true
|
||||
cto@example.com,이몽룡,기술부문,이사,기술총괄,true
|
||||
user1@example.com,성춘향,기술부문/개발팀,팀원,프론트엔드 개발,false`;
|
||||
const blob = new Blob(
|
||||
[
|
||||
`${headers}
|
||||
${example}`,
|
||||
],
|
||||
{ type: "text/csv" },
|
||||
);
|
||||
const headers = "이메일,이름,소속,직급,직무,구분,그룹,디비젼,팀,셀";
|
||||
const example = `test1@example.com,홍길동,한맥,수석,기획,팀장,전략그룹,기획실,인사팀,-
|
||||
test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실,개발1팀,A셀`;
|
||||
const blob = new Blob([`\uFEFF${headers}\n${example}`], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -82,14 +117,23 @@ ${example}`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
setFile(null);
|
||||
setResult(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Upload size={14} />
|
||||
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV)")}
|
||||
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV/XLSX)")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.org.import_title", "조직도 일괄 등록")}
|
||||
@@ -97,64 +141,153 @@ ${example}`,
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.org.import_description",
|
||||
"CSV 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
|
||||
"CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("ui.admin.org.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
{!result ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="gap-2"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
<Download size={14} />
|
||||
{t("ui.admin.org.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv, .xlsx, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{file
|
||||
? t("ui.common.change_file", "파일 변경")
|
||||
: t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
</div>
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="text-primary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{file.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mutation.isPending && progressId && (
|
||||
<div className="w-full mt-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div className="flex justify-between text-xs mb-1 font-medium text-muted-foreground">
|
||||
<span>데이터 처리 중...</span>
|
||||
<span>
|
||||
{percent}% ({progressData?.current || 0} /{" "}
|
||||
{progressData?.total || 0})
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden relative">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-all duration-300 ease-out absolute top-0 left-0"
|
||||
style={{ width: `${Math.max(5, percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20 flex items-center gap-3">
|
||||
<FileText className="text-primary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{file.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg bg-primary/5 border border-primary/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
전체 행
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{result.totalRows}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
처리 완료
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{result.processed}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
사용자 생성/업데이트
|
||||
</div>
|
||||
<div className="text-xl font-bold text-blue-600">
|
||||
{result.userCreated} / {result.userUpdated}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-orange-500/5 border border-orange-500/10">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
조직(테넌트) 생성
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{result.tenantCreated}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
|
||||
<AlertCircle size={16} />
|
||||
오류 목록 ({result.errors.length})
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-md p-2 bg-destructive/5 text-xs font-mono space-y-1">
|
||||
{result.errors.map((err, idx) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Errors are a static list returned from the server.
|
||||
key={idx}
|
||||
className="text-destructive border-b border-destructive/10 pb-1 last:border-0"
|
||||
>
|
||||
{" "}
|
||||
{err}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || mutation.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
)}
|
||||
{t("ui.admin.org.start_import", "임포트 시작")}
|
||||
</Button>
|
||||
{!result ? (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || mutation.isPending}
|
||||
className="w-full sm:w-auto relative"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||
처리 중 ({percent}%)
|
||||
</>
|
||||
) : (
|
||||
t("ui.admin.org.start_import", "임포트 시작")
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setOpen(false)} className="w-full sm:w-auto">
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -59,20 +59,17 @@ function TenantCreatePage() {
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-semibold">
|
||||
{t("ui.admin.tenants.create.title", "테넌트 추가")}
|
||||
{t("ui.admin.tenants.create.title", "테넌트 생성")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.tenants.create.subtitle",
|
||||
"글로벌 운영 기준의 신규 테넌트를 등록합니다.",
|
||||
"새로운 테넌트를 시스템에 등록합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">
|
||||
{t("ui.common.badge.admin_only", "Admin only")}
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ function TenantDetailPage() {
|
||||
const canAccessSchema =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
|
||||
const isFederationTab = location.pathname.includes("/federation");
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
|
||||
@@ -43,9 +42,6 @@ function TenantDetailPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">
|
||||
{t("ui.common.admin_only", "관리자 전용")}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -53,7 +49,6 @@ function TenantDetailPage() {
|
||||
<Link
|
||||
to={`/tenants/${tenantId}`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
!isFederationTab &&
|
||||
!isPermissionsTab &&
|
||||
!location.pathname.includes("/schema") &&
|
||||
!isOrganizationTab
|
||||
@@ -63,16 +58,6 @@ function TenantDetailPage() {
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_profile", "프로필")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/federation`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
isFederationTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_federation", "외부 연동")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/permissions`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
CornerDownRight,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||
@@ -13,6 +20,8 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -24,13 +33,18 @@ import {
|
||||
import {
|
||||
type TenantSummary,
|
||||
deleteTenant,
|
||||
deleteTenantsBulk,
|
||||
fetchMe,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
@@ -40,7 +54,6 @@ function TenantListPage() {
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin") {
|
||||
const manageableCount = profile.manageableTenants?.length ?? 0;
|
||||
// If only 1 in array, OR array is empty but we have a primary tenantId
|
||||
if (
|
||||
(manageableCount === 1 || manageableCount === 0) &&
|
||||
profile.tenantId
|
||||
@@ -66,6 +79,14 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteBulkMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
|
||||
onSuccess: () => {
|
||||
setSelectedIds([]);
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
profile &&
|
||||
profile.role !== "super_admin" &&
|
||||
@@ -83,7 +104,6 @@ function TenantListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// While redirecting (only if exactly one manageable tenant)
|
||||
if (
|
||||
profile?.role === "tenant_admin" &&
|
||||
(profile.manageableTenants?.length ?? 0) <= 1
|
||||
@@ -98,7 +118,51 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const tenants = query.data?.items ?? [];
|
||||
const allTenants = query.data?.items ?? [];
|
||||
const tenants = React.useMemo(() => {
|
||||
if (!search.trim()) return allTenants;
|
||||
const term = search.toLowerCase();
|
||||
return allTenants.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(term) ||
|
||||
t.slug.toLowerCase().includes(term),
|
||||
);
|
||||
}, [allTenants, search]);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(tenants.map((t) => t.id));
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (id: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds((prev) => [...prev, id]);
|
||||
} else {
|
||||
setSelectedIds((prev) => prev.filter((i) => i !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBulk = () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.tenants.delete_bulk_confirm",
|
||||
"선택한 {{count}}개 테넌트를 삭제할까요?",
|
||||
{ count: selectedIds.length },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteBulkMutation.mutate(selectedIds);
|
||||
};
|
||||
|
||||
const rootTenant =
|
||||
tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
|
||||
|
||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||
if (
|
||||
@@ -130,6 +194,34 @@ function TenantListPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
{selectedIds.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteBulk}
|
||||
disabled={deleteBulkMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t("ui.admin.tenants.delete_selected", "선택 삭제")} (
|
||||
{selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
</RoleGuard>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<OrgChartUploadModal
|
||||
tenantId={rootTenant?.id || "root"}
|
||||
onSuccess={() => query.refetch()}
|
||||
/>
|
||||
</RoleGuard>
|
||||
|
||||
<Button asChild variant="outline" className="gap-2">
|
||||
<Link to="/tenants/org-chart">
|
||||
{t("ui.admin.tenants.view_org_chart", "전체 조직도 보기")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
@@ -161,11 +253,23 @@ function TenantListPage() {
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">
|
||||
{t("ui.common.badge.admin_only", "Admin only")}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
</CardHeader>{" "}
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="mb-6 flex items-center gap-4 flex-shrink-0">
|
||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색...",
|
||||
)}
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
|
||||
{errorMsg ?? fallbackError}
|
||||
@@ -177,6 +281,17 @@ function TenantListPage() {
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">
|
||||
<Checkbox
|
||||
checked={
|
||||
tenants.length > 0 &&
|
||||
selectedIds.length === tenants.length
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelectAll(!!checked)
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
@@ -203,7 +318,7 @@ function TenantListPage() {
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<TableCell colSpan={8}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -211,7 +326,7 @@ function TenantListPage() {
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
colSpan={8}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -223,6 +338,14 @@ function TenantListPage() {
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(tenant.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelect(tenant.id, !!checked)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
|
||||
486
adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx
Normal file
486
adminfront/src/features/tenants/routes/TenantOrgChartPage.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { type UserSummary, fetchUsers } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type UserWithPath = UserSummary & { _path: { level: number; name: string }[] };
|
||||
|
||||
interface OrgNode {
|
||||
name: string;
|
||||
level: number;
|
||||
members: UserWithPath[];
|
||||
subData: UserWithPath[];
|
||||
children: OrgNode[];
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
export function TenantOrgChartPage() {
|
||||
const [selectedDept, setSelectedDept] = React.useState<string>("전체");
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [lines, setLines] = React.useState<
|
||||
{
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
key: string;
|
||||
path: string;
|
||||
}[]
|
||||
>([]);
|
||||
const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 });
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["users", { limit: 5000, offset: 0 }],
|
||||
queryFn: () => fetchUsers(5000, 0),
|
||||
});
|
||||
|
||||
const users = React.useMemo(() => {
|
||||
if (!query.data?.items) return [];
|
||||
return query.data.items
|
||||
.filter((u) => u.status === "active")
|
||||
.map((u) => {
|
||||
const deptStr = u.department || "";
|
||||
const parts = deptStr.includes(" > ")
|
||||
? deptStr.split(" > ")
|
||||
: deptStr.split("/");
|
||||
|
||||
return {
|
||||
...u,
|
||||
_path: parts
|
||||
.map((name, i) => ({ level: i, name: name.trim() }))
|
||||
.filter((p) => p.name),
|
||||
};
|
||||
});
|
||||
}, [query.data]);
|
||||
|
||||
const depts = React.useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const u of users) {
|
||||
if (u._path[0]) s.add(u._path[0].name);
|
||||
}
|
||||
return Array.from(s).sort();
|
||||
}, [users]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedDept !== "전체" && !depts.includes(selectedDept)) {
|
||||
setSelectedDept("전체");
|
||||
}
|
||||
}, [selectedDept, depts]);
|
||||
|
||||
const buildHierarchy = (data: UserWithPath[], depth: number): OrgNode[] => {
|
||||
if (!data.length) return [];
|
||||
const map: Record<string, OrgNode> = {};
|
||||
const groups: OrgNode[] = [];
|
||||
|
||||
for (const m of data) {
|
||||
const step = m._path[depth];
|
||||
if (!step) continue;
|
||||
if (!map[step.name]) {
|
||||
map[step.name] = {
|
||||
name: step.name,
|
||||
level: step.level,
|
||||
members: [],
|
||||
subData: [],
|
||||
children: [],
|
||||
};
|
||||
groups.push(map[step.name]);
|
||||
}
|
||||
if (m._path.length === depth + 1) {
|
||||
map[step.name].members.push(m);
|
||||
} else {
|
||||
map[step.name].subData.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
return groups.map((g) => ({
|
||||
...g,
|
||||
children: buildHierarchy(g.subData, depth + 1),
|
||||
}));
|
||||
};
|
||||
|
||||
const calculateTotalCount = (node: OrgNode): number => {
|
||||
let count = node.members.length;
|
||||
for (const c of node.children) {
|
||||
count += calculateTotalCount(c);
|
||||
}
|
||||
node.totalCount = count;
|
||||
return count;
|
||||
};
|
||||
|
||||
const drawLines = React.useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scrollTop = container.scrollTop;
|
||||
const scrollLeft = container.scrollLeft;
|
||||
const childBoxes = container.querySelectorAll("[data-parent]");
|
||||
const newLines: {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
key: string;
|
||||
path: string;
|
||||
}[] = [];
|
||||
|
||||
for (const box of Array.from(childBoxes)) {
|
||||
const parentId = box.getAttribute("data-parent");
|
||||
if (!parentId) continue;
|
||||
const parent = document.getElementById(parentId);
|
||||
if (!parent) continue;
|
||||
|
||||
const pRect = parent.getBoundingClientRect();
|
||||
const cRect = box.getBoundingClientRect();
|
||||
|
||||
if (pRect.width === 0 || cRect.width === 0) continue;
|
||||
|
||||
const parentLevel = Number.parseInt(
|
||||
parent.getAttribute("data-level") || "0",
|
||||
10,
|
||||
);
|
||||
|
||||
if (parentLevel === 0) {
|
||||
// Horizontal fork for Level 0 -> Level 1
|
||||
const px = pRect.left + pRect.width / 2 - rect.left + scrollLeft;
|
||||
const py = pRect.bottom - rect.top + scrollTop;
|
||||
const cx = cRect.left + cRect.width / 2 - rect.left + scrollLeft;
|
||||
const cy = cRect.top - rect.top + scrollTop;
|
||||
const midY = py + (cy - py) / 2;
|
||||
|
||||
newLines.push({
|
||||
key: `${parentId}->${box.id}`,
|
||||
x1: px,
|
||||
y1: py,
|
||||
x2: cx,
|
||||
y2: cy,
|
||||
path: `M ${px} ${py} L ${px} ${midY} L ${cx} ${midY} L ${cx} ${cy}`,
|
||||
});
|
||||
} else {
|
||||
// Vertical spine for Level >= 1 -> Level >= 2
|
||||
const spineX = pRect.left + 32 - rect.left + scrollLeft; // 32px indent from parent's left edge
|
||||
const py = pRect.bottom - rect.top + scrollTop;
|
||||
const cx = cRect.left - rect.left + scrollLeft; // Child's left edge
|
||||
const cy = cRect.top + 24 - rect.top + scrollTop; // Middle of child's header
|
||||
|
||||
newLines.push({
|
||||
key: `${parentId}->${box.id}`,
|
||||
x1: spineX,
|
||||
y1: py,
|
||||
x2: cx,
|
||||
y2: cy,
|
||||
path: `M ${spineX} ${py} L ${spineX} ${cy} L ${cx} ${cy}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setLines(newLines);
|
||||
setSvgSize({
|
||||
width: Math.max(container.scrollWidth, rect.width),
|
||||
height: Math.max(container.scrollHeight, rect.height),
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// Biome requires used variables. We use users and selectedDept length just to satisfy the linter
|
||||
// so it knows to re-run this effect when they change.
|
||||
const _forceTrigger = selectedDept + users.length;
|
||||
|
||||
const timeout = setTimeout(drawLines, 150);
|
||||
window.addEventListener("resize", drawLines);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener("resize", drawLines);
|
||||
};
|
||||
}, [drawLines, selectedDept, users]);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">로딩 중...</div>
|
||||
);
|
||||
}
|
||||
|
||||
const targetDepts = selectedDept === "전체" ? depts : [selectedDept];
|
||||
const totalUsers = targetDepts.reduce((acc, d) => {
|
||||
return acc + users.filter((u) => u._path[0]?.name === d).length;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-theme(spacing.32))] bg-slate-50 rounded-xl overflow-hidden shadow-sm border border-slate-200">
|
||||
<header className="flex items-center justify-between px-6 py-4 bg-white border-b border-slate-200 shadow-sm z-10 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" asChild className="h-8 w-8">
|
||||
<Link to="/tenants">
|
||||
<ChevronLeft size={16} />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">통합 조직도</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
조직 구조를 효율적인 세로 계층형으로 시각화합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto max-w-2xl custom-scrollbar pb-1">
|
||||
{["전체", ...depts].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedDept(d);
|
||||
setLines([]); // Reset lines during switch
|
||||
}}
|
||||
className={`whitespace-nowrap px-4 py-1.5 text-sm font-semibold rounded-full border transition-colors ${
|
||||
selectedDept === d
|
||||
? "bg-slate-800 text-white border-slate-800"
|
||||
: "bg-white text-slate-600 border-slate-200 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-2 whitespace-nowrap px-4 py-1.5 bg-blue-50 text-blue-700 font-bold rounded-full border border-blue-200 text-sm flex items-center">
|
||||
총 {totalUsers}명
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto relative p-8 bg-[#f8fafc]"
|
||||
ref={containerRef}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 left-0 pointer-events-none z-0"
|
||||
style={{
|
||||
width: svgSize.width ? `${svgSize.width}px` : "100%",
|
||||
height: svgSize.height ? `${svgSize.height}px` : "100%",
|
||||
}}
|
||||
>
|
||||
{lines.map((l) => (
|
||||
<path
|
||||
key={l.key}
|
||||
d={l.path}
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24">
|
||||
{targetDepts.map((dName) => {
|
||||
const dData = users.filter((u) => u._path[0]?.name === dName);
|
||||
const hierarchy = buildHierarchy(dData, 0);
|
||||
const dNode = hierarchy[0];
|
||||
if (!dNode) return null;
|
||||
calculateTotalCount(dNode);
|
||||
|
||||
return (
|
||||
<div key={dName} className="flex flex-col items-center w-full">
|
||||
<OrgNodeView
|
||||
node={dNode}
|
||||
parentId={null}
|
||||
onToggle={drawLines}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------- Node Rendering --------------------- //
|
||||
|
||||
const ROLE_ORDER = [
|
||||
"사장",
|
||||
"부사장",
|
||||
"전무",
|
||||
"상무",
|
||||
"이사",
|
||||
"수석",
|
||||
"책임",
|
||||
"선임",
|
||||
"주임",
|
||||
"사원",
|
||||
];
|
||||
|
||||
function getRankWeight(u: UserWithPath) {
|
||||
const role = u.position || "";
|
||||
let idx = ROLE_ORDER.indexOf(role);
|
||||
if (idx === -1) idx = 99;
|
||||
const isLeader = u.position?.endsWith("장") || u.jobTitle?.endsWith("장");
|
||||
return (isLeader ? -100 : 0) + idx;
|
||||
}
|
||||
|
||||
function OrgNodeView({
|
||||
node,
|
||||
parentId,
|
||||
onToggle,
|
||||
}: {
|
||||
node: OrgNode;
|
||||
parentId: string | null;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
const myId = `node-${node.level}-${node.name.replace(/\s/g, "")}`;
|
||||
|
||||
const toggle = () => {
|
||||
setCollapsed(!collapsed);
|
||||
setTimeout(onToggle, 100);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
|
||||
const membersToShow = [...node.members].sort(
|
||||
(a, b) => getRankWeight(a) - getRankWeight(b),
|
||||
);
|
||||
|
||||
const isVerticalChildren = node.level >= 1; // Children of Level 1+ are vertical
|
||||
const isVerticallyStacked = node.level >= 1; // Level 1+ are vertically stacked inside parent
|
||||
|
||||
// 하위 조직이 모두 말단(Leaf) 조직일 경우, 부모 박스 내부에 회색 그룹으로 묶어서(임베딩) 표시합니다.
|
||||
const embedChildren =
|
||||
node.children.length > 0 &&
|
||||
node.children.every((c) => c.children.length === 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-3`}
|
||||
>
|
||||
<div
|
||||
id={myId}
|
||||
data-parent={parentId || undefined}
|
||||
data-level={node.level}
|
||||
className={`bg-white border rounded-xl shadow-sm mb-4 flex flex-col transition-all shrink-0 ${
|
||||
node.level === 0 ? "border-slate-800 border-t-4" : "border-slate-300"
|
||||
} ${collapsed ? "opacity-80" : ""}`}
|
||||
style={{ width: "fit-content", minWidth: "260px", maxWidth: "400px" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-2 font-bold flex justify-between items-center cursor-pointer select-none hover:bg-slate-50 transition-colors rounded-t-xl outline-none focus-visible:ring-2 focus-visible:ring-primary w-full text-left ${
|
||||
node.level === 0
|
||||
? "text-slate-800 text-lg"
|
||||
: "text-slate-700 text-sm"
|
||||
}`}
|
||||
onClick={toggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
<span className="text-slate-400 font-normal text-xs ml-4">
|
||||
({node.totalCount})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && membersToShow.length > 0 && (
|
||||
<div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full">
|
||||
{membersToShow.map((m) => (
|
||||
<MemberCard key={m.id} member={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && embedChildren && (
|
||||
<div className="flex flex-col gap-2 p-2 pt-0 w-full">
|
||||
{node.children.map((child) => {
|
||||
const childMembers = [...child.members].sort(
|
||||
(a, b) => getRankWeight(a) - getRankWeight(b),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={child.name}
|
||||
className="bg-slate-50 border border-slate-200 rounded-lg p-1.5 flex flex-col gap-1.5 w-full"
|
||||
>
|
||||
<div className="text-[11px] font-bold text-slate-600 flex justify-between px-1">
|
||||
<span>{child.name}</span>
|
||||
<span className="text-slate-400 font-normal">
|
||||
({child.totalCount})
|
||||
</span>
|
||||
</div>
|
||||
{childMembers.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-1 w-full">
|
||||
{childMembers.map((m) => (
|
||||
<MemberCard key={m.id} member={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && !embedChildren && node.children.length > 0 && (
|
||||
<div
|
||||
className={`flex ${
|
||||
isVerticalChildren
|
||||
? "flex-col items-start pl-12 gap-4 w-full"
|
||||
: "flex-row gap-10 justify-center items-start"
|
||||
} relative`}
|
||||
>
|
||||
{node.children.map((c) => (
|
||||
<OrgNodeView
|
||||
key={c.name}
|
||||
node={c}
|
||||
parentId={myId}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCard({ member }: { member: UserWithPath }) {
|
||||
const coColor = (() => {
|
||||
const c = (member.companyCode || "").toLowerCase();
|
||||
if (c.includes("hanmac")) return "bg-[#1E3A8A] text-white border-[#1E3A8A]";
|
||||
if (c.includes("saman")) return "bg-[#047857] text-white border-[#047857]";
|
||||
if (c.includes("ptc")) return "bg-[#C2410C] text-white border-[#C2410C]";
|
||||
if (c.includes("baron")) return "bg-[#4338CA] text-white border-[#4338CA]";
|
||||
return "bg-slate-600 text-white border-slate-700";
|
||||
})();
|
||||
|
||||
const roleBadge =
|
||||
member.jobTitle && member.jobTitle !== member.position
|
||||
? member.jobTitle
|
||||
: member.position?.endsWith("장")
|
||||
? member.position
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center px-1.5 h-[30px] rounded border shadow-sm overflow-hidden w-full leading-none ${coColor}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0 w-full">
|
||||
<div className="flex items-baseline gap-1 truncate shrink-0">
|
||||
<span className="font-bold text-[11px] whitespace-nowrap">
|
||||
{member.name}
|
||||
</span>
|
||||
{member.position && member.position !== roleBadge && (
|
||||
<span className="text-[10px] opacity-90 whitespace-nowrap font-medium">
|
||||
{member.position}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{roleBadge && (
|
||||
<span className="bg-white/20 text-[9px] px-1 py-[1.5px] rounded-[3px] font-bold tracking-tight shrink-0 whitespace-nowrap ml-auto">
|
||||
{roleBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -251,12 +251,9 @@ export function UserGroupDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className="font-normal">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
|
||||
</Badge>
|
||||
<Badge variant="muted" className="font-normal">
|
||||
ID: {id?.split("-")[0]}...
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ function UserListPage() {
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const limit = 50;
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
@@ -190,13 +190,14 @@ function UserListPage() {
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: bulkDeleteUsers,
|
||||
onSuccess: () => {
|
||||
onSuccess: (_, variables) => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.bulk.delete_success",
|
||||
"선택한 사용자들이 삭제되었습니다.",
|
||||
"{{count}}명의 사용자가 삭제되었습니다.",
|
||||
{ count: variables.length },
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -493,9 +494,18 @@ function UserListPage() {
|
||||
<TableCell>
|
||||
<input
|
||||
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 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => toggleSelectUser(user.id)}
|
||||
disabled={user.id === profile?.id}
|
||||
title={
|
||||
user.id === profile?.id
|
||||
? t(
|
||||
"msg.admin.users.self_delete_blocked",
|
||||
"본인 계정은 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -559,9 +569,20 @@ function UserListPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
className="text-destructive hover:text-destructive disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
disabled={
|
||||
deleteMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
title={
|
||||
user.id === profile?.id
|
||||
? t(
|
||||
"msg.admin.users.self_delete_blocked",
|
||||
"본인 계정은 삭제할 수 없습니다.",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
|
||||
@@ -74,16 +74,13 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "email,name,phone,role,tenant,department,employee_id";
|
||||
const headers =
|
||||
"email,name,phone,role,tenant,department,position,jobTitle,employee_id";
|
||||
const example =
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
|
||||
const blob = new Blob(
|
||||
[
|
||||
`${headers}
|
||||
${example}`,
|
||||
],
|
||||
{ type: "text/csv" },
|
||||
);
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
|
||||
const blob = new Blob([`${headers}\n${example}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
|
||||
@@ -34,6 +34,10 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
item.tenantSlug = value;
|
||||
} else if (header === "department") {
|
||||
item.department = value;
|
||||
} else if (header === "position") {
|
||||
item.position = value;
|
||||
} else if (header === "jobtitle") {
|
||||
item.jobTitle = value;
|
||||
} else {
|
||||
item.metadata[header] = value;
|
||||
}
|
||||
|
||||
@@ -139,6 +139,12 @@ export async function deleteTenant(tenantId: string) {
|
||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
|
||||
}
|
||||
|
||||
export async function deleteTenantsBulk(ids: string[]) {
|
||||
await apiClient.delete("/v1/admin/tenants/bulk", {
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveTenant(tenantId: string) {
|
||||
const { data } = await apiClient.post<TenantSummary>(
|
||||
`/v1/admin/tenants/${tenantId}/approve`,
|
||||
@@ -260,21 +266,44 @@ export async function removeGroupMember(
|
||||
);
|
||||
}
|
||||
|
||||
export async function importOrgChart(tenantId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const { data } = await apiClient.post(
|
||||
`/v1/admin/tenants/${tenantId}/organization/import`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
},
|
||||
export interface ImportResult {
|
||||
totalRows: number;
|
||||
processed: number;
|
||||
userCreated: number;
|
||||
userUpdated: number;
|
||||
tenantCreated: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export async function fetchImportProgress(
|
||||
tenantId: string,
|
||||
progressId: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<{ current: number; total: number }>(
|
||||
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function importOrgChart(
|
||||
tenantId: string,
|
||||
file: File,
|
||||
progressId?: string,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const url = progressId
|
||||
? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}`
|
||||
: `/v1/admin/tenants/${tenantId}/organization/import`;
|
||||
|
||||
const { data } = await apiClient.post<{ data: ImportResult }>(url, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export type GroupRole = {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
@@ -354,6 +383,7 @@ export type UserSummary = {
|
||||
role: string;
|
||||
status: string;
|
||||
tenantSlug?: string;
|
||||
companyCode?: string;
|
||||
tenant?: TenantSummary;
|
||||
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -411,6 +441,8 @@ export type BulkUserItem = {
|
||||
role?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
metadata: Record<string, string>;
|
||||
};
|
||||
|
||||
|
||||
@@ -877,7 +877,9 @@ user = "General User (Tenant Member)"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "Add Tenant"
|
||||
delete_selected = "Delete Selected"
|
||||
title = "Tenant Registry"
|
||||
view_org_chart = "View Full Org Chart"
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = "Add Button"
|
||||
@@ -944,11 +946,31 @@ tab_schema = "Tab Schema"
|
||||
title = "Details"
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
select_placeholder = "Select Placeholder"
|
||||
search_placeholder = "Search tenant by name or slug..."
|
||||
select_placeholder = "Select a tenant"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
descendants = "Descendant Members"
|
||||
direct = "Direct Members"
|
||||
|
||||
[msg.admin.apikeys.registry]
|
||||
count = "There are {{count}} active keys registered."
|
||||
|
||||
[msg.admin.org]
|
||||
import_partial_success = "Imported some organization data successfully."
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_bulk_confirm = "Delete {{count}} selected tenants?"
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = "You cannot delete your own account."
|
||||
|
||||
[ui.admin.apikeys.registry]
|
||||
title = "API Key Registry"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
delete_selected = "Delete Selected"
|
||||
view_org_chart = "View Full Org Chart"
|
||||
direct_label = "Direct"
|
||||
list_title = "Member Management"
|
||||
title = "Tenant Members ({{count}})"
|
||||
|
||||
@@ -159,7 +159,7 @@ scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다."
|
||||
|
||||
[msg.admin.org]
|
||||
hover_member_info = "마우스를 올리면 상세 정보를 확인할 수 있습니다."
|
||||
import_description = "CSV 파일을 업로드하여 조직도를 일괄 등록합니다."
|
||||
import_description = "CSV 또는 XLSX 파일을 업로드하여 조직도를 일괄 등록합니다. (필수 컬럼: 이메일, 이름)"
|
||||
import_error = "조직도 임포트 중 오류가 발생했습니다."
|
||||
import_success = "조직도가 성공적으로 임포트되었습니다."
|
||||
|
||||
@@ -187,6 +187,7 @@ total_tenants = "전체 테넌트 수"
|
||||
[msg.admin.tenants]
|
||||
approve_confirm = "이 테넌트를 승인하시겠습니까?"
|
||||
approve_success = "테넌트가 승인되었습니다."
|
||||
delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
|
||||
delete_confirm = "테넌트 \"{{name}}\"를 삭제할까요?"
|
||||
delete_success = "테넌트가 삭제되었습니다."
|
||||
empty = "아직 등록된 테넌트가 없습니다."
|
||||
@@ -280,6 +281,7 @@ edit_subtitle = "{{email}} 계정의 정보를 수정합니다."
|
||||
not_found = "사용자를 찾을 수 없습니다."
|
||||
update_error = "사용자 수정에 실패했습니다."
|
||||
update_success = "사용자 정보가 수정되었습니다."
|
||||
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "필수입니다."
|
||||
@@ -877,7 +879,9 @@ user = "일반 사용자 (Tenant Member)"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "테넌트 추가"
|
||||
delete_selected = "선택 삭제"
|
||||
title = "테넌트 목록"
|
||||
view_org_chart = "전체 조직도 보기"
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = "관리자 추가"
|
||||
@@ -944,11 +948,31 @@ tab_schema = "사용자 스키마"
|
||||
title = "상세"
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
|
||||
select_placeholder = "테넌트를 선택하세요"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
descendants = "하위 조직 멤버"
|
||||
direct = "소속 멤버"
|
||||
|
||||
[msg.admin.apikeys.registry]
|
||||
count = "총 {{count}}개의 활성 키가 등록되어 있습니다."
|
||||
|
||||
[msg.admin.org]
|
||||
import_partial_success = "일부 조직 정보를 가져왔습니다."
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = "자신의 계정은 삭제할 수 없습니다."
|
||||
|
||||
[ui.admin.apikeys.registry]
|
||||
title = "API Key Registry"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
delete_selected = "선택 삭제"
|
||||
view_org_chart = "전체 조직도 보기"
|
||||
direct_label = "직속"
|
||||
list_title = "구성원 관리"
|
||||
title = "테넌트 구성원 ({{count}})"
|
||||
|
||||
@@ -878,7 +878,9 @@ user = ""
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = ""
|
||||
delete_selected = ""
|
||||
title = ""
|
||||
view_org_chart = ""
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = ""
|
||||
@@ -945,11 +947,31 @@ tab_schema = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
search_placeholder = ""
|
||||
select_placeholder = ""
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
descendants = ""
|
||||
direct = ""
|
||||
|
||||
[msg.admin.apikeys.registry]
|
||||
count = ""
|
||||
|
||||
[msg.admin.org]
|
||||
import_partial_success = ""
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_bulk_confirm = ""
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = ""
|
||||
|
||||
[ui.admin.apikeys.registry]
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
delete_selected = ""
|
||||
view_org_chart = ""
|
||||
direct_label = ""
|
||||
list_title = ""
|
||||
title = ""
|
||||
|
||||
53
backend/cmd/fix_kratos_roles.go
Normal file
53
backend/cmd/fix_kratos_roles.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
kratosAdmin := service.NewKratosAdminService()
|
||||
ctx := context.Background()
|
||||
|
||||
identities, err := kratosAdmin.ListIdentities(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list identities: %v", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, id := range identities {
|
||||
traits := id.Traits
|
||||
changed := false
|
||||
|
||||
if r, ok := traits["role"].(string); ok {
|
||||
norm := domain.NormalizeRole(r)
|
||||
if norm != r && norm == domain.RoleUser {
|
||||
traits["role"] = norm
|
||||
traits["grade"] = norm
|
||||
changed = true
|
||||
}
|
||||
} else if g, ok := traits["grade"].(string); ok {
|
||||
norm := domain.NormalizeRole(g)
|
||||
if norm != g && norm == domain.RoleUser {
|
||||
traits["role"] = norm
|
||||
traits["grade"] = norm
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
_, err := kratosAdmin.UpdateIdentity(ctx, id.ID, traits, id.State)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update %s: %v", id.ID, err)
|
||||
} else {
|
||||
count++
|
||||
fmt.Printf("Updated %s\n", id.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("Total updated: %d\n", count)
|
||||
}
|
||||
@@ -542,3 +542,7 @@ func TestHeadlessPasswordLogin_E2E_AcceptsConfiguredPublicHTTPSAudience(t *testi
|
||||
t.Fatalf("did not expect audience mismatch log, got=%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *e2eMockKratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -567,6 +567,7 @@ func main() {
|
||||
|
||||
// Signup Routes
|
||||
signup := auth.Group("/signup")
|
||||
signup.Get("/tenants", authHandler.GetActiveTenants)
|
||||
signup.Post("/check-email", authHandler.CheckEmail)
|
||||
signup.Post("/check-login-id", authHandler.CheckLoginID)
|
||||
signup.Post("/send-email-code", authHandler.SendSignupEmailCode)
|
||||
@@ -609,6 +610,7 @@ func main() {
|
||||
// Tenant Management (Mixed roles, handler filters results)
|
||||
admin.Get("/tenants", requireAdmin, tenantHandler.ListTenants)
|
||||
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
||||
admin.Delete("/tenants/bulk", requireSuperAdmin, tenantHandler.DeleteTenantsBulk)
|
||||
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant)
|
||||
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
|
||||
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
||||
@@ -621,8 +623,10 @@ func main() {
|
||||
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
|
||||
|
||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||
org := admin.Group("/tenants/:tenantId/organization", requireAdmin)
|
||||
org.Post("/import", orgChartHandler.ImportCSV) // CSV Import API
|
||||
org := admin.Group("/tenants/:tenantId/organization")
|
||||
org.Post("/import", orgChartHandler.ImportOrgChart) // Org Chart Bulk Import API
|
||||
org.Get("/import/progress/:progressId", orgChartHandler.GetImportProgress) // Progress API
|
||||
|
||||
org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
|
||||
org.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
||||
org.Get("/:id", userGroupHandler.Get)
|
||||
|
||||
@@ -11,14 +11,18 @@ require (
|
||||
github.com/bwmarrin/snowflake v0.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/descope/go-sdk v1.7.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.3
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/gofiber/fiber/v2 v2.52.10
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
github.com/testcontainers/testcontainers-go v0.40.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
@@ -58,11 +62,11 @@ require (
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
@@ -96,30 +100,36 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM=
|
||||
@@ -58,6 +60,8 @@ github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNv
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -110,6 +114,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -130,8 +136,8 @@ github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -161,12 +167,16 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
@@ -199,13 +209,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
@@ -226,6 +239,8 @@ github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -239,6 +254,12 @@ github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7Fw
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
@@ -253,20 +274,30 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e h1:Ctm9yurWsg7aWwIpH9Bnap/IdSVxixymIb3MhiMEQQA=
|
||||
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -274,10 +305,10 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -298,15 +329,19 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
@@ -315,8 +350,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
@@ -332,3 +375,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
|
||||
@@ -85,6 +85,7 @@ type UserProfileResponse struct {
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Tenant *Tenant `json:"tenant,omitempty"`
|
||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
|
||||
@@ -21,13 +21,18 @@ const (
|
||||
func NormalizeRole(role string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(role))
|
||||
switch normalized {
|
||||
case "tenant_member":
|
||||
return RoleUser
|
||||
case "admin":
|
||||
// Legacy admin is treated as tenant admin for least-privilege compatibility.
|
||||
return RoleTenantAdmin
|
||||
default:
|
||||
case RoleSuperAdmin, RoleTenantAdmin, RoleRPAdmin, RoleUser:
|
||||
return normalized
|
||||
case "tenant_member", "member":
|
||||
return RoleUser
|
||||
case "admin", "tenantadmin", "tenant-admin":
|
||||
return RoleTenantAdmin
|
||||
case "superadmin", "super-admin":
|
||||
return RoleSuperAdmin
|
||||
default:
|
||||
// Default any other business title (팀장, 그룹장, etc.) to a regular user.
|
||||
// These should be mapped to JobTitle or Position instead.
|
||||
return RoleUser
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ func TestNormalizeRole(t *testing.T) {
|
||||
{name: "legacy admin", in: "admin", want: RoleTenantAdmin},
|
||||
{name: "legacy tenant member", in: "tenant_member", want: RoleUser},
|
||||
{name: "trim and lower", in: " ADMIN ", want: RoleTenantAdmin},
|
||||
{name: "unknown role pass-through", in: "custom_role", want: "custom_role"},
|
||||
{name: "empty", in: " ", want: ""},
|
||||
{name: "unknown role mapped to user", in: "custom_role", want: RoleUser},
|
||||
{name: "empty string mapped to user", in: " ", want: RoleUser},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
||||
@@ -406,6 +406,27 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"message": "Verification code sent"})
|
||||
}
|
||||
|
||||
var affiliateSlugs = map[string]bool{
|
||||
"hanmac": true,
|
||||
"saman": true,
|
||||
"ptc": true,
|
||||
"jangheon": true,
|
||||
"baron": true,
|
||||
"halla": true,
|
||||
}
|
||||
|
||||
func (h *AuthHandler) isAffiliateTenant(ctx context.Context, domainName string) (bool, *domain.Tenant) {
|
||||
if h.TenantService == nil {
|
||||
return false, nil
|
||||
}
|
||||
tenant, err := h.TenantService.GetTenantByDomain(ctx, domainName)
|
||||
if err != nil || tenant == nil {
|
||||
return false, nil
|
||||
}
|
||||
// [Strict] Check if the slug belongs to the predefined family company slugs
|
||||
return affiliateSlugs[strings.ToLower(tenant.Slug)], tenant
|
||||
}
|
||||
|
||||
// VerifySignupCode - Verifies the code for email or phone
|
||||
func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
|
||||
var req domain.VerifySignupCodeRequest
|
||||
@@ -459,10 +480,91 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
|
||||
state.Verified = true
|
||||
h.saveSignupState(key, state, signupStateExpiration)
|
||||
|
||||
return c.JSON(fiber.Map{"success": true})
|
||||
// [New] Check if this is a family affiliate domain to let frontend lock the choice
|
||||
isAffiliate := false
|
||||
parts := strings.Split(req.Target, "@")
|
||||
if req.Type == "email" && len(parts) == 2 {
|
||||
isAffiliate, _ = h.isAffiliateTenant(c.Context(), parts[1])
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"isAffiliate": isAffiliate,
|
||||
})
|
||||
}
|
||||
|
||||
// Signup - Finalize registration
|
||||
// GetActiveTenants - List active tenants ONLY if the email is verified in Redis
|
||||
func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
||||
if h.TenantService == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Tenant service unavailable")
|
||||
}
|
||||
|
||||
email := c.Query("email")
|
||||
if email == "" {
|
||||
// No email provided, return empty list (Security policy)
|
||||
return c.JSON([]interface{}{})
|
||||
}
|
||||
|
||||
// 1. Verify Verification Status in Redis
|
||||
emailKey := prefixSignupEmail + email
|
||||
state, _ := h.getSignupState(emailKey)
|
||||
if state == nil || !state.Verified {
|
||||
slog.Warn("[GetActiveTenants] Unverified access attempt", "email", email)
|
||||
return errorJSON(c, fiber.StatusForbidden, "Email verification is required before selecting an organization.")
|
||||
}
|
||||
|
||||
// 2. Extract domain from verified email
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 {
|
||||
return c.JSON([]interface{}{})
|
||||
}
|
||||
domainName := parts[1]
|
||||
|
||||
// [Policy] Verify if the email belongs to any family affiliate domain
|
||||
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
|
||||
if !isInternal {
|
||||
// If not an affiliate email, do not show any tenants
|
||||
return c.JSON([]interface{}{})
|
||||
}
|
||||
|
||||
// 3. List and Filter Tenants
|
||||
tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "")
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants")
|
||||
}
|
||||
|
||||
type tenantResp struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Type string `json:"type"`
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
|
||||
var results []tenantResp
|
||||
for _, t := range tenants {
|
||||
// [Strict] Only allow choosing defined family company slugs
|
||||
if t.Status != domain.TenantStatusActive || !affiliateSlugs[strings.ToLower(t.Slug)] {
|
||||
continue
|
||||
}
|
||||
|
||||
var domains []string
|
||||
for _, d := range t.Domains {
|
||||
domains = append(domains, d.Domain)
|
||||
}
|
||||
results = append(results, tenantResp{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Slug: t.Slug,
|
||||
Type: t.Type,
|
||||
Domains: domains,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(results)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
var req domain.SignupRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
@@ -504,33 +606,47 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Identity provider unavailable")
|
||||
}
|
||||
|
||||
// [Strict] Enforce Tenant Auto-Assignment
|
||||
// [New Policy] Enforce Explicit Tenant Assignment (No Auto-Provisioning)
|
||||
companyCode := ""
|
||||
var tenantID *string
|
||||
|
||||
parts := strings.Split(req.Email, "@")
|
||||
if len(parts) == 2 {
|
||||
domainName := parts[1]
|
||||
tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName)
|
||||
if err == nil && tenant != nil {
|
||||
if tenant.Status == domain.TenantStatusActive {
|
||||
slog.Info("[Signup] Auto-assigning tenant by domain", "email", req.Email, "tenant", tenant.Slug)
|
||||
companyCode = tenant.Slug
|
||||
tenantID = &tenant.ID
|
||||
} else {
|
||||
slog.Warn("[Signup] Attempted to join non-active tenant by domain", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status)
|
||||
return errorJSON(c, fiber.StatusForbidden, "Your organization's tenant is currently not active.")
|
||||
}
|
||||
}
|
||||
if len(parts) != 2 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "Invalid email format")
|
||||
}
|
||||
domainName := parts[1]
|
||||
|
||||
// Check if this domain belongs to a predefined family affiliate
|
||||
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
|
||||
|
||||
// [Strict Policy] Force AffiliationType based on predefined family slugs (User cannot choose)
|
||||
if isInternal {
|
||||
req.AffiliationType = "AFFILIATE"
|
||||
slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email)
|
||||
} else {
|
||||
req.AffiliationType = "GENERAL"
|
||||
slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email)
|
||||
}
|
||||
|
||||
// Fallback/Validation for manually provided CompanyCode if domain lookup didn't yield a tenant
|
||||
if tenantID == nil && req.CompanyCode != "" {
|
||||
// If user provided a CompanyCode, verify it exists and is a family affiliate
|
||||
if req.CompanyCode != "" {
|
||||
// [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode
|
||||
if !isInternal {
|
||||
slog.Warn("[Signup] Security violation: non-internal email providing CompanyCode", "email", req.Email)
|
||||
return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.")
|
||||
}
|
||||
|
||||
// Verify the selected company code exists and is indeed a family company
|
||||
if !affiliateSlugs[strings.ToLower(req.CompanyCode)] {
|
||||
return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.")
|
||||
}
|
||||
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode)
|
||||
if err == nil && tenant != nil {
|
||||
if tenant.Status == domain.TenantStatusActive {
|
||||
// Policy: Should we allow manual joining by Slug?
|
||||
// For now, let's allow it but log it as manual.
|
||||
// We no longer strictly cross-check if the chosen tenant owns the email domain.
|
||||
// Being an 'isInternal' (family) email is enough to join ANY family affiliate.
|
||||
|
||||
slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
|
||||
companyCode = tenant.Slug
|
||||
tenantID = &tenant.ID
|
||||
@@ -538,52 +654,18 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.")
|
||||
}
|
||||
} else {
|
||||
// If companyCode provided but not found, we automatically create one
|
||||
// [New Policy] 자동 생성 로직 추가
|
||||
slog.Info("[Signup] CompanyCode not found, creating new tenant automatically", "slug", req.CompanyCode)
|
||||
|
||||
// Determine name from CompanyCode
|
||||
tenantName := req.CompanyCode
|
||||
// Map slug to localized name if possible
|
||||
slugToName := map[string]string{
|
||||
"HANMAC": "한맥",
|
||||
"SAMAN": "삼안",
|
||||
"JANGHEON": "장헌",
|
||||
"HALLA": "한라",
|
||||
"PTC": "PTC",
|
||||
"BARON": "바론",
|
||||
}
|
||||
if name, ok := slugToName[strings.ToUpper(req.CompanyCode)]; ok {
|
||||
tenantName = name
|
||||
}
|
||||
|
||||
// Create the tenant
|
||||
// Note: creatorID is unknown at this point, will be set via Read-Model sync later
|
||||
newTenant, err := h.TenantService.RegisterTenant(c.Context(),
|
||||
tenantName,
|
||||
req.CompanyCode,
|
||||
domain.TenantTypeCompany,
|
||||
"Automatically created during signup",
|
||||
nil, // domains
|
||||
nil, // parentID
|
||||
"", // creatorID (will sync later)
|
||||
)
|
||||
if err != nil {
|
||||
// Handle race condition: if tenant was created by another request just now
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
newTenant, err = h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode)
|
||||
}
|
||||
|
||||
if err != nil || newTenant == nil {
|
||||
slog.Error("[Signup] Failed to create tenant automatically", "slug", req.CompanyCode, "error", err)
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to initialize organization.")
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("[Signup] Successfully created missing tenant", "slug", req.CompanyCode, "id", newTenant.ID)
|
||||
tenantID = &newTenant.ID
|
||||
companyCode = newTenant.Slug
|
||||
slog.Warn("[Signup] Attempted to join non-existent organization", "slug", req.CompanyCode, "email", req.Email)
|
||||
return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
|
||||
}
|
||||
} else {
|
||||
// If it's a family affiliate domain, they MUST select one of the family companies
|
||||
if isInternal {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.")
|
||||
}
|
||||
}
|
||||
|
||||
if tenantID == nil && req.AffiliationType == "AFFILIATE" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.")
|
||||
}
|
||||
|
||||
// Normalize Phone (E.164 형태로 보관)
|
||||
@@ -5283,11 +5365,18 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
}
|
||||
}
|
||||
|
||||
// [New] Fetch manageable tenants for Tenant Admin
|
||||
if profile.Role == domain.RoleTenantAdmin && h.TenantService != nil {
|
||||
manageable, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID)
|
||||
// [New] Fetch manageable and joined tenants
|
||||
if h.TenantService != nil {
|
||||
if profile.Role == domain.RoleTenantAdmin {
|
||||
manageable, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID)
|
||||
if err == nil {
|
||||
profile.ManageableTenants = manageable
|
||||
}
|
||||
}
|
||||
|
||||
joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID)
|
||||
if err == nil {
|
||||
profile.ManageableTenants = manageable
|
||||
profile.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,10 @@ func (m *AsyncMockTenantService) IsDomainAllowed(ctx context.Context, domainName
|
||||
return false, nil
|
||||
}
|
||||
func (m *AsyncMockTenantService) ApproveTenant(ctx context.Context, id string) error { return nil }
|
||||
func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {}
|
||||
func (m *AsyncMockTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {}
|
||||
func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -271,7 +274,9 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) {
|
||||
mockRedis.On("Delete", phoneKey).Return(nil)
|
||||
|
||||
// Tenant Mocks
|
||||
mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(nil, errors.New("not found"))
|
||||
validTenant := &domain.Tenant{ID: "t1", Slug: "example", Status: domain.TenantStatusActive}
|
||||
mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(validTenant, nil)
|
||||
mockTenant.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil)
|
||||
|
||||
// Kratos Mocks (Success)
|
||||
mockIdp.On("CreateUser", mock.Anything, "Password123!").Return("new-user-uuid", nil)
|
||||
@@ -321,3 +326,16 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) {
|
||||
mockUserRepo.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
|
||||
args := m.Called(ctx, tenantIDs)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
@@ -1824,3 +1824,7 @@ func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) {
|
||||
t.Fatalf("expected error=Invalid credentials, got=%v", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -98,7 +99,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
|
||||
})
|
||||
mockRedis.On("Get", mock.Anything).Return(string(verifiedState), nil)
|
||||
|
||||
t.Run("Create Tenant if CompanyCode Missing", func(t *testing.T) {
|
||||
t.Run("Fail - Tenant not found for CompanyCode", func(t *testing.T) {
|
||||
reqBody := domain.SignupRequest{
|
||||
Email: "user@gmail.com",
|
||||
Password: "StrongPass123!",
|
||||
@@ -109,38 +110,34 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
newTenant := &domain.Tenant{ID: "t_new", Slug: "new-slug", Status: domain.TenantStatusActive}
|
||||
|
||||
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil)
|
||||
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil)
|
||||
mockTenantSvc.On("RegisterTenant", mock.Anything, "new-slug", "new-slug", domain.TenantTypeCompany, mock.Anything, mock.Anything, mock.Anything, "").Return(newTenant, nil)
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, "t_new").Return(newTenant, nil)
|
||||
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil)
|
||||
mockRedis.On("Delete", mock.Anything).Return(nil)
|
||||
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil).Once()
|
||||
mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "gmail.com").Return(nil, errors.New("not found")).Maybe()
|
||||
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Active Company Code", func(t *testing.T) {
|
||||
reqBody := domain.SignupRequest{
|
||||
Email: "user@gmail.com",
|
||||
Email: "user@hanmaceng.co.kr",
|
||||
Password: "StrongPass123!",
|
||||
Name: "Test User",
|
||||
Phone: "010-1234-5678",
|
||||
TermsAccepted: true,
|
||||
CompanyCode: "valid-slug",
|
||||
CompanyCode: "hanmac",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
validTenant := &domain.Tenant{ID: "t1", Slug: "valid-slug", Status: domain.TenantStatusActive}
|
||||
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil)
|
||||
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "valid-slug").Return(validTenant, nil)
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil)
|
||||
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil)
|
||||
validTenant := &domain.Tenant{ID: "t1", Slug: "hanmac", Status: domain.TenantStatusActive}
|
||||
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(&domain.Tenant{Slug: "hanmac"}, nil).Once()
|
||||
mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(validTenant, nil).Maybe()
|
||||
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac").Return(validTenant, nil).Once()
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil).Once()
|
||||
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once()
|
||||
mockRedis.On("Delete", mock.Anything).Return(nil)
|
||||
|
||||
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
|
||||
|
||||
@@ -15,7 +15,7 @@ func NewOrgChartHandler(s service.OrgChartService) *OrgChartHandler {
|
||||
return &OrgChartHandler{Service: s}
|
||||
}
|
||||
|
||||
func (h *OrgChartHandler) ImportCSV(c *fiber.Ctx) error {
|
||||
func (h *OrgChartHandler) ImportOrgChart(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("tenantId")
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"})
|
||||
@@ -32,10 +32,30 @@ func (h *OrgChartHandler) ImportCSV(c *fiber.Ctx) error {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := h.Service.ImportCSV(c.Context(), tenantID, f); err != nil {
|
||||
slog.Error("Failed to import CSV", "error", err, "tenantID", tenantID)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
progressID := c.Query("progressId")
|
||||
result, err := h.Service.ImportOrgChart(c.Context(), tenantID, f, file.Filename, progressID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to import org chart", "error", err, "tenantID", tenantID, "filename", file.Filename)
|
||||
// If we have a result even with error, return it
|
||||
if result != nil {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"message": "Import completed with errors",
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "Import completed successfully"})
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Import completed",
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OrgChartHandler) GetImportProgress(c *fiber.Ctx) error {
|
||||
pid := c.Params("progressId")
|
||||
if val, ok := service.ImportProgressCache.Load(pid); ok {
|
||||
return c.JSON(val)
|
||||
}
|
||||
return c.JSON(fiber.Map{"current": 0, "total": 0})
|
||||
}
|
||||
|
||||
@@ -739,6 +739,36 @@ func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
|
||||
}
|
||||
|
||||
// Permission check: Super Admin can delete anything.
|
||||
// Tenant Admin should theoretically only delete manageable sub-tenants,
|
||||
// but currently bulk delete is intended for Super Admin.
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "only super admin can perform bulk deletion")
|
||||
}
|
||||
|
||||
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"message": "Tenants deleted successfully",
|
||||
"count": len(req.IDs),
|
||||
})
|
||||
}
|
||||
|
||||
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
||||
domains := make([]string, 0, len(t.Domains))
|
||||
for _, d := range t.Domains {
|
||||
|
||||
@@ -88,6 +88,14 @@ func (m *MockTenantService) SetKetoService(keto service.KetoService) {
|
||||
m.Called(keto)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, domainName)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
type MockUserRepoForHandler struct {
|
||||
mock.Mock
|
||||
}
|
||||
@@ -230,3 +238,15 @@ func TestTenantHandler_ApproveTenant(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
|
||||
args := m.Called(ctx, tenantIDs)
|
||||
return args.Error(0)
|
||||
}
|
||||
func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
@@ -105,11 +105,23 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
if profile != nil {
|
||||
for _, t := range profile.ManageableTenants {
|
||||
manageableSlugs[strings.ToLower(t.Slug)] = true
|
||||
manageableSlugs[strings.ToLower(t.ID)] = true // Add ID as well
|
||||
}
|
||||
// Include primary tenant slug if not already there
|
||||
if profile.CompanyCode != "" {
|
||||
manageableSlugs[strings.ToLower(profile.CompanyCode)] = true
|
||||
}
|
||||
if profile.TenantID != nil {
|
||||
manageableSlugs[strings.ToLower(*profile.TenantID)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var targetTenantID string
|
||||
if tenantSlug != "" && h.TenantService != nil {
|
||||
t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
||||
if err == nil && t != nil {
|
||||
targetTenantID = strings.ToLower(t.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,16 +135,17 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
||||
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||
compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
||||
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
||||
|
||||
// Tenant Admin filtering
|
||||
if requesterRole == domain.RoleTenantAdmin {
|
||||
if !manageableSlugs[compCode] {
|
||||
if !manageableSlugs[compCode] && !manageableSlugs[tID] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated tenantSlug filter
|
||||
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) {
|
||||
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) && tID != targetTenantID {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -261,15 +274,17 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
Department string `json:"department"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
@@ -321,6 +336,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
|
||||
attributes := map[string]interface{}{
|
||||
"department": req.Department,
|
||||
"position": req.Position,
|
||||
"jobTitle": req.JobTitle,
|
||||
"affiliationType": "internal",
|
||||
"companyCode": req.CompanyCode,
|
||||
"grade": role,
|
||||
@@ -454,6 +471,8 @@ type bulkUserItem struct {
|
||||
Role string `json:"role"`
|
||||
TenantSlug string `json:"tenantSlug"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
@@ -570,6 +589,8 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
|
||||
attributes := map[string]interface{}{
|
||||
"department": dept,
|
||||
"position": strings.TrimSpace(item.Position),
|
||||
"jobTitle": strings.TrimSpace(item.JobTitle),
|
||||
"affiliationType": "internal",
|
||||
"companyCode": tenantSlug,
|
||||
"tenant_id": tItem.ID,
|
||||
@@ -845,6 +866,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
Role *string `json:"role"`
|
||||
CompanyCode *string `json:"companyCode"`
|
||||
Department *string `json:"department"`
|
||||
Position *string `json:"position"`
|
||||
JobTitle *string `json:"jobTitle"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
@@ -879,6 +902,16 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
results := make([]map[string]any, 0, len(req.UserIDs))
|
||||
|
||||
for _, id := range req.UserIDs {
|
||||
// [Safety] Cannot delete yourself
|
||||
if id == requester.ID {
|
||||
results = append(results, map[string]any{
|
||||
"id": id,
|
||||
"success": false,
|
||||
"message": "cannot delete your own account for safety",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), id)
|
||||
if err != nil {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"})
|
||||
@@ -924,6 +957,12 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
if req.Department != nil {
|
||||
traits["department"] = *req.Department
|
||||
}
|
||||
if req.Position != nil {
|
||||
traits["position"] = *req.Position
|
||||
}
|
||||
if req.JobTitle != nil {
|
||||
traits["jobTitle"] = *req.JobTitle
|
||||
}
|
||||
|
||||
state := identity.State
|
||||
if req.Status != nil {
|
||||
@@ -958,6 +997,12 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
if req.Department != nil {
|
||||
localUser.Department = *req.Department
|
||||
}
|
||||
if req.Position != nil {
|
||||
localUser.Position = *req.Position
|
||||
}
|
||||
if req.JobTitle != nil {
|
||||
localUser.JobTitle = *req.JobTitle
|
||||
}
|
||||
|
||||
// Resolve TenantID if changing companyCode
|
||||
if req.CompanyCode != nil && h.TenantService != nil {
|
||||
@@ -1011,6 +1056,16 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
|
||||
results := make([]map[string]any, 0, len(req.UserIDs))
|
||||
|
||||
for _, id := range req.UserIDs {
|
||||
// [Safety] Cannot delete yourself
|
||||
if id == requester.ID {
|
||||
results = append(results, map[string]any{
|
||||
"id": id,
|
||||
"success": false,
|
||||
"message": "cannot delete your own account for safety",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), id)
|
||||
if err != nil {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"})
|
||||
@@ -1093,6 +1148,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
Status *string `json:"status"`
|
||||
CompanyCode *string `json:"companyCode"`
|
||||
Department *string `json:"department"`
|
||||
Position *string `json:"position"`
|
||||
JobTitle *string `json:"jobTitle"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
@@ -1176,6 +1233,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if req.Department != nil {
|
||||
traits["department"] = strings.TrimSpace(*req.Department)
|
||||
}
|
||||
if req.Position != nil {
|
||||
traits["position"] = strings.TrimSpace(*req.Position)
|
||||
}
|
||||
if req.JobTitle != nil {
|
||||
traits["jobTitle"] = strings.TrimSpace(*req.JobTitle)
|
||||
}
|
||||
if req.Role != nil {
|
||||
role := domain.NormalizeRole(*req.Role)
|
||||
if role == "" {
|
||||
@@ -1189,6 +1252,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "department": true,
|
||||
"position": true, "jobTitle": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true,
|
||||
"custom_login_ids": true, "id": true,
|
||||
}
|
||||
@@ -1339,6 +1403,12 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
|
||||
// [New] Check access scope before deletion
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
|
||||
// [Safety] Cannot delete yourself
|
||||
if requester != nil && userID == requester.ID {
|
||||
return errorJSON(c, fiber.StatusForbidden, "cannot delete your own account for safety")
|
||||
}
|
||||
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err == nil && identity != nil {
|
||||
@@ -1407,14 +1477,16 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
Status: normalizeStatus(identity.State),
|
||||
CompanyCode: compCode,
|
||||
Department: extractTraitString(traits, "department"),
|
||||
Position: extractTraitString(traits, "position"),
|
||||
JobTitle: extractTraitString(traits, "jobTitle"),
|
||||
Metadata: make(domain.JSONMap),
|
||||
CreatedAt: formatTime(identity.CreatedAt),
|
||||
UpdatedAt: formatTime(identity.UpdatedAt),
|
||||
}
|
||||
|
||||
// [New] Fetch all manageable tenants (for Multi-tenancy support)
|
||||
// [New] Fetch all joined tenants (for Multi-tenancy support)
|
||||
if h.TenantService != nil {
|
||||
if joined, err := h.TenantService.ListManageableTenants(ctx, identity.ID); err == nil {
|
||||
if joined, err := h.TenantService.ListJoinedTenants(ctx, identity.ID); err == nil {
|
||||
summary.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
@@ -1426,6 +1498,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "department": true,
|
||||
"position": true, "jobTitle": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true,
|
||||
"custom_login_ids": true, "id": true,
|
||||
}
|
||||
@@ -1476,6 +1549,8 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
||||
Status: normalizeStatus(identity.State),
|
||||
CompanyCode: compCode,
|
||||
Department: extractTraitString(traits, "department"),
|
||||
Position: extractTraitString(traits, "position"),
|
||||
JobTitle: extractTraitString(traits, "jobTitle"),
|
||||
AffiliationType: extractTraitString(traits, "affiliationType"),
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
@@ -1501,6 +1576,7 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "department": true,
|
||||
"position": true, "jobTitle": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
|
||||
"custom_login_ids": true, "id": true,
|
||||
}
|
||||
|
||||
@@ -123,6 +123,14 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, domainName)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
@@ -758,3 +766,11 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
||||
mockOry.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForUser) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -108,3 +108,15 @@ func TestTenantContextMiddleware(t *testing.T) {
|
||||
mockSvc.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) ProvisionTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -18,6 +19,8 @@ type TenantRepository interface {
|
||||
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
|
||||
AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error
|
||||
List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
|
||||
ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error)
|
||||
DeleteBulk(ctx context.Context, ids []string) error
|
||||
}
|
||||
|
||||
type tenantRepository struct {
|
||||
@@ -112,3 +115,38 @@ func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID
|
||||
|
||||
return tenants, total, nil
|
||||
}
|
||||
|
||||
func (r *tenantRepository) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) {
|
||||
var tenants []domain.Tenant
|
||||
if err := r.db.WithContext(ctx).Where("type = ?", tenantType).Preload("Domains").Find(&tenants).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenants, nil
|
||||
}
|
||||
|
||||
func (r *tenantRepository) DeleteBulk(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Release slugs for all target tenants to allow reuse
|
||||
suffix := "-deleted-" + time.Now().Format("20060102150405")
|
||||
if err := tx.Model(&domain.Tenant{}).Where("id IN ?", ids).
|
||||
Update("slug", gorm.Expr("slug || ?", suffix)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Soft delete tenants
|
||||
if err := tx.Where("id IN ?", ids).Delete(&domain.Tenant{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Also delete related UserGroups if any (Type USER_GROUP tenants have records in user_groups table)
|
||||
if err := tx.Where("id IN ?", ids).Delete(&domain.UserGroup{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,11 +42,30 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
|
||||
// Use Upsert logic: if email exists, update all fields
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "email"}},
|
||||
UpdateAll: true,
|
||||
}).Save(user).Error
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Resolve email conflicts: If another user in the local DB has this email but a different ID,
|
||||
// we must remove the old local record because Kratos is the source of truth for ID <-> Email mapping.
|
||||
var existing domain.User
|
||||
if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil {
|
||||
if existing.ID != user.ID {
|
||||
// Delete associated login IDs first to prevent FK constraint violation
|
||||
if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Different ID holds this email locally. Hard delete the old record to avoid constraint violation.
|
||||
if err := tx.Unscoped().Delete(&domain.User{}, "id = ?", existing.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Perform Upsert based on ID.
|
||||
// In GORM v2, true upsert requires Create() with OnConflict on the primary key.
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
}).Create(user).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
@@ -175,13 +194,14 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||
|
||||
if companyCode != "" {
|
||||
db = db.Where("company_code = ?", companyCode)
|
||||
// [Matrix Fix] Match users either by their primary company code OR by the slug of the department they are attached to
|
||||
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
|
||||
Where("users.company_code = ? OR tenants.slug = ?", companyCode, companyCode)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
// Search in basic fields and metadata (PostgreSQL JSONB)
|
||||
db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ? OR metadata::text LIKE ?)",
|
||||
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR users.metadata::text LIKE ?)",
|
||||
searchTerm, searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
@@ -189,7 +209,7 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := db.Offset(offset).Limit(limit).Find(&users).Error; err != nil {
|
||||
if err := db.Offset(offset).Limit(limit).Preload("Tenant").Find(&users).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -49,6 +50,7 @@ type KratosAdminService interface {
|
||||
UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error)
|
||||
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
|
||||
DeleteIdentity(ctx context.Context, identityID string) error
|
||||
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
|
||||
ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error)
|
||||
GetSession(ctx context.Context, sessionID string) (*KratosSession, error)
|
||||
DeleteSession(ctx context.Context, sessionID string) error
|
||||
@@ -257,83 +259,65 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s/sessions", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
if user == nil {
|
||||
return "", fmt.Errorf("kratos admin: user payload is nil")
|
||||
}
|
||||
|
||||
traits := map[string]interface{}{
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
}
|
||||
if user.PhoneNumber != "" {
|
||||
traits["phone_number"] = user.PhoneNumber
|
||||
}
|
||||
for k, v := range user.Attributes {
|
||||
if k == "id" || k == "email" {
|
||||
continue
|
||||
}
|
||||
traits[k] = v
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"config": map[string]string{
|
||||
"password": password,
|
||||
},
|
||||
},
|
||||
},
|
||||
"state": "active",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("kratos admin list identity sessions failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", fmt.Errorf("kratos admin create identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var sessions []KratosSession
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil {
|
||||
return nil, err
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
|
||||
endpoint := fmt.Sprintf("%s/admin/sessions/%s", strings.TrimRight(s.AdminURL, "/"), sessionID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("kratos admin get session failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var session KratosSession
|
||||
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
endpoint := fmt.Sprintf("%s/admin/sessions/%s", strings.TrimRight(s.AdminURL, "/"), sessionID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("kratos admin delete session failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func hashPasswordForKratosAdmin(password string) (string, error) {
|
||||
@@ -386,3 +370,88 @@ func getenvKratos(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
|
||||
url := fmt.Sprintf("%s/admin/identities/%s/sessions", s.AdminURL, identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := s.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return []KratosSession{}, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var sessions []KratosSession
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
|
||||
url := fmt.Sprintf("%s/admin/sessions/%s", s.AdminURL, sessionID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := s.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var session KratosSession
|
||||
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
url := fmt.Sprintf("%s/admin/sessions/%s", s.AdminURL, sessionID)
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := s.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identity
|
||||
}
|
||||
return args.Get(0).(*KratosIdentity), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
||||
args := m.Called(ctx, identityID, traits, state)
|
||||
if args.Get(0) == nil {
|
||||
@@ -104,29 +103,26 @@ func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, ident
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
return m.Called(ctx, identityID, newPassword).Error(0)
|
||||
args := m.Called(ctx, identityID, newPassword)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
return m.Called(ctx, identityID).Error(0)
|
||||
args := m.Called(ctx, identityID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
args := m.Called(ctx, user, password)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
|
||||
args := m.Called(ctx, identityID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]KratosSession), args.Error(1)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
|
||||
args := m.Called(ctx, sessionID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*KratosSession), args.Error(1)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return m.Called(ctx, sessionID).Error(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,18 +3,43 @@ package service
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
var whitespaceRegex = regexp.MustCompile(`\s+`)
|
||||
var nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`)
|
||||
|
||||
type ProgressData struct {
|
||||
Current int `json:"current"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
var ImportProgressCache sync.Map
|
||||
|
||||
type ImportResult struct {
|
||||
TotalRows int `json:"totalRows"`
|
||||
Processed int `json:"processed"`
|
||||
UserCreated int `json:"userCreated"`
|
||||
UserUpdated int `json:"userUpdated"`
|
||||
TenantCreated int `json:"tenantCreated"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
type OrgChartService interface {
|
||||
ImportCSV(ctx context.Context, tenantID string, r io.Reader) error
|
||||
ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error)
|
||||
}
|
||||
|
||||
type orgChartService struct {
|
||||
@@ -41,227 +66,442 @@ func NewOrgChartService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.Reader) error {
|
||||
reader := csv.NewReader(r)
|
||||
header, err := reader.Read()
|
||||
func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error) {
|
||||
result := &ImportResult{Errors: make([]string, 0)}
|
||||
var allSheetsRecords [][][]string
|
||||
var err error
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(filename), ".xlsx") {
|
||||
allSheetsRecords, err = s.readAllXLSXSheets(r)
|
||||
} else {
|
||||
csvRecords, csvErr := s.readCSV(r)
|
||||
if csvErr == nil {
|
||||
allSheetsRecords = [][][]string{csvRecords}
|
||||
}
|
||||
err = csvErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CSV header: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Map header columns
|
||||
colMap := make(map[string]int)
|
||||
for i, name := range header {
|
||||
colMap[strings.ToLower(strings.TrimSpace(name))] = i
|
||||
fieldMapping := map[string][]string{
|
||||
"email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"},
|
||||
"name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"},
|
||||
"position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"},
|
||||
"jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"},
|
||||
"phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"},
|
||||
"company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"},
|
||||
"is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"},
|
||||
}
|
||||
|
||||
// Required columns
|
||||
required := []string{"email", "name", "organization", "position", "jobtitle"}
|
||||
for _, req := range required {
|
||||
if _, ok := colMap[req]; !ok {
|
||||
return fmt.Errorf("missing required column: %s", req)
|
||||
var dataRows [][]string
|
||||
actualMap := make(map[string]int)
|
||||
found := false
|
||||
var headerMap map[string]int
|
||||
|
||||
for sheetIdx, records := range allSheetsRecords {
|
||||
for i, row := range records {
|
||||
if len(row) < 2 { continue }
|
||||
tempMap := make(map[string]int)
|
||||
for j, cell := range row {
|
||||
clean := s.cleanHeader(cell)
|
||||
if clean != "" { tempMap[clean] = j }
|
||||
}
|
||||
emailIdx := s.findBestMatch(tempMap, fieldMapping["email"])
|
||||
nameIdx := s.findBestMatch(tempMap, fieldMapping["name"])
|
||||
if nameIdx != -1 && emailIdx == -1 {
|
||||
for j, cell := range row {
|
||||
c := s.cleanHeader(cell)
|
||||
if strings.Contains(c, "mail") || strings.Contains(c, "계정") || strings.Contains(c, "id") || strings.Contains(c, "사번") {
|
||||
emailIdx = j; break
|
||||
}
|
||||
}
|
||||
}
|
||||
if emailIdx != -1 && nameIdx != -1 {
|
||||
dataRows = records[i+1:]
|
||||
headerMap = tempMap
|
||||
for key, aliases := range fieldMapping {
|
||||
actualMap[key] = s.findBestMatch(tempMap, aliases)
|
||||
}
|
||||
if actualMap["email"] == -1 { actualMap["email"] = emailIdx }
|
||||
found = true
|
||||
slog.Info("Found header row", "sheet", sheetIdx, "row", i)
|
||||
break
|
||||
}
|
||||
}
|
||||
if found { break }
|
||||
}
|
||||
|
||||
if !found {
|
||||
return nil, fmt.Errorf("required columns (email/name) not found. please check your headers")
|
||||
}
|
||||
|
||||
// [MH-OrgChart-Standalone Architecture]
|
||||
// Hierarchy is explicitly ordered: 부서(part) -> 그룹(gr) -> 디비전(div) -> 팀(team) -> 셀(cell)
|
||||
hierarchyLevels := [][]string{
|
||||
{"department", "organization", "부서", "조직", "부서명", "조직명", "소속부서", "part", "파트", "본부", "실", "국"},
|
||||
{"gr", "grp", "group", "그룹"},
|
||||
{"div", "division", "디비젼", "디비전"},
|
||||
{"team", "팀", "teal", "팀명"},
|
||||
{"cell", "셀"},
|
||||
}
|
||||
|
||||
hierarchyIdx := make([]int, 0)
|
||||
for _, aliases := range hierarchyLevels {
|
||||
idx := s.findBestMatch(headerMap, aliases)
|
||||
hierarchyIdx = append(hierarchyIdx, idx) // Keep order, -1 means not found
|
||||
}
|
||||
|
||||
// Cache for created/found organization units to handle hierarchy efficiently
|
||||
// key: path (e.g. "HQ/Sales"), value: ID
|
||||
pathCache := make(map[string]string)
|
||||
result.TotalRows = len(dataRows)
|
||||
|
||||
for {
|
||||
record, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
if progressID != "" {
|
||||
ImportProgressCache.Store(progressID, ProgressData{Current: 0, Total: result.TotalRows})
|
||||
defer ImportProgressCache.Delete(progressID)
|
||||
}
|
||||
|
||||
if tenantID == "root" || tenantID == "" {
|
||||
t, _ := s.tenantRepo.FindBySlug(ctx, "root-group")
|
||||
if t == nil {
|
||||
tenantID = uuid.NewString()
|
||||
_ = s.tenantRepo.Create(ctx, &domain.Tenant{ID: tenantID, Name: "Root Group", Slug: "root-group", Type: domain.TenantTypeCompanyGroup, Status: domain.TenantStatusActive})
|
||||
result.TenantCreated++
|
||||
} else {
|
||||
tenantID = t.ID
|
||||
}
|
||||
}
|
||||
|
||||
for rowIdx, record := range dataRows {
|
||||
if len(record) == 0 { continue }
|
||||
email := s.getVal(record, actualMap["email"])
|
||||
name := s.getVal(record, actualMap["name"])
|
||||
if email == "" || name == "" { continue }
|
||||
|
||||
position := s.getVal(record, actualMap["position"])
|
||||
jobTitle := s.getVal(record, actualMap["jobtitle"])
|
||||
phone := s.normalizePhone(s.getVal(record, actualMap["phone"]))
|
||||
|
||||
companyName := s.getVal(record, actualMap["company"])
|
||||
if companyName == "" { companyName = "Main" }
|
||||
companySlug := s.generateCompanySlug(companyName)
|
||||
|
||||
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read CSV record", "error", err)
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err))
|
||||
continue
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(record[colMap["email"]])
|
||||
name := strings.TrimSpace(record[colMap["name"]])
|
||||
orgPath := strings.TrimSpace(record[colMap["organization"]])
|
||||
position := strings.TrimSpace(record[colMap["position"]])
|
||||
jobTitle := strings.TrimSpace(record[colMap["jobtitle"]])
|
||||
isOwner := false
|
||||
if idx, ok := colMap["is_owner"]; ok && idx < len(record) {
|
||||
val := strings.ToLower(record[idx])
|
||||
isOwner = val == "true" || val == "y" || val == "1" || val == "yes"
|
||||
// Build orgPath following the strict order: 부서 -> 그룹 -> 디비전 -> 팀 -> 셀
|
||||
var orgParts []string
|
||||
for _, idx := range hierarchyIdx {
|
||||
val := s.getVal(record, idx)
|
||||
if val != "" && val != "-" {
|
||||
orgParts = append(orgParts, val)
|
||||
}
|
||||
}
|
||||
orgPath := strings.Join(orgParts, " > ")
|
||||
|
||||
if email == "" || name == "" || orgPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Process Organization Hierarchy
|
||||
leafID, err := s.ensureOrgPath(ctx, tenantID, orgPath, pathCache)
|
||||
if err != nil {
|
||||
slog.Error("Failed to ensure org path", "path", orgPath, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Upsert User
|
||||
// Check if user exists in Kratos first (SoT)
|
||||
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
|
||||
if err != nil || kratosID == "" {
|
||||
slog.Warn("User not found in Kratos, skipping import for now. Users must be registered in Kratos first.", "email", email)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update User in Local DB (Read-Model)
|
||||
user, err := s.userRepo.FindByID(ctx, kratosID)
|
||||
if err != nil {
|
||||
// If not in local DB, create it
|
||||
user = &domain.User{
|
||||
ID: kratosID,
|
||||
Email: email,
|
||||
leafID := companyTenantID
|
||||
if len(orgParts) > 0 {
|
||||
// [Matrix Fix] Build hierarchy under the Root Tenant (tenantID), NOT the individual company.
|
||||
// This allows departments like '총괄기획실' to be shared across multiple companies without duplication.
|
||||
leafID, err = s.ensureOrgPath(ctx, tenantID, orgParts, pathCache, result)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Hierarchy fail: %v", rowIdx+2, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.Position = position
|
||||
user.JobTitle = jobTitle
|
||||
user.Department = orgPath
|
||||
user.TenantID = &tenantID
|
||||
user.Status = "active"
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
slog.Error("Failed to update user in local DB", "userID", kratosID, "error", err)
|
||||
continue
|
||||
isOwner := false
|
||||
grade := "member"
|
||||
if idx := actualMap["is_owner"]; idx != -1 && idx < len(record) {
|
||||
grade = strings.TrimSpace(record[idx])
|
||||
isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" ||
|
||||
strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") ||
|
||||
strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장")
|
||||
}
|
||||
|
||||
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
|
||||
if (err != nil || kratosID == "") && phone != "" {
|
||||
kratosID, _ = s.kratos.FindIdentityIDByIdentifier(ctx, phone)
|
||||
}
|
||||
|
||||
if kratosID == "" {
|
||||
brokerUser := &domain.BrokerUser{
|
||||
Email: email, Name: name, PhoneNumber: phone,
|
||||
Attributes: map[string]interface{}{
|
||||
"affiliationType": "AFFILIATE", "companyCode": companySlug,
|
||||
"department": orgPath, "grade": grade, "position": position,
|
||||
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
|
||||
},
|
||||
}
|
||||
kratosID, err = s.kratos.CreateUser(ctx, brokerUser, "baron1234!@#")
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: User creation failed: %v", rowIdx+2, err))
|
||||
continue
|
||||
}
|
||||
result.UserCreated++
|
||||
} else {
|
||||
traits := map[string]interface{}{
|
||||
"name": name, "companyCode": companySlug, "department": orgPath,
|
||||
"grade": grade, "position": position, "affiliationType": "AFFILIATE",
|
||||
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
|
||||
}
|
||||
if phone != "" {
|
||||
traits["phone_number"] = phone
|
||||
}
|
||||
_, _ = s.kratos.UpdateIdentity(ctx, kratosID, traits, "active")
|
||||
result.UserUpdated++
|
||||
}
|
||||
|
||||
err = s.userRepo.Update(ctx, &domain.User{
|
||||
ID: kratosID, Email: email, Name: name, Phone: phone, Position: position,
|
||||
JobTitle: jobTitle, Department: orgPath,
|
||||
TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity
|
||||
CompanyCode: companySlug, AffiliationType: "AFFILIATE", Status: "active", UpdatedAt: time.Now(), Role: domain.RoleUser,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Row update failed", "row", rowIdx+2, "email", email, "error", err)
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: DB Update fail: %v", rowIdx+2, err))
|
||||
}
|
||||
|
||||
// 3. Sync Membership to Keto via Outbox
|
||||
if s.ketoOutboxRepo != nil {
|
||||
// Add as member of UserGroup (which is a Tenant namespace object)
|
||||
// 1. [Redundant Assignment] Always assign to the Legal Company Tenant
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Namespace: "Tenant",
|
||||
Object: companyTenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
|
||||
// [New] Also add as member of the root Tenant (for tenant-level member count)
|
||||
if leafID != tenantID {
|
||||
// 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists)
|
||||
if leafID != companyTenantID {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
// Add as owner if applicable
|
||||
|
||||
// 3. Assign ownership if leader
|
||||
if isOwner {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "owners",
|
||||
Subject: "User:" + kratosID,
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "owners",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
result.Processed++
|
||||
if progressID != "" && (result.Processed%5 == 0 || result.Processed == result.TotalRows) {
|
||||
ImportProgressCache.Store(progressID, ProgressData{Current: result.Processed, Total: result.TotalRows})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, path string, cache map[string]string) (string, error) {
|
||||
parts := strings.Split(path, "/")
|
||||
func (s *orgChartService) cleanHeader(val string) string {
|
||||
clean := strings.ToLower(whitespaceRegex.ReplaceAllString(val, ""))
|
||||
clean = nonAlphaNumRegex.ReplaceAllString(clean, "")
|
||||
return strings.TrimPrefix(clean, "\ufeff")
|
||||
}
|
||||
|
||||
func (s *orgChartService) findBestMatch(tempMap map[string]int, aliases []string) int {
|
||||
for _, alias := range aliases {
|
||||
ca := s.cleanHeader(alias)
|
||||
if idx, ok := tempMap[ca]; ok { return idx }
|
||||
}
|
||||
for cleaned, idx := range tempMap {
|
||||
for _, alias := range aliases {
|
||||
ca := s.cleanHeader(alias)
|
||||
if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) { return idx }
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (s *orgChartService) getVal(record []string, idx int) string {
|
||||
if idx == -1 || idx >= len(record) { return "" }
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
|
||||
func (s *orgChartService) normalizePhone(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
|
||||
re := regexp.MustCompile(`[^0-9+]`)
|
||||
normalized = re.ReplaceAllString(normalized, "")
|
||||
|
||||
if len(normalized) < 8 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
if !strings.HasPrefix(normalized, "+") && len(normalized) >= 9 {
|
||||
if strings.HasPrefix(normalized, "0") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
return "+82" + normalized
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil { return nil, err }
|
||||
reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))))
|
||||
reader.LazyQuotes = true
|
||||
reader.FieldsPerRecord = -1
|
||||
return reader.ReadAll()
|
||||
}
|
||||
|
||||
func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
|
||||
f, err := excelize.OpenReader(r)
|
||||
if err != nil { return nil, err }
|
||||
defer f.Close()
|
||||
var allRecords [][][]string
|
||||
for _, sheet := range f.GetSheetList() {
|
||||
if rows, err := f.GetRows(sheet); err == nil { allRecords = append(allRecords, rows) }
|
||||
}
|
||||
return allRecords, nil
|
||||
}
|
||||
|
||||
func (s *orgChartService) generateCompanySlug(name string) string {
|
||||
n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, ""))
|
||||
slugs := map[string]string{
|
||||
"한맥": "hanmac", "삼안": "saman", "장헌": "jangheon",
|
||||
"ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla",
|
||||
}
|
||||
for k, v := range slugs {
|
||||
if strings.Contains(n, k) || strings.Contains(n, v) { return v }
|
||||
}
|
||||
return utils.GenerateSlug(name)
|
||||
}
|
||||
|
||||
func isAlphaNumeric(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name, slug, email string, cache map[string]string, res *ImportResult) (string, error) {
|
||||
if rootID == "root" || rootID == "" {
|
||||
// Auto-provision a root group if none is provided
|
||||
rootSlug := "root-group"
|
||||
t, _ := s.tenantRepo.FindBySlug(ctx, rootSlug)
|
||||
if t == nil {
|
||||
t = &domain.Tenant{ID: uuid.NewString(), Name: "Root Group", Slug: rootSlug, Type: domain.TenantTypeCompanyGroup, Status: domain.TenantStatusActive}
|
||||
_ = s.tenantRepo.Create(ctx, t)
|
||||
res.TenantCreated++
|
||||
}
|
||||
rootID = t.ID
|
||||
}
|
||||
|
||||
cacheKey := "company:" + slug
|
||||
if id, ok := cache[cacheKey]; ok { return id, nil }
|
||||
|
||||
tenant, _ := s.tenantRepo.FindBySlug(ctx, slug)
|
||||
if tenant == nil {
|
||||
tenant, _ = s.tenantRepo.FindByName(ctx, name)
|
||||
}
|
||||
|
||||
if tenant == nil {
|
||||
tenant = &domain.Tenant{ID: uuid.NewString(), Name: name, Slug: slug, Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, ParentID: &rootID}
|
||||
if err := s.tenantRepo.Create(ctx, tenant); err != nil { return "", err }
|
||||
if s.ketoOutboxRepo != nil {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + rootID, Action: domain.KetoOutboxActionCreate})
|
||||
}
|
||||
res.TenantCreated++
|
||||
}
|
||||
|
||||
domainPart := ""
|
||||
if parts := strings.Split(email, "@"); len(parts) == 2 { domainPart = parts[1] }
|
||||
if domainPart != "" { _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true) }
|
||||
|
||||
cache[cacheKey] = tenant.ID
|
||||
return tenant.ID, nil
|
||||
}
|
||||
|
||||
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, parts []string, cache map[string]string, res *ImportResult) (string, error) {
|
||||
currentParentID := rootTenantID
|
||||
currentPath := ""
|
||||
|
||||
for i, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
if part == "" || part == "-" { continue }
|
||||
if currentPath == "" { currentPath = part } else { currentPath += "/" + part }
|
||||
|
||||
cacheKey := rootTenantID + ":" + currentPath
|
||||
if id, ok := cache[cacheKey]; ok {
|
||||
currentParentID = id; continue
|
||||
}
|
||||
|
||||
if currentPath == "" {
|
||||
currentPath = part
|
||||
} else {
|
||||
currentPath = currentPath + "/" + part
|
||||
}
|
||||
|
||||
if id, ok := cache[currentPath]; ok {
|
||||
currentParentID = id
|
||||
continue
|
||||
}
|
||||
|
||||
// Check DB if already exists
|
||||
var existingID string
|
||||
if s.userGroupRepo != nil {
|
||||
groups, err := s.userGroupRepo.ListByTenantID(ctx, rootTenantID)
|
||||
if err == nil {
|
||||
for _, g := range groups {
|
||||
// Match by name and parent
|
||||
if g.Name == part && ((g.ParentID == nil && currentParentID == rootTenantID) || (g.ParentID != nil && *g.ParentID == currentParentID)) {
|
||||
existingID = g.ID
|
||||
break
|
||||
}
|
||||
if groups, err := s.userGroupRepo.ListByTenantID(ctx, rootTenantID); err == nil {
|
||||
for _, g := range groups {
|
||||
isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID)
|
||||
isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID)
|
||||
if g.Name == part && (isTopMatch || isSubMatch) {
|
||||
existingID = g.ID; break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if existingID == "" {
|
||||
// Create new unit
|
||||
unitID := uuid.NewString()
|
||||
existingID = uuid.NewString()
|
||||
groupSlug := fmt.Sprintf("ug-%s", existingID[:13])
|
||||
|
||||
// 1. Create Tenant (Type: USER_GROUP)
|
||||
newTenant := &domain.Tenant{
|
||||
ID: unitID,
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
Slug: fmt.Sprintf("ug-%s", unitID[:8]),
|
||||
if err := s.tenantRepo.Create(ctx, &domain.Tenant{
|
||||
ID: existingID,
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
Slug: groupSlug,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
if err := s.tenantRepo.Create(ctx, newTenant); err != nil {
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var ugParentID *string
|
||||
if currentParentID != rootTenantID {
|
||||
pid := currentParentID
|
||||
ugParentID = &pid
|
||||
}
|
||||
|
||||
// 2. Create UserGroup metadata
|
||||
newUserGroup := &domain.UserGroup{
|
||||
ID: unitID,
|
||||
TenantID: rootTenantID,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{
|
||||
ID: existingID,
|
||||
TenantID: rootTenantID,
|
||||
ParentID: ugParentID,
|
||||
Name: part,
|
||||
UnitType: s.guessUnitType(i, len(parts)),
|
||||
}
|
||||
if err := s.userGroupRepo.Create(ctx, newUserGroup); err != nil {
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 3. Sync Hierarchy to Keto via Outbox
|
||||
if s.ketoOutboxRepo != nil {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: unitID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + currentParentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: existingID, Relation: "parents", Subject: "Tenant:" + currentParentID, Action: domain.KetoOutboxActionCreate})
|
||||
}
|
||||
|
||||
existingID = unitID
|
||||
res.TenantCreated++
|
||||
}
|
||||
|
||||
cache[currentPath] = existingID
|
||||
cache[cacheKey] = existingID
|
||||
currentParentID = existingID
|
||||
}
|
||||
|
||||
return currentParentID, nil
|
||||
}
|
||||
|
||||
func (s *orgChartService) guessUnitType(index, total int) string {
|
||||
if total == 1 {
|
||||
return "Team"
|
||||
}
|
||||
if index == 0 {
|
||||
return "Division"
|
||||
}
|
||||
if index == total-1 {
|
||||
return "Team"
|
||||
}
|
||||
return "Department"
|
||||
if total == 1 { return "Team" }
|
||||
if index == 0 { return "Division" }
|
||||
return "Team"
|
||||
}
|
||||
|
||||
249
backend/internal/service/org_chart_service_test.go
Normal file
249
backend/internal/service/org_chart_service_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type mockTenantRepo struct {
|
||||
mock.Mock
|
||||
repository.TenantRepository
|
||||
}
|
||||
|
||||
func (m *mockTenantRepo) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, slug)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockTenantRepo) Create(ctx context.Context, tenant *domain.Tenant) error {
|
||||
args := m.Called(ctx, tenant)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *mockTenantRepo) AddDomain(ctx context.Context, tenantID, domainName string, isPrimary bool) error {
|
||||
args := m.Called(ctx, tenantID, domainName, isPrimary)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type mockUserGroupRepo struct {
|
||||
mock.Mock
|
||||
repository.UserGroupRepository
|
||||
}
|
||||
|
||||
func (m *mockUserGroupRepo) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
|
||||
args := m.Called(ctx, tenantID)
|
||||
return args.Get(0).([]domain.UserGroup), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockUserGroupRepo) Create(ctx context.Context, ug *domain.UserGroup) error {
|
||||
args := m.Called(ctx, ug)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type mockUserRepo struct {
|
||||
mock.Mock
|
||||
repository.UserRepository
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Update(ctx context.Context, user *domain.User) error {
|
||||
args := m.Called(ctx, user)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type mockKetoOutboxRepo struct {
|
||||
mock.Mock
|
||||
repository.KetoOutboxRepository
|
||||
}
|
||||
|
||||
func (m *mockKetoOutboxRepo) Create(ctx context.Context, outbox *domain.KetoOutbox) error {
|
||||
args := m.Called(ctx, outbox)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type mockKratosService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockKratosService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
args := m.Called(ctx, identifier)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockKratosService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) UpdateIdentity(ctx context.Context, id string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
||||
args := m.Called(ctx, id, traits, state)
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockKratosService) UpdateIdentityPassword(ctx context.Context, id, pw string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) DeleteIdentity(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
args := m.Called(ctx, user, password)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func setupMocksForImport(tenantRepo *mockTenantRepo, ugRepo *mockUserGroupRepo, userRepo *mockUserRepo, ketoRepo *mockKetoOutboxRepo, kratos *mockKratosService, ctx context.Context, companySlug, email string) {
|
||||
tenantRepo.On("FindBySlug", ctx, companySlug).Return(&domain.Tenant{ID: companySlug + "-id", Slug: companySlug}, nil).Maybe()
|
||||
tenantRepo.On("FindBySlug", ctx, mock.Anything).Return(&domain.Tenant{ID: "fallback-id", Slug: "fallback"}, nil).Maybe()
|
||||
tenantRepo.On("AddDomain", ctx, mock.Anything, mock.Anything, true).Return(nil).Maybe()
|
||||
tenantRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
|
||||
ugRepo.On("ListByTenantID", ctx, mock.Anything).Return([]domain.UserGroup{}, nil).Maybe()
|
||||
ugRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
|
||||
kratos.On("FindIdentityIDByIdentifier", ctx, email).Return("user-id", nil).Maybe()
|
||||
kratos.On("UpdateIdentity", ctx, "user-id", mock.Anything, "active").Return(nil, nil).Maybe()
|
||||
userRepo.On("Update", ctx, mock.Anything).Return(nil).Maybe()
|
||||
ketoRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
|
||||
}
|
||||
|
||||
func TestImportOrgChart_CSV_BOM(t *testing.T) {
|
||||
tenantRepo := new(mockTenantRepo)
|
||||
ugRepo := new(mockUserGroupRepo)
|
||||
userRepo := new(mockUserRepo)
|
||||
ketoRepo := new(mockKetoOutboxRepo)
|
||||
kratos := new(mockKratosService)
|
||||
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
|
||||
|
||||
csvData := "\xef\xbb\xbf이메일,이름,소속,직급,그룹,디비젼,팀,구분\n" +
|
||||
"test@example.com,홍길동,한맥,사원,엔지니어링,구조,계획,팀원"
|
||||
|
||||
ctx := context.Background()
|
||||
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "hanmac", "test@example.com")
|
||||
|
||||
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
}
|
||||
|
||||
func TestImportOrgChart_XLSX(t *testing.T) {
|
||||
tenantRepo := new(mockTenantRepo)
|
||||
ugRepo := new(mockUserGroupRepo)
|
||||
userRepo := new(mockUserRepo)
|
||||
ketoRepo := new(mockKetoOutboxRepo)
|
||||
kratos := new(mockKratosService)
|
||||
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
|
||||
|
||||
xlsx := excelize.NewFile()
|
||||
xlsx.SetCellValue("Sheet1", "A1", "이메일")
|
||||
xlsx.SetCellValue("Sheet1", "B1", "이름")
|
||||
xlsx.SetCellValue("Sheet1", "C1", "소속")
|
||||
xlsx.SetCellValue("Sheet1", "A2", "xlsx@example.com")
|
||||
xlsx.SetCellValue("Sheet1", "B2", "엑셀맨")
|
||||
xlsx.SetCellValue("Sheet1", "C2", "삼안")
|
||||
|
||||
var buf bytes.Buffer
|
||||
xlsx.Write(&buf)
|
||||
|
||||
ctx := context.Background()
|
||||
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "saman", "xlsx@example.com")
|
||||
|
||||
res, err := svc.ImportOrgChart(ctx, "root-id", &buf, "test.xlsx", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
}
|
||||
|
||||
func TestImportOrgChart_MissingColumns(t *testing.T) {
|
||||
svc := NewOrgChartService(nil, nil, nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
csvData := "소속,직급\n한맥,부장"
|
||||
res, err := svc.ImportOrgChart(ctx, "root", bytes.NewReader([]byte(csvData)), "test.csv", "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, res)
|
||||
}
|
||||
|
||||
func TestImportOrgChart_RobustHeader(t *testing.T) {
|
||||
tenantRepo := new(mockTenantRepo)
|
||||
ugRepo := new(mockUserGroupRepo)
|
||||
userRepo := new(mockUserRepo)
|
||||
ketoRepo := new(mockKetoOutboxRepo)
|
||||
kratos := new(mockKratosService)
|
||||
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
|
||||
|
||||
csvData := "\n\n 이 메 일 , 이 름 , 소 속 \n" +
|
||||
"robust@example.com,로버스트,바론"
|
||||
ctx := context.Background()
|
||||
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "baron", "robust@example.com")
|
||||
|
||||
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
}
|
||||
|
||||
func TestImportOrgChart_MultiSheet_ComplexHeaders(t *testing.T) {
|
||||
tenantRepo := new(mockTenantRepo)
|
||||
ugRepo := new(mockUserGroupRepo)
|
||||
userRepo := new(mockUserRepo)
|
||||
ketoRepo := new(mockKetoOutboxRepo)
|
||||
kratos := new(mockKratosService)
|
||||
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
|
||||
|
||||
xlsx := excelize.NewFile()
|
||||
xlsx.NewSheet("Sheet2")
|
||||
xlsx.SetCellValue("Sheet2", "A3", " 이 메 일 ")
|
||||
xlsx.SetCellValue("Sheet2", "B3", " 성 함 / 이 름 ")
|
||||
xlsx.SetCellValue("Sheet2", "C3", " 소 속 회 사 ")
|
||||
xlsx.SetCellValue("Sheet2", "A4", "sheet2@example.com")
|
||||
xlsx.SetCellValue("Sheet2", "B4", "시트투")
|
||||
xlsx.SetCellValue("Sheet2", "C4", "한맥")
|
||||
|
||||
var buf bytes.Buffer
|
||||
xlsx.Write(&buf)
|
||||
|
||||
ctx := context.Background()
|
||||
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "hanmac", "sheet2@example.com")
|
||||
|
||||
res, err := svc.ImportOrgChart(ctx, "root-id", &buf, "test.xlsx", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
}
|
||||
|
||||
func TestImportOrgChart_MessyHeader(t *testing.T) {
|
||||
tenantRepo := new(mockTenantRepo)
|
||||
ugRepo := new(mockUserGroupRepo)
|
||||
userRepo := new(mockUserRepo)
|
||||
ketoRepo := new(mockKetoOutboxRepo)
|
||||
kratos := new(mockKratosService)
|
||||
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
|
||||
|
||||
csvData := " 이메일(ID)* , 성 명 , [소속] \n" +
|
||||
"messy@example.com,메시,바론"
|
||||
ctx := context.Background()
|
||||
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "baron", "messy@example.com")
|
||||
|
||||
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
}
|
||||
|
||||
func (m *mockKratosService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockKratosService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockKratosService) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -20,9 +20,12 @@ type TenantService interface {
|
||||
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
|
||||
ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
|
||||
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
||||
ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
||||
IsDomainAllowed(ctx context.Context, domainName string) (bool, error)
|
||||
ApproveTenant(ctx context.Context, id string) error
|
||||
SetKetoService(keto KetoService) // 추가
|
||||
ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
||||
SetKetoService(keto KetoService)
|
||||
DeleteTenantsBulk(ctx context.Context, ids []string) error
|
||||
}
|
||||
|
||||
type tenantService struct {
|
||||
@@ -55,8 +58,6 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
||||
return nil, errors.New("keto service not initialized")
|
||||
}
|
||||
|
||||
// [Keto] 'Tenant' 네임스페이스에서 'manage' 권한을 가진 모든 테넌트 ID 조회
|
||||
// OPL(parents 상속 포함) 결과가 반영된 리스트를 가져옵니다.
|
||||
allIDs, err := s.keto.ListObjects(ctx, "Tenant", "manage", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list manageable tenants from Keto", "userID", userID, "error", err)
|
||||
@@ -64,7 +65,6 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
||||
}
|
||||
|
||||
if len(allIDs) == 0 {
|
||||
// Fallback: Check direct membership if list objects didn't catch everything
|
||||
directAdminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||
directOwnerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
|
||||
|
||||
@@ -89,13 +89,42 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
||||
return s.repo.FindByIDs(ctx, allIDs)
|
||||
}
|
||||
|
||||
func (s *tenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
if s.keto == nil {
|
||||
return nil, errors.New("keto service not initialized")
|
||||
}
|
||||
|
||||
memberIDs, err := s.keto.ListObjects(ctx, "Tenant", "members", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list joined tenants from Keto", "userID", userID, "error", err)
|
||||
return []domain.Tenant{}, nil
|
||||
}
|
||||
|
||||
ownerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
|
||||
adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||
|
||||
idMap := make(map[string]bool)
|
||||
for _, id := range memberIDs { idMap[id] = true }
|
||||
for _, id := range ownerIDs { idMap[id] = true }
|
||||
for _, id := range adminIDs { idMap[id] = true }
|
||||
|
||||
allIDs := make([]string, 0, len(idMap))
|
||||
for id := range idMap {
|
||||
allIDs = append(allIDs, id)
|
||||
}
|
||||
|
||||
if len(allIDs) == 0 {
|
||||
return []domain.Tenant{}, nil
|
||||
}
|
||||
|
||||
return s.repo.FindByIDs(ctx, allIDs)
|
||||
}
|
||||
|
||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
|
||||
// Validate Slug
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
|
||||
// 1. Check if slug exists
|
||||
existing, err := s.repo.FindBySlug(ctx, slug)
|
||||
if err == nil && existing != nil {
|
||||
return nil, errors.New("tenant slug already exists")
|
||||
@@ -104,7 +133,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Create Tenant
|
||||
tenant := &domain.Tenant{
|
||||
Type: tenantType,
|
||||
Name: name,
|
||||
@@ -118,9 +146,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// [Keto] Sync hierarchy and ownership via Outbox
|
||||
if s.outboxRepo != nil {
|
||||
// Global Super Admin access to every tenant
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
@@ -129,7 +155,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
|
||||
// Sync hierarchy
|
||||
if tenant.ParentID != nil {
|
||||
if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
@@ -142,10 +167,8 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
}
|
||||
}
|
||||
|
||||
// Sync creator ownership
|
||||
if creatorID != "" {
|
||||
slog.Info("Creating outbox entries for tenant creator", "tenant", tenant.ID, "creator", creatorID)
|
||||
// Add as owner
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
@@ -153,7 +176,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
Subject: "User:" + creatorID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
// Add as admin
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
@@ -161,7 +183,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
Subject: "User:" + creatorID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
// Add as member
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
@@ -172,7 +193,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Add Domains (Auto-verify for manual admin registration)
|
||||
for _, d := range domains {
|
||||
if err := s.repo.AddDomain(ctx, tenant.ID, d, true); err != nil {
|
||||
slog.Error("Failed to add domain to tenant", "tenant", slug, "domain", d, "error", err)
|
||||
@@ -183,12 +203,10 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
}
|
||||
|
||||
func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
|
||||
// Validate Slug
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
|
||||
// Verify that adminEmail domain matches the requested domainName
|
||||
parts := strings.Split(adminEmail, "@")
|
||||
if len(parts) != 2 || parts[1] != domainName {
|
||||
return nil, errors.New("admin email domain must match the tenant domain")
|
||||
@@ -207,7 +225,6 @@ func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, des
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// [Keto] Global Super Admin access to every tenant (even pending ones)
|
||||
if s.outboxRepo != nil {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
@@ -218,7 +235,6 @@ func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, des
|
||||
})
|
||||
}
|
||||
|
||||
// Add Domain as unverified
|
||||
if err := s.repo.AddDomain(ctx, tenant.ID, domainName, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -237,15 +253,12 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// [Keto] Sync relation via Outbox
|
||||
if s.outboxRepo != nil {
|
||||
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
|
||||
slog.Info("Queueing tenant admin/owner sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
|
||||
// Check if user already exists in our Read-Model
|
||||
if s.userRepo != nil {
|
||||
user, err := s.userRepo.FindByEmail(ctx, adminEmail)
|
||||
if err == nil && user != nil {
|
||||
// User exists, assign Admin, Owner, and Member roles in Keto via Outbox
|
||||
slog.Info("Queueing tenant ownership/membership sync to Keto", "tenant", tenant.Slug, "userID", user.ID)
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
@@ -284,7 +297,6 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only return ACTIVE tenants for auto-assignment
|
||||
if tenant.Status != domain.TenantStatusActive {
|
||||
return nil, errors.New("tenant is not active")
|
||||
}
|
||||
@@ -297,7 +309,6 @@ func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*doma
|
||||
}
|
||||
|
||||
func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
// Let the repository handle the query and pagination
|
||||
return s.repo.List(ctx, limit, offset, parentID)
|
||||
}
|
||||
|
||||
@@ -311,3 +322,67 @@ func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string)
|
||||
}
|
||||
return tenant != nil && tenant.Status == domain.TenantStatusActive, nil
|
||||
}
|
||||
|
||||
func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
groups, err := s.repo.ListByType(ctx, domain.TenantTypeCompanyGroup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, g := range groups {
|
||||
rawConfig, ok := g.Config["autoProvisioning"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
enabled, _ := rawConfig["enabled"].(bool)
|
||||
if !enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
mapping, ok := rawConfig["mappingRules"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
rule, ok := mapping[domainName].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
slug, _ := rule["slug"].(string)
|
||||
name, _ := rule["name"].(string)
|
||||
|
||||
if slug == "" || name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("[Provisioning] Found rule for domain, creating sub-tenant", "domain", domainName, "parent", g.Slug, "newTenant", slug)
|
||||
return s.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "Automatically provisioned via group policy", []string{domainName}, &g.ID, "")
|
||||
}
|
||||
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func (s *tenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.repo.DeleteBulk(ctx, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.outboxRepo != nil {
|
||||
for _, id := range ids {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: id,
|
||||
Relation: "parents",
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -64,6 +64,19 @@ func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, pare
|
||||
return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MockTenantRepoForSvc) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, tenantType)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantRepoForSvc) DeleteBulk(ctx context.Context, ids []string) error {
|
||||
args := m.Called(ctx, ids)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type MockKetoSvcForTenant struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
@@ -158,12 +158,20 @@ func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName stri
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantRepository) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
func (m *MockTenantRepository) DeleteBulk(ctx context.Context, ids []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUserGroupService_Create(t *testing.T) {
|
||||
|
||||
59
docs/b2b2b_dynamic_provisioning_flow.md
Normal file
59
docs/b2b2b_dynamic_provisioning_flow.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 가족사 테넌트 가입 및 관리 정책 (인증 기반 수정안)
|
||||
|
||||
이 문서는 보안 강화를 위해 **이메일 인증 성공 시에만 가족사 소속을 선택**할 수 있도록 변경된 가입 흐름을 설명합니다.
|
||||
|
||||
## 회원가입 및 권한 관리 흐름도
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
%% 시작점
|
||||
A([사용자 회원가입 시작]) --> B[이메일 입력 및 인증 코드 발송]
|
||||
B --> C{이메일 인증 성공?}
|
||||
|
||||
C -- No --> B
|
||||
C -- Yes --> D{인증된 이메일이<br>내부/가족사 도메인인가?}
|
||||
|
||||
%% 일반 도메인 (gmail, naver 등)
|
||||
D -- No<br>(External) --> E[개인 테넌트 자동 할당<br>Type: PERSONAL]
|
||||
E --> J
|
||||
|
||||
%% 내부 도메인 (hanmaceng.co.kr 등)
|
||||
D -- Yes<br>(Internal) --> F[가족사 목록 노출 및 선택<br>Select Company Code]
|
||||
F --> G{선택한 코드가<br>ACTIVE 상태인가?}
|
||||
|
||||
G -- No --> F
|
||||
G -- Yes --> J[Ory Kratos 계정 생성]
|
||||
|
||||
%% 유저 생성 및 권한 할당
|
||||
J --> K[(Local DB 유저 레코드 생성)]
|
||||
K --> N[기본 권한 할당: user<br>Keto: members 부여]
|
||||
|
||||
N --> O([회원가입 완료])
|
||||
|
||||
%% 관리자 수동 개입 (별도 흐름)
|
||||
P((최고 관리자<br>Super Admin)) -.-> Q[사용자 역할 변경<br>user -> tenant_admin]
|
||||
Q -.-> R[(Keto 권한 수동 할당<br>owners, admins 부여)]
|
||||
|
||||
%% 스타일링
|
||||
classDef process fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
|
||||
classDef decision fill:#fff3e0,stroke:#e65100,stroke-width:2px;
|
||||
classDef db fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px;
|
||||
classDef startend fill:#f3e5f5,stroke:#4a148c,stroke-width:2px;
|
||||
classDef admin fill:#f5f5f5,stroke:#616161,stroke-width:2px,stroke-dasharray: 5 5;
|
||||
|
||||
class A,O startend;
|
||||
class B,F,J,N,Q process;
|
||||
class C,D,G decision;
|
||||
class E,K,R db;
|
||||
class P admin;
|
||||
```
|
||||
|
||||
## 핵심 정책 변경 사항
|
||||
|
||||
1. **선(先)인증 후(後)선택:** 사용자는 이메일 소유권 인증(OTP 또는 인증 링크)을 완료하기 전까지는 어떠한 가족사 소속도 선택할 수 없습니다.
|
||||
2. **도메인 기반 노출 제어:**
|
||||
- 인증된 이메일 도메인이 시스템에 등록된 가족사 도메인(`hanmaceng.co.kr` 등)일 경우에만 소속 선택 UI가 활성화됩니다.
|
||||
- 일반 외부 도메인(gmail, naver 등)은 `PERSONAL` 테넌트로 강제 배정되어 가족사 리스트 자체가 노출되지 않습니다.
|
||||
3. **이메일 도메인 중복 방지:** 같은 도메인을 쓰더라도 다른 소속일 수 있는 경우(예: 협력사 등)를 대비하여, 인증 성공 후에도 사용자가 직접 본인의 정확한 소속(`Company Code`)을 선택하게 하여 데이터 무결성을 확보합니다.
|
||||
4. **수동 권한 위임 유지:** 모든 가입자는 기본적으로 `user` 권한을 부여받으며, 테넌트 관리자(`tenant_admin`)나 오너(`owner`) 권한은 지주사 관리자가 사용자의 신원을 최종 확인한 후 수동으로 부여합니다.
|
||||
5. **실시간 상태 검증:** 가입 시점에 선택한 테넌트가 `ACTIVE` 상태가 아닐 경우 가입 진행을 차단합니다.
|
||||
@@ -1,88 +1,111 @@
|
||||
# Organization Chart Architecture & Implementation Policy (ADR)
|
||||
# 조직도 기반 테넌트 및 권한 매핑 정책 (Organization Chart Policy)
|
||||
|
||||
## 1. Overview (개요)
|
||||
본 문서는 Baron SSO 내 `adminfront`에서 사용될 **조직도(Organization Chart) 및 다중 테넌시(Multi-Tenancy) 대응 기능**에 대한 아키텍처 결정 사항(Architecture Decision Record)과 세부 구현 방향을 정의합니다.
|
||||
이 문서는 실무 부서 및 직급 체계가 포함된 인사(HR) 데이터를 기반으로, Baron SSO의 다형성 테넌트(Polymorphic Tenant)와 Ory Keto 기반의 ReBAC 권한 모델을 어떻게 구축하고 동기화할 것인지에 대한 아키텍처 설계와 가이드라인을 정의합니다.
|
||||
|
||||
이 정책은 기존 B2B 테넌트 모델(`Tenant`)과 사내 사용자 그룹 모델(`UserGroup`), 그리고 Ory Keto 기반의 권한 제어(ReBAC) 시스템 간의 일관성을 유지하면서, 복잡하고 다양한 형태의 고객사별 조직 구조(N-Depth)를 지원하기 위해 작성되었습니다.
|
||||
## 1. 개요 및 요구사항 분석
|
||||
|
||||
제공된 인사 데이터(샘플)는 다음과 같은 특징을 가집니다.
|
||||
|
||||
| 연번 | 그룹 | 디비젼 | 팀 | 셀 | 직급 | 이름 | 직무 | 구분 | 소속 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | 사장단 | - | - | - | 사장 | 정태원 | 사장단 | 센터장 | 한맥 |
|
||||
| 4 | 엔지니어링 기획 | - | - | - | 부사장 | 양병홍 | 엔지니어 | 그룹장 | 삼안 |
|
||||
| 5 | 엔지니어링 기획 | 일반구조물 | - | - | 수석 | 이동원 | 엔지니어 | 디비젼장 | 삼안 |
|
||||
| 6 | 엔지니어링 기획 | 일반구조물 | 구조물계획 | - | 수석 | 김일태 | 엔지니어 | 팀장 | 삼안 |
|
||||
| 7 | 엔지니어링 기획 | 일반구조물 | 구조물계획 | - | 수석 | 곽현석 | 엔지니어 | 팀원 | 한맥 |
|
||||
|
||||
### 🔍 주요 분석 포인트 (Matrix Organization)
|
||||
가장 중요한 점은 6번(삼안 소속)과 7번(한맥 소속)이 **서로 다른 법인 소속임에도 불구하고 "엔지니어링 기획그룹 > 일반구조물 디비젼 > 구조물계획 팀" 이라는 동일한 논리적 부서에 속해 있다**는 것입니다.
|
||||
이는 개별 법인(COMPANY) 산하에 부서가 종속되는 Tree 구조가 아니라, 지주사(COMPANY_GROUP) 차원에서 부서를 관리하고 법인 소속은 개인의 속성(Attribute)으로 분리해야 함을 의미합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Architectural Decisions (핵심 아키텍처 결정)
|
||||
## 2. 아키텍처 매핑 전략 (Data to DB)
|
||||
|
||||
### 2.1 B2B Tenant vs. Internal UserGroup Hierarchy (테넌트 vs. 유저그룹 계층화)
|
||||
조직의 계층(Hierarchy)을 표현하기 위해 `Tenant` 자체를 중첩(Nested)시킬 것인지, 아니면 단일 `Tenant` 내의 `UserGroup`을 중첩시킬 것인지에 대한 결정입니다.
|
||||
엑셀의 각 컬럼은 데이터베이스 모델과 다음과 같이 1:1로 매핑됩니다.
|
||||
|
||||
* **Decision (결정):** 조직도는 **`UserGroup` 내부의 자기 참조(`parent_id`)를 통해 계층화**합니다.
|
||||
* **Rationale (이유):**
|
||||
* **관심사 분리 (Separation of Concerns):** `Tenant` 모델은 결제, 도메인 매핑, B2B 고객사(Company) 격리 등 무거운 비즈니스 로직을 담고 있습니다. "개발팀", "인사부"와 같은 단순한 사내 조직 단위까지 `Tenant` 테이블에 저장하면 시스템 복잡도가 기하급수적으로 증가합니다.
|
||||
* **조회 성능 (Performance):** 특정 고객사(Company)의 전체 조직도를 그릴 때 `SELECT * FROM user_groups WHERE tenant_id = ?` 단일 쿼리로 모든 노드를 가져와 애플리케이션 메모리에서 트리를 구성할 수 있어 성능상 매우 유리합니다.
|
||||
* **단일 진실 공급원 (SoT):** 회사(Company) 단위의 물리적 격리는 `Tenant`가, 논리적인 사내 부서/팀 구조는 `UserGroup`이 담당하도록 역할을 명확히 분리합니다.
|
||||
### 2.1 조직 (Tenant) 매핑
|
||||
모든 조직 단위는 `Tenant` 테이블에 저장되며 `type`과 `parent_id`로 계층을 구성합니다.
|
||||
|
||||
### 2.2 Flexible N-Depth Organizational Structure (유연한 N-Depth 조직 구조)
|
||||
고객사마다 조직 단계(부, 국, 실, 본부, 파트, 반, 셀 등)의 명칭과 깊이(Depth)가 다릅니다. 이를 하드코딩된 Enum으로 제한해서는 안 됩니다.
|
||||
* **소속 (한맥, 삼안):** `Tenant` (Type: `COMPANY`) - 법인 격리 공간
|
||||
* **그룹 / 디비젼 / 팀 / 셀:** `Tenant` (Type: `USER_GROUP`) - 논리적 사내 조직
|
||||
* **계층 연결:** 하위 조직(팀)의 `parent_id`는 상위 조직(디비젼)의 `id`를 참조합니다.
|
||||
|
||||
* **Decision (결정):** 조직의 단계나 명칭을 시스템(DB 스키마)에서 강제하지 않으며, **N-Depth 인접 목록(Adjacency List) 모델을 사용**합니다.
|
||||
* **Implementation (구현):**
|
||||
* `UserGroup` 모델에 `parent_id` (UUID, Nullable) 컬럼을 추가하여 부모-자식 관계를 형성합니다.
|
||||
* 조직 타입(`unit_type`) 필드는 고정된 Enum(예: `TEAM`, `GROUP`) 대신, 고객사가 자유롭게 입력할 수 있는 **동적 문자열(`String`)**로 관리하거나, 계층의 상대적 깊이(Depth)만을 의미 단위로 사용합니다.
|
||||
* 프론트엔드의 Checkbox Tree 컴포넌트는 재귀적(Recursive)으로 설계되어 데이터의 깊이에 상관없이 무한한 N-Depth를 렌더링할 수 있어야 합니다.
|
||||
### 2.2 사용자 (User) 속성 매핑
|
||||
* **이름:** `User.Name`
|
||||
* **직급 (사장, 부사장, 수석 등):** `User.Position`
|
||||
* **직무 (엔지니어, 기획자 등):** `User.JobTitle`
|
||||
* **소속 (법인 코드):** `User.CompanyCode` (`hanmac`, `saman` 등)
|
||||
* **식별자:** (엑셀에 누락됨) 시스템 로그인을 위해 반드시 **이메일(Email) 또는 사번(LoginID)** 컬럼이 추가되어야 합니다.
|
||||
|
||||
### 2.3 권한 및 역할 (Keto ReBAC) 매핑
|
||||
엑셀의 **구분(센터장, 그룹장, 디비젼장, 팀장, 팀원)** 컬럼은 해당 사용자가 조직 내에서 어떤 권한을 가지는지(Ory Keto의 Relation)를 결정합니다.
|
||||
|
||||
* **리더 (장급):** 해당 조직 테넌트의 `owners` 또는 `admins` 튜플 부여.
|
||||
* *예:* 양병홍 부사장은 `엔지니어링 기획그룹`의 `owners`가 됩니다.
|
||||
* **팀원:** 가장 말단 조직 테넌트의 `members` 튜플 부여.
|
||||
* *예:* 곽현석 수석은 `구조물계획 팀`의 `members`가 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Structure & Schema Updates (데이터 구조 및 스키마 업데이트)
|
||||
## 3. 다이어그램: 통합 조직도 계층 설계
|
||||
|
||||
새로운 테이블을 추가하는 대신, 기존 모델을 확장하여 중복을 방지합니다.
|
||||
아래는 위 전략을 바탕으로 구성된 지주사 통합 조직도와 권한 상속(Keto OPL) 다이어그램입니다.
|
||||
|
||||
### 3.1 `user_groups` 테이블 확장
|
||||
조직 계층 및 부서 단위 표현을 위해 필드를 추가합니다.
|
||||
* `id`, `tenant_id`, `name`, `description` (기존 유지)
|
||||
* **`parent_id` (UUID, Nullable FK):** 상위 `UserGroup` 참조 (조직 트리 구성).
|
||||
* **`unit_type` (String, Optional):** 조직 단위 명칭 (예: "본부", "실", "팀"). 시스템이 강제하지 않으며 프론트엔드 라벨링 용도로 사용됩니다.
|
||||
```mermaid
|
||||
graph TD
|
||||
%% 지주사 및 법인
|
||||
G[지주사<br>Type: COMPANY_GROUP] --> C1[한맥<br>Type: COMPANY]
|
||||
G --> C2[삼안<br>Type: COMPANY]
|
||||
|
||||
### 3.2 `users` 테이블 확장 (직급 및 직무)
|
||||
CSV에서 업로드되는 사용자의 인사 정보(직급, 직무 등)는 `User` 모델에 직접 저장합니다.
|
||||
* **`position` (String):** 직급 (예: "수석", "책임", "사원").
|
||||
* **`job_title` (String):** 직무 (예: "프론트엔드 개발", "기획").
|
||||
* *(또는 기존에 존재하는 `Metadata` (JSONB) 필드를 활용하여 스키마 변경 없이 동적 속성으로 관리할 수도 있습니다.)*
|
||||
%% 통합 조직도 (지주사 직속 논리적 연결)
|
||||
G -.-> T1[전략기획그룹<br>Type: USER_GROUP]
|
||||
G -.-> T2[엔지니어링 기획그룹<br>Type: USER_GROUP]
|
||||
|
||||
T2 --> T2_1[일반구조물 디비젼<br>Type: USER_GROUP]
|
||||
T2_1 --> T2_1_1[구조물계획 팀<br>Type: USER_GROUP]
|
||||
|
||||
%% 유저 권한 매핑 (Keto Tuples)
|
||||
U2([양병홍 / 삼안]) -. owners (그룹장) .-> T2
|
||||
U3([이동원 / 삼안]) -. owners (디비젼장) .-> T2_1
|
||||
U4([김일태 / 삼안]) -. owners (팀장) .-> T2_1_1
|
||||
U5([곽현석 / 한맥]) -. members (팀원) .-> T2_1_1
|
||||
|
||||
%% Keto OPL 상속 (부모의 권한이 자식으로 흐름)
|
||||
T2 -. 부모/자식 상속 .-> T2_1
|
||||
T2_1 -. 부모/자식 상속 .-> T2_1_1
|
||||
|
||||
%% 결과적인 권한 도달
|
||||
U2 -. 자동 상속 (Read/Write) .-> T2_1_1
|
||||
|
||||
%% 스타일
|
||||
classDef company fill:#e3f2fd,stroke:#0277bd,stroke-width:2px;
|
||||
classDef group fill:#fff3e0,stroke:#e65100,stroke-width:2px;
|
||||
classDef user fill:#f3e5f5,stroke:#4a148c,stroke-width:1px;
|
||||
|
||||
class G,C1,C2 company;
|
||||
class T1,T2,T2_1,T2_1_1 group;
|
||||
class U2,U3,U4,U5 user;
|
||||
```
|
||||
|
||||
### 💡 ReBAC 상속의 이점 (OPL)
|
||||
위 다이어그램에서 **양병홍 부사장(그룹장)**은 최상위 조직인 `엔지니어링 기획그룹`의 `owners`로 한 번만 매핑됩니다.
|
||||
하지만 Keto의 `parents` 상속 설계 덕분에, 하위의 `일반구조물 디비젼`과 `구조물계획 팀`, 그리고 향후 생겨날 모든 하위 '셀' 단위까지 **자동으로 관리 권한(Read/Write)을 상속**받게 됩니다. 권한 부여 작업을 1회로 최소화할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. ReBAC Integration Policy (Ory Keto 연동 정책)
|
||||
## 4. 구축 파이프라인 (Bulk Import) 가이드
|
||||
|
||||
DB의 `user_groups` 계층 트리는 Ory Keto의 관계 튜플(Tuple)과 동기화되어 권한 제어에 사용됩니다. (기존 통합 권한 정책 `tenant-usergroup-policy.md` 준수)
|
||||
수백, 수천 명의 조직도를 수동으로 입력하는 것은 불가능하므로, 시스템은 **CSV 일괄 등록(Bulk Import)** API를 제공해야 합니다.
|
||||
|
||||
1. **조직 계층 동기화 (Hierarchy):**
|
||||
* DB에서 A팀(`UserGroup`)이 B본부(`UserGroup`)의 하위로 설정되면, Keto에는 `UserGroup:<A팀_ID>#parent@UserGroup:<B본부_ID>` 튜플이 생성됩니다.
|
||||
2. **소속원 매핑 (Membership):**
|
||||
* 유저가 A팀에 속하면 `UserGroup:<A팀_ID>#members@User:<유저_ID>` 튜플이 생성됩니다.
|
||||
3. **조직장 및 어드민 승격 (Leadership):**
|
||||
* CSV 데이터 분석 또는 수동 지정을 통해 특정 유저가 A팀의 '조직장'으로 식별되면, `UserGroup:<A팀_ID>#owners@User:<유저_ID>` 튜플이 생성됩니다.
|
||||
* 정책에 따라 `owners` 관계를 가진 유저는 해당 조직(UserGroup)과 그 하위 조직에 대한 `admins` 권한을 자동으로 상속받습니다.
|
||||
1. **데이터 준비:** 엑셀 데이터를 CSV로 변환합니다. (반드시 이메일 또는 사번 컬럼 포함)
|
||||
2. **조직(Tenant) 순차 생성 (Upsert):**
|
||||
* 스크립트는 CSV의 그룹 ➔ 디비젼 ➔ 팀 ➔ 셀 순서로 읽으며, 없는 조직은 생성하고 상위 조직의 ID를 `parent_id`로 연결합니다.
|
||||
* 생성 시 백엔드 `TenantService`는 자동으로 Keto에 `parents` 튜플을 동기화합니다.
|
||||
3. **사용자(User) 계정 생성:**
|
||||
* Ory Kratos에 계정을 생성하고(`POST /identities`), 로컬 DB `users` 테이블에 직급, 직무 등의 메타데이터를 저장합니다.
|
||||
4. **멤버십(Keto 튜플) 매핑:**
|
||||
* 사용자가 속한 **가장 깊은(Deepest) 말단 조직 단위 하나**를 찾습니다.
|
||||
* 직책(장급/일반)에 따라 `owners` 또는 `members` 권한을 Keto에 부여합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Loading & CSV Upload Strategy (데이터 로딩 및 CSV 업로드 전략)
|
||||
|
||||
고정된 컬럼 구조는 다양한 회사의 조직도를 수용할 수 없으므로 유연한 파싱 로직이 필요합니다.
|
||||
|
||||
### 5.1 Flexible CSV Format (유연한 CSV 포맷)
|
||||
* **경로 기반 방식 (Path-based):** 조직 계층을 슬래시(`/`) 등으로 구분하여 하나의 문자열로 전달받습니다.
|
||||
* *예시 컬럼:* `[조직_경로, 직급, 이름, 직무, 이메일]`
|
||||
* *데이터 예시:* `"개발본부/클라우드실/플랫폼팀", "수석", "홍길동", "백엔드 개발", "hong@example.com"`
|
||||
* **동적 뎁스 방식 (Dynamic Depth):** 뒤에서부터 고정된 사용자 속성 열(직급, 직무, 이름, 이메일 등)을 식별하고, 그 앞의 모든 열을 동적인 계층 구조로 해석합니다.
|
||||
|
||||
### 5.2 Processing Flow (처리 흐름)
|
||||
1. **Parsing & Validation:** 프론트엔드/백엔드에서 유연한 CSV 포맷을 파싱하고, `UserGroup` 계층 경로를 분석합니다.
|
||||
2. **Tree Resolution:** 백엔드는 "개발본부 > 클라우드실 > 플랫폼팀" 경로를 DB에서 조회하거나 없으면 순차적으로 생성(`parent_id` 매핑)하여 `UserGroup` ID 트리를 완성합니다.
|
||||
3. **User Upsert:** `User` 정보를 생성하거나 업데이트(`position`, `job_title` 갱신)합니다.
|
||||
4. **Keto Synchronization:** DB 트랜잭션 완료 후, Background Worker가 변경된 조직 계층과 멤버십 정보를 기반으로 Ory Keto 튜플을 생성/삭제(Reconciliation)합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend Multi-Tenancy UI (프론트엔드 다중 테넌트 UI)
|
||||
|
||||
관리자가 여러 테넌트(Company)에 접근 권한이 있을 경우, 조직도를 명확히 구분하여 보여주어야 합니다.
|
||||
|
||||
* **Tabs Interface:** 화면 상단 또는 측면에 사용자가 접근 가능한 최상위 `Tenant` 목록을 탭(Tabs) 형태로 제공합니다.
|
||||
* **Scoped Fetching:** 특정 탭(Tenant)을 선택할 때마다 해당 `tenant_id`를 파라미터로 백엔드 API를 호출하여, 격리된 해당 회사만의 `UserGroup` 트리를 렌더링합니다.
|
||||
* **Checkbox Tree Component:** Radix UI와 TailwindCSS를 기반으로 개발되며, N-Depth 중첩을 지원하고 부모-자식 간의 반선택(Indeterminate) 상태를 재귀적으로 계산하는 독립적인(Reusable) 컴포넌트로 구현됩니다.
|
||||
이 정책을 통해 복잡한 매트릭스 조직과 권한 체계를 단일 아키텍처로 우아하게 통합할 수 있습니다.
|
||||
|
||||
@@ -1011,7 +1011,9 @@ user = "TENANT MEMBER"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "Add Tenant"
|
||||
delete_selected = "Delete Selected"
|
||||
title = "Tenant Registry"
|
||||
view_org_chart = "View Full Org Chart"
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = "Add Button"
|
||||
@@ -1068,7 +1070,8 @@ tab_schema = "Tab Schema"
|
||||
title = "Details"
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
select_placeholder = "Select Placeholder"
|
||||
search_placeholder = "Search tenant by name or slug..."
|
||||
select_placeholder = "Select a tenant"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
descendants = "Descendant Members"
|
||||
@@ -1079,6 +1082,30 @@ title = "Tenant Members ({{count}})"
|
||||
total = "Total"
|
||||
total_label = "Total"
|
||||
|
||||
[msg.admin.apikeys.registry]
|
||||
count = "There are {{count}} active keys registered."
|
||||
|
||||
[msg.admin.org]
|
||||
import_partial_success = "Imported some organization data successfully."
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_bulk_confirm = "Delete {{count}} selected tenants?"
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = "You cannot delete your own account."
|
||||
|
||||
[ui.admin.apikeys.registry]
|
||||
title = "API Key Registry"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
delete_selected = "Delete Selected"
|
||||
view_org_chart = "View Full Org Chart"
|
||||
direct_label = "Direct"
|
||||
list_title = "Member Management"
|
||||
title = "Tenant Members ({{count}})"
|
||||
total = "Total"
|
||||
total_label = "Total"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = "EMAIL"
|
||||
name = "NAME"
|
||||
|
||||
@@ -328,7 +328,9 @@ user = "TENANT MEMBER"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "테넌트 추가"
|
||||
delete_selected = "선택 삭제"
|
||||
title = "테넌트 목록"
|
||||
view_org_chart = "전체 조직도 보기"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
@@ -1408,7 +1410,9 @@ user = "TENANT MEMBER"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "테넌트 추가"
|
||||
delete_selected = "선택 삭제"
|
||||
title = "테넌트 목록"
|
||||
view_org_chart = "전체 조직도 보기"
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = "관리자 추가"
|
||||
@@ -1465,6 +1469,7 @@ tab_schema = "사용자 스키마"
|
||||
title = "상세"
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
|
||||
select_placeholder = "테넌트를 선택하세요"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
@@ -1476,6 +1481,30 @@ title = "테넌트 구성원 ({{count}})"
|
||||
total = "전체"
|
||||
total_label = "전체"
|
||||
|
||||
[msg.admin.apikeys.registry]
|
||||
count = "총 {{count}}개의 활성 키가 등록되어 있습니다."
|
||||
|
||||
[msg.admin.org]
|
||||
import_partial_success = "일부 조직 정보를 가져왔습니다."
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = "자신의 계정은 삭제할 수 없습니다."
|
||||
|
||||
[ui.admin.apikeys.registry]
|
||||
title = "API Key Registry"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
delete_selected = "선택 삭제"
|
||||
view_org_chart = "전체 조직도 보기"
|
||||
direct_label = "직속"
|
||||
list_title = "구성원 관리"
|
||||
title = "테넌트 구성원 ({{count}})"
|
||||
total = "전체"
|
||||
total_label = "전체"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = "EMAIL"
|
||||
name = "NAME"
|
||||
|
||||
@@ -203,7 +203,9 @@ user = ""
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = ""
|
||||
delete_selected = ""
|
||||
title = ""
|
||||
view_org_chart = ""
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = ""
|
||||
@@ -1283,7 +1285,9 @@ user = ""
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = ""
|
||||
delete_selected = ""
|
||||
title = ""
|
||||
view_org_chart = ""
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = ""
|
||||
@@ -1340,6 +1344,7 @@ tab_schema = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
search_placeholder = ""
|
||||
select_placeholder = ""
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
@@ -1351,6 +1356,30 @@ title = ""
|
||||
total = ""
|
||||
total_label = ""
|
||||
|
||||
[msg.admin.apikeys.registry]
|
||||
count = ""
|
||||
|
||||
[msg.admin.org]
|
||||
import_partial_success = ""
|
||||
|
||||
[msg.admin.tenants]
|
||||
delete_bulk_confirm = ""
|
||||
|
||||
[msg.admin.users]
|
||||
self_delete_blocked = ""
|
||||
|
||||
[ui.admin.apikeys.registry]
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
delete_selected = ""
|
||||
view_org_chart = ""
|
||||
direct_label = ""
|
||||
list_title = ""
|
||||
title = ""
|
||||
total = ""
|
||||
total_label = ""
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = ""
|
||||
name = ""
|
||||
|
||||
@@ -981,6 +981,23 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> getActiveTenants({
|
||||
String? email,
|
||||
}) async {
|
||||
var uriString = '$_baseUrl/api/v1/auth/signup/tenants';
|
||||
if (email != null && email.isNotEmpty) {
|
||||
uriString += '?email=${Uri.encodeComponent(email)}';
|
||||
}
|
||||
|
||||
final url = Uri.parse(uriString);
|
||||
final response = await http.get(url);
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> list = jsonDecode(response.body);
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
static Future<void> sendSignupCode(String target, String type) async {
|
||||
final path = type == 'email' ? 'send-email-code' : 'send-sms-code';
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path');
|
||||
@@ -1000,7 +1017,7 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> verifySignupCode(
|
||||
static Future<Map<String, dynamic>> verifySignupCode(
|
||||
String target,
|
||||
String type,
|
||||
String code,
|
||||
@@ -1014,10 +1031,9 @@ class AuthProxyService {
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['success'] ?? false;
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
return false;
|
||||
throw Exception('Verification failed');
|
||||
}
|
||||
|
||||
static Future<void> signup({
|
||||
@@ -1048,8 +1064,18 @@ class AuthProxyService {
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final error = jsonDecode(response.body)['error'] ?? 'Signup failed';
|
||||
throw Exception(error);
|
||||
String errorMessage = 'Signup failed';
|
||||
try {
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is Map<String, dynamic> && decoded.containsKey('error')) {
|
||||
errorMessage = decoded['error'];
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback if the body isn't valid JSON (e.g., an HTML error page)
|
||||
errorMessage =
|
||||
'Server error (${response.statusCode}): ${response.body.isNotEmpty ? response.body.substring(0, response.body.length > 100 ? 100 : response.body.length) : "Unknown error"}';
|
||||
}
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
bool _isEmailVerified = false;
|
||||
bool _isPhoneVerified = false;
|
||||
String _affiliationType = 'GENERAL';
|
||||
bool _isAffiliateLocked = false;
|
||||
String? _companyCode;
|
||||
bool _isAffiliateEmail = false; // 가족사 이메일 여부
|
||||
bool _termsAccepted = false;
|
||||
bool _privacyAccepted = false;
|
||||
bool _isLoading = false;
|
||||
@@ -54,6 +54,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
bool _isPasswordObscured = true;
|
||||
bool _isConfirmPasswordObscured = true;
|
||||
|
||||
// Dynamic Tenants
|
||||
List<Map<String, dynamic>> _tenants = [];
|
||||
|
||||
// Inline Errors
|
||||
String? _emailError;
|
||||
String? _phoneError;
|
||||
@@ -66,16 +69,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
Timer? _phoneTimer;
|
||||
int _phoneSeconds = 0;
|
||||
|
||||
// 가족사 도메인 맵
|
||||
final Map<String, String> _affiliateDomains = {
|
||||
'hanmaceng.co.kr': 'HANMAC',
|
||||
'samaneng.com': 'SAMAN',
|
||||
'jangheon.co.kr': 'JANGHEON',
|
||||
'hallasanup.com': 'HALLA',
|
||||
'pre-cast.co.kr': 'PTC',
|
||||
'baroncs.co.kr': 'BARON',
|
||||
};
|
||||
|
||||
String _renderTranslatedText(
|
||||
String key, {
|
||||
String? fallback,
|
||||
@@ -92,6 +85,28 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPolicy();
|
||||
// initState에서는 _fetchTenants() 호출 제외
|
||||
}
|
||||
|
||||
Future<void> _fetchTenants() async {
|
||||
if (!_isEmailVerified) return;
|
||||
|
||||
try {
|
||||
final tenants = await AuthProxyService.getActiveTenants(
|
||||
email: _emailController.text.trim(),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_tenants = tenants;
|
||||
if (_tenants.isNotEmpty && _affiliationType == 'AFFILIATE') {
|
||||
// 목록이 있는데 아직 아무것도 선택되지 않았다면 자동 할당 가능
|
||||
_companyCode ??= _tenants.first['slug'];
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load tenants: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadPolicy() async {
|
||||
@@ -121,35 +136,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 이메일 입력 시 도메인 체크 로직
|
||||
// 이메일 입력 시 도메인 체크 로직 (자동 선택 제거)
|
||||
void _checkEmailAffiliation(String email) {
|
||||
if (!email.contains('@')) {
|
||||
if (_isAffiliateEmail) {
|
||||
setState(() {
|
||||
_isAffiliateEmail = false;
|
||||
_affiliationType = 'GENERAL';
|
||||
_companyCode = null;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final domain = email.split('@').last.toLowerCase();
|
||||
if (_affiliateDomains.containsKey(domain)) {
|
||||
setState(() {
|
||||
_isAffiliateEmail = true;
|
||||
_affiliationType = 'AFFILIATE';
|
||||
_companyCode = _affiliateDomains[domain];
|
||||
});
|
||||
} else {
|
||||
if (_isAffiliateEmail) {
|
||||
setState(() {
|
||||
_isAffiliateEmail = false;
|
||||
_affiliationType = 'GENERAL';
|
||||
_companyCode = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Note: We no longer auto-set _companyCode or _affiliationType based on domain
|
||||
// as per user requirement (same domain can belong to different affiliates).
|
||||
return;
|
||||
}
|
||||
|
||||
void _startTimer(String type) {
|
||||
@@ -225,18 +216,32 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
final code = _emailCodeController.text.trim();
|
||||
if (code.length != 6) return;
|
||||
try {
|
||||
final success = await AuthProxyService.verifySignupCode(
|
||||
final res = await AuthProxyService.verifySignupCode(
|
||||
_emailController.text.trim(),
|
||||
'email',
|
||||
code,
|
||||
);
|
||||
if (success) {
|
||||
if (res['success'] == true) {
|
||||
setState(() {
|
||||
_isEmailVerified = true;
|
||||
_emailTimer?.cancel();
|
||||
_emailSeconds = 0;
|
||||
_emailError = null;
|
||||
|
||||
if (res['isAffiliate'] == true) {
|
||||
_affiliationType = 'AFFILIATE';
|
||||
_isAffiliateLocked = true;
|
||||
} else {
|
||||
_affiliationType = 'GENERAL';
|
||||
_companyCode = null;
|
||||
_isAffiliateLocked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Only fetch tenants if it's an affiliate domain
|
||||
if (res['isAffiliate'] == true) {
|
||||
_fetchTenants();
|
||||
}
|
||||
} else {
|
||||
setState(
|
||||
() => _emailError = tr('msg.userfront.signup.email.code_mismatch'),
|
||||
@@ -278,12 +283,12 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
final code = _phoneCodeController.text.trim();
|
||||
if (code.length != 6) return;
|
||||
try {
|
||||
final success = await AuthProxyService.verifySignupCode(
|
||||
final res = await AuthProxyService.verifySignupCode(
|
||||
_phoneController.text.trim(),
|
||||
'phone',
|
||||
code,
|
||||
);
|
||||
if (success) {
|
||||
if (res['success'] == true) {
|
||||
setState(() {
|
||||
_isPhoneVerified = true;
|
||||
_phoneTimer?.cancel();
|
||||
@@ -1446,55 +1451,44 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
const SizedBox(height: 18),
|
||||
_buildProfileFieldGroup(
|
||||
title: tr('ui.userfront.signup.profile.affiliation_type'),
|
||||
description: _isAffiliateEmail
|
||||
? tr('msg.userfront.signup.profile.affiliate_hint')
|
||||
: '소속 유형과 회사 정보를 입력합니다.',
|
||||
description: '소속 유형과 회사 정보를 입력합니다.',
|
||||
isDesktop: isDesktop,
|
||||
trailing: _isAffiliateEmail
|
||||
? _buildAutoDetectedBadge()
|
||||
: null,
|
||||
trailing: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AbsorbPointer(
|
||||
absorbing: _isAffiliateEmail,
|
||||
child: Opacity(
|
||||
opacity: _isAffiliateEmail ? 0.7 : 1.0,
|
||||
child: DropdownButtonFormField<String>(
|
||||
key: ValueKey(_affiliationType),
|
||||
initialValue: _affiliationType,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr(
|
||||
'ui.userfront.signup.profile.affiliation_type',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'GENERAL',
|
||||
child: Text(
|
||||
tr('domain.affiliation.general'),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'AFFILIATE',
|
||||
child: Text(
|
||||
tr('domain.affiliation.affiliate'),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: _isAffiliateEmail
|
||||
? null
|
||||
: (val) {
|
||||
if (val == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_affiliationType = val;
|
||||
});
|
||||
},
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(_affiliationType),
|
||||
initialValue: _affiliationType,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr(
|
||||
'ui.userfront.signup.profile.affiliation_type',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'GENERAL',
|
||||
child: Text(tr('domain.affiliation.general')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'AFFILIATE',
|
||||
child: Text(tr('domain.affiliation.affiliate')),
|
||||
),
|
||||
],
|
||||
onChanged: _isAffiliateLocked
|
||||
? null
|
||||
: (val) {
|
||||
if (val == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_affiliationType = val;
|
||||
if (_affiliationType == 'GENERAL') {
|
||||
_companyCode = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
@@ -1504,67 +1498,23 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
children: [
|
||||
if (_affiliationType == 'AFFILIATE') ...[
|
||||
const SizedBox(height: 14),
|
||||
AbsorbPointer(
|
||||
absorbing: _isAffiliateEmail,
|
||||
child: Opacity(
|
||||
opacity: _isAffiliateEmail ? 0.7 : 1.0,
|
||||
child: DropdownButtonFormField<String>(
|
||||
key: ValueKey(_companyCode ?? 'none'),
|
||||
initialValue: _companyCode,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr(
|
||||
'ui.userfront.signup.profile.company',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'HANMAC',
|
||||
child: Text(
|
||||
tr('domain.company.hanmac'),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'SAMAN',
|
||||
child: Text(
|
||||
tr('domain.company.saman'),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'PTC',
|
||||
child: Text(
|
||||
tr(
|
||||
'domain.company.ptc',
|
||||
fallback: 'PTC',
|
||||
),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'JANGHEON',
|
||||
child: Text(
|
||||
tr('domain.company.jangheon'),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'BARON',
|
||||
child: Text(
|
||||
tr('domain.company.baron'),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'HALLA',
|
||||
child: Text(
|
||||
tr('domain.company.halla'),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: _isAffiliateEmail
|
||||
? null
|
||||
: (val) => setState(
|
||||
() => _companyCode = val,
|
||||
),
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(_companyCode ?? 'none'),
|
||||
initialValue: _companyCode,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr(
|
||||
'ui.userfront.signup.profile.company',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: _tenants.map((t) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: t['slug'],
|
||||
child: Text(t['name'] ?? t['slug']),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (val) =>
|
||||
setState(() => _companyCode = val),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -1608,9 +1558,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
}
|
||||
|
||||
Widget _buildProfileInfoNoticeCard({required bool isDesktop}) {
|
||||
final description = _isAffiliateEmail
|
||||
? '가족사 이메일이 확인되어 소속 유형이 자동으로 고정됩니다.'
|
||||
: '회원가입 후 사용할 기본 소속 정보를 입력합니다.';
|
||||
const description = '회원가입 후 사용할 기본 소속 정보를 입력합니다.';
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
@@ -1710,25 +1658,6 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAutoDetectedBadge() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEEF2FF),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: const Color(0xFFC7D2FE)),
|
||||
),
|
||||
child: const Text(
|
||||
'자동 선택',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF4338CA),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildPolicyDescription() {
|
||||
if (_isPolicyLoading) {
|
||||
return tr('msg.userfront.signup.policy.loading');
|
||||
|
||||
Reference in New Issue
Block a user