1
0
forked from baron/baron-sso

Merge origin/dev into dev

This commit is contained in:
2026-06-15 20:05:47 +09:00
67 changed files with 6933 additions and 3919 deletions

View File

@@ -1426,7 +1426,7 @@ jobs:
run: |
mkdir -p ../reports
set +e
pnpm test 2>&1 | tee ../reports/devfront-test.log
pnpm run test:ci 2>&1 | tee ../reports/devfront-test.log
test_exit_code=${PIPESTATUS[0]}
set -e
@@ -1442,7 +1442,7 @@ jobs:
echo "1. \`cd devfront\`"
echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
echo "3. \`pnpm exec playwright install --with-deps\`"
echo "4. \`pnpm test\`"
echo "4. \`pnpm run test:ci\`"
echo
echo "## Log Tail (last 200 lines)"
echo '```text'

View File

@@ -14,6 +14,8 @@ import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import { TenantFineGrainedPermissionsPage } from "../features/tenants/routes/TenantFineGrainedPermissionsPage";
import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
@@ -51,6 +53,10 @@ export const adminRoutes: RouteObject[] = [
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
{
path: "permissions-direct",
element: <TenantFineGrainedPermissionsPage />,
},
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
@@ -59,6 +65,10 @@ export const adminRoutes: RouteObject[] = [
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{
path: "relations",
element: <TenantFineGrainedPermissionsTab />,
},
],
},
{

View File

@@ -116,6 +116,7 @@ describe("admin AppLayout", () => {
"Ory SSOT System",
"Data Integrity",
"Users",
"권한 부여",
"Auth Guard",
"API Keys",
"Audit Logs",

View File

@@ -62,6 +62,12 @@ const staticNavItems: ShellSidebarNavItem[] = [
to: "/users",
icon: Users,
},
{
labelKey: "ui.admin.nav.permissions_direct",
labelFallback: "권한 부여",
to: "/permissions-direct",
icon: ShieldCheck,
},
{
labelKey: "ui.admin.nav.auth_guard",
labelFallback: "Auth Guard",
@@ -206,70 +212,72 @@ function AppLayout() {
...profile,
role: effectiveRole ?? profile?.role,
});
const filteredItems = items.filter((item) => {
if (item.to === "/api-keys") return isSuperAdmin;
return true;
});
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
{ includeInternal: true },
);
if (isSuperAdmin) {
filteredItems.splice(1, 0, {
// Splice optional menus in a standard order
items.splice(1, 0, {
labelKey: "ui.admin.nav.tenants",
labelFallback: "Tenants",
to: "/tenants",
icon: Building2,
});
filteredItems.splice(2, 0, {
items.splice(2, 0, {
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
if (showWorksmobile) {
filteredItems.splice(3, 0, {
items.splice(3, 0, {
labelKey: "ui.admin.nav.worksmobile",
labelFallback: "Worksmobile",
to: "/worksmobile",
icon: LineWorksNavIcon,
});
}
filteredItems.splice(4, 0, {
items.splice(4, 0, {
labelKey: "ui.admin.nav.ory_ssot",
labelFallback: "Ory SSOT System",
to: "/system/ory-ssot",
icon: Database,
});
filteredItems.splice(5, 0, {
items.splice(5, 0, {
labelKey: "ui.admin.nav.data_integrity",
labelFallback: "Data Integrity",
to: "/system/data-integrity",
icon: ShieldCheck,
});
} else {
// Non-superadmins
filteredItems.splice(1, 0, {
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
if (showWorksmobile) {
filteredItems.splice(2, 0, {
labelKey: "ui.admin.nav.worksmobile",
labelFallback: "Worksmobile",
to: "/worksmobile",
icon: LineWorksNavIcon,
});
}
const permissions = profile?.systemPermissions;
return items.filter((item) => {
// Super Admin ALWAYS bypasses and gets full access to everything
if (isSuperAdmin) {
if (item.to === "/worksmobile") return showWorksmobile;
return true;
}
return filteredItems;
// For others, check their fine-grained systemPermissions
if (!permissions) return false;
if (item.to === "/") return permissions.overview;
if (item.to === "/users") return permissions.users;
if (item.to === "/auth") return permissions.auth_guard;
if (item.to === "/api-keys") return permissions.api_keys;
if (item.to === "/audit-logs") return permissions.audit_logs;
if (item.to === "/permissions-direct") return false;
if (item.to === "/tenants") return permissions.tenants;
if (item.to === orgfrontUrl) return permissions.org_chart;
if (item.to === "/worksmobile")
return permissions.worksmobile && showWorksmobile;
if (item.to === "/system/ory-ssot") return permissions.ory_ssot;
if (item.to === "/system/data-integrity")
return permissions.data_integrity;
return true;
});
}, [profile]);
const handleLogout = () => {

View File

@@ -29,6 +29,7 @@ const members = [
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ id: "admin-user", role: "super_admin" })),
fetchTenant: vi.fn(async () => tenant),
fetchUsers: vi.fn(async () => ({
items: [

View File

@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab";
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
const exportUsersCSVMock = vi.hoisted(() =>
@@ -94,12 +95,29 @@ vi.mock("react-oidc-context", () => ({
}));
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => users[0]),
fetchTenant: vi.fn(async (tenantId) => ({
id: tenantId,
name: "Test Tenant",
slug: "test-tenant",
userPermissions: { view: true, manage: true, manage_admins: true },
})),
fetchTenantOwners: vi.fn(async () => [users[0]]),
fetchTenantAdmins: vi.fn(async () => [users[1]]),
addTenantOwner: vi.fn(async () => undefined),
addTenantAdmin: vi.fn(async () => undefined),
removeTenantOwner: vi.fn(async () => undefined),
removeTenantAdmin: vi.fn(async () => undefined),
fetchTenantRelations: vi.fn(async () => [
{
userId: "user-relation-1",
name: "Relation User",
email: "relation@example.com",
relations: ["profile_managers", "schema_viewers"],
},
]),
addTenantRelation: vi.fn(async () => undefined),
removeTenantRelation: vi.fn(async () => undefined),
fetchUsers: vi.fn(async () => ({
items: users,
total: users.length,
@@ -160,6 +178,22 @@ describe("admin tenant tab coverage smoke", () => {
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
});
it("renders tenant fine-grained relations list", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/relations"
element={<TenantFineGrainedPermissionsTab />}
/>
</Routes>,
"/tenants/tenant-company/relations",
);
expect(await screen.findByText("Relation User")).toBeInTheDocument();
expect(screen.getByText("relation@example.com")).toBeInTheDocument();
expect(screen.getByText("세부 권한 설정 (Fine-grained Permissions)")).toBeInTheDocument();
});
it("renders tenant hierarchy and selected organization members", async () => {
renderWithProviders(
<Routes>

View File

@@ -29,6 +29,7 @@ type DomainTagInputProps = {
confirmedConflicts?: string[];
onConfirmedConflictsChange?: (domains: string[]) => void;
placeholder?: string;
disabled?: boolean;
};
export function DomainTagInput({
@@ -40,6 +41,7 @@ export function DomainTagInput({
confirmedConflicts = [],
onConfirmedConflictsChange,
placeholder,
disabled = false,
}: DomainTagInputProps) {
const [input, setInput] = useState("");
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
@@ -107,6 +109,7 @@ export function DomainTagInput({
className="gap-1 rounded-md"
>
<span>{domain}</span>
{!disabled && (
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
@@ -115,6 +118,7 @@ export function DomainTagInput({
>
<X size={12} />
</button>
)}
</Badge>
))}
<Input
@@ -133,6 +137,7 @@ export function DomainTagInput({
tokenizeInput();
}
}}
disabled={disabled}
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
placeholder={value.length === 0 ? placeholder : undefined}
/>

View File

@@ -36,6 +36,7 @@ type ParentTenantSelectorProps = {
localTenantFilter?: (tenant: TenantSummary) => boolean;
compact?: boolean;
controlTestId?: string;
disabled?: boolean;
};
export function ParentTenantSelector({
@@ -55,6 +56,7 @@ export function ParentTenantSelector({
localTenantFilter,
compact = false,
controlTestId,
disabled = false,
}: ParentTenantSelectorProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [localPickerOpen, setLocalPickerOpen] = useState(false);
@@ -117,6 +119,7 @@ export function ParentTenantSelector({
variant="outline"
size="sm"
className={compact ? "h-8 shrink-0 px-2" : undefined}
disabled={disabled}
>
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
@@ -147,7 +150,12 @@ export function ParentTenantSelector({
{localPickerLabel && (
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
>
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
@@ -234,6 +242,7 @@ export function ParentTenantSelector({
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
onClick={() => onChange("")}
aria-label={noneLabel}
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>

View File

@@ -0,0 +1,29 @@
import type React from "react";
import {
type TenantPermissionKey,
useTenantPermission,
} from "../hooks/useTenantPermission";
interface TenantPermissionGuardProps {
tenantId: string;
relation: TenantPermissionKey;
fallback?: React.ReactNode;
children: React.ReactNode;
}
export function TenantPermissionGuard({
tenantId,
relation,
fallback = null,
children,
}: TenantPermissionGuardProps) {
const { hasPermission, isLoading } = useTenantPermission(tenantId);
if (isLoading) return null;
if (!hasPermission(relation)) {
return <>{fallback}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,133 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, renderHook, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { describe, expect, it, vi } from "vitest";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
import { useTenantPermission } from "./useTenantPermission";
vi.mock("../../../lib/adminApi", () => ({
fetchMe: vi.fn(),
fetchTenant: vi.fn(),
}));
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
describe("useTenantPermission", () => {
it("returns true for all permissions if user is super_admin", async () => {
vi.mocked(fetchMe).mockResolvedValue({
id: "user-super",
role: "super_admin",
} as any);
vi.mocked(fetchTenant).mockResolvedValue({
id: "tenant-1",
name: "Super Tenant",
userPermissions: { view: false, manage: false, manage_admins: false },
} as any);
const { result } = renderHook(() => useTenantPermission("tenant-1"), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasPermission("view")).toBe(true);
expect(result.current.hasPermission("manage")).toBe(true);
expect(result.current.hasPermission("manage_admins")).toBe(true);
});
it("returns permissions mapped from userPermissions for normal admins/users", async () => {
vi.mocked(fetchMe).mockResolvedValue({
id: "user-admin",
role: "tenant_admin",
} as any);
vi.mocked(fetchTenant).mockResolvedValue({
id: "tenant-2",
name: "Tenant Admin Corp",
userPermissions: { view: true, manage: true, manage_admins: false },
} as any);
const { result } = renderHook(() => useTenantPermission("tenant-2"), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasPermission("view")).toBe(true);
expect(result.current.hasPermission("manage")).toBe(true);
expect(result.current.hasPermission("manage_admins")).toBe(false);
});
});
describe("TenantPermissionGuard", () => {
it("renders children when user has permission", async () => {
vi.mocked(fetchMe).mockResolvedValue({
id: "user-admin",
role: "tenant_admin",
} as any);
vi.mocked(fetchTenant).mockResolvedValue({
id: "tenant-3",
userPermissions: { view: true, manage: true, manage_admins: false },
} as any);
render(
<TenantPermissionGuard
tenantId="tenant-3"
relation="manage"
fallback={<div>Access Denied</div>}
>
<div>Access Granted</div>
</TenantPermissionGuard>,
{ wrapper: createWrapper() },
);
await waitFor(() => {
expect(screen.getByText("Access Granted")).toBeInTheDocument();
});
expect(screen.queryByText("Access Denied")).not.toBeInTheDocument();
});
it("renders fallback when user lacks permission", async () => {
vi.mocked(fetchMe).mockResolvedValue({
id: "user-admin",
role: "tenant_admin",
} as any);
vi.mocked(fetchTenant).mockResolvedValue({
id: "tenant-4",
userPermissions: { view: true, manage: false, manage_admins: false },
} as any);
render(
<TenantPermissionGuard
tenantId="tenant-4"
relation="manage"
fallback={<div>Access Denied</div>}
>
<div>Access Granted</div>
</TenantPermissionGuard>,
{ wrapper: createWrapper() },
);
await waitFor(() => {
expect(screen.getByText("Access Denied")).toBeInTheDocument();
});
expect(screen.queryByText("Access Granted")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { normalizeAdminRole } from "../../../lib/roles";
export type TenantPermissionKey =
| "view"
| "manage"
| "manage_admins"
| "view_profile"
| "manage_profile"
| "view_permissions"
| "manage_permissions"
| "view_organization"
| "manage_organization"
| "view_schema"
| "manage_schema";
export function useTenantPermission(tenantId: string) {
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const { data: tenant } = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: !!tenantId,
});
const hasPermission = (requiredRelation: TenantPermissionKey): boolean => {
// Super Admin always has full bypass access
if (normalizeAdminRole(profile?.role) === "super_admin") {
return true;
}
return !!tenant?.userPermissions?.[requiredRelation];
};
return { hasPermission, isLoading: !tenant };
}

View File

@@ -49,6 +49,7 @@ import {
type TenantAdmin,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
type DialogMode = "owner" | "admin";
@@ -69,6 +70,10 @@ export function TenantAdminsAndOwnersTab() {
const _currentUserId = auth.user?.profile.sub;
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const { hasPermission } = useTenantPermission(tenantId);
const isWritable =
hasPermission("manage_permissions") || hasPermission("manage_admins");
const canView = hasPermission("view_permissions") || hasPermission("view");
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
@@ -338,6 +343,16 @@ export function TenantAdminsAndOwnersTab() {
if (!tenantId) return null;
if (!canView) {
return (
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
<h3 className="text-xl font-bold text-destructive">
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
</h3>
</div>
);
}
const serverOwners = ownersQuery.data || [];
const serverAdmins = adminsQuery.data || [];
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
@@ -362,7 +377,7 @@ export function TenantAdminsAndOwnersTab() {
);
return (
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
{/* Owners Card */}
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
@@ -382,6 +397,7 @@ export function TenantAdminsAndOwnersTab() {
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("owner")}
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
@@ -471,6 +487,7 @@ export function TenantAdminsAndOwnersTab() {
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("admin")}
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}

View File

@@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query";
import { Copy } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { useTenantPermission } from "../hooks/useTenantPermission";
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();
@@ -17,13 +17,7 @@ function TenantDetailPage() {
enabled: tenantId.length > 0,
});
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema = profileRole === "super_admin";
const { hasPermission } = useTenantPermission(tenantId);
const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization");
@@ -110,7 +104,7 @@ function TenantDetailPage() {
>
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</Link>
{canAccessSchema && (
{hasPermission("view") && (
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
@@ -122,6 +116,18 @@ function TenantDetailPage() {
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
)}
{hasPermission("view") && (
<Link
to={`/tenants/${tenantId}/relations`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
location.pathname.includes("/relations")
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_relations", "세부 권한")}
</Link>
)}
</div>
{/* Outlet for nested routes */}

View File

@@ -0,0 +1,860 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Building2,
Database,
Key,
KeyRound,
LayoutDashboard,
Network,
NotebookTabs,
Plus,
Search,
Share2,
Shield,
ShieldCheck,
Trash2,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Avatar, AvatarFallback } from "../../../components/ui/avatar";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import { Card, CardContent } from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { toast } from "../../../components/ui/use-toast";
import {
addSystemRelation,
fetchAllTenants,
fetchMe,
fetchSystemRelations,
fetchUsers,
removeSystemRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function TenantFineGrainedPermissionsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [activeTab, _setActiveTab] = useState<"tenant" | "system">("system");
const [_selectedTenantId, _setSelectedTenantId] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeUserId, setActiveUserId] = useState<string | null>(null);
const [userSearchTerm, setUserSearchTerm] = useState("");
// 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
const [localSystemPermissions, setLocalSystemPermissions] = useState<
Record<string, Record<string, "none" | "read" | "write">>
>({});
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const isSuperAdmin = profile?.role === "super_admin";
const tenantsQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
enabled: isSuperAdmin,
});
const _tenants = isSuperAdmin
? (tenantsQuery.data?.items ?? [])
: (profile?.manageableTenants ?? []);
// System Relations (Admin Control) Queries & Mutations
const systemRelationsQuery = useQuery({
queryKey: ["system-relations"],
queryFn: fetchSystemRelations,
enabled: isSuperAdmin && activeTab === "system",
});
const systemRelations = systemRelationsQuery.data ?? [];
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
useEffect(() => {
if (systemRelationsQuery.data) {
const initialMap: Record<
string,
Record<string, "none" | "read" | "write">
> = {};
for (const user of systemRelationsQuery.data) {
initialMap[user.userId] = {};
const menus = [
"overview",
"audit_logs",
"tenants",
"org_chart",
"users",
"worksmobile",
"api_keys",
"ory_ssot",
"data_integrity",
"auth_guard",
"permissions_direct",
];
for (const m of menus) {
const isWrite = user.relations.includes(`${m}_managers`);
const isRead = user.relations.includes(`${m}_viewers`);
initialMap[user.userId][m] = isWrite
? "write"
: isRead
? "read"
: "none";
}
}
setLocalSystemPermissions(initialMap);
}
}, [systemRelationsQuery.data]);
const addSystemRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
addSystemRelation(payload.userId, payload.relation),
onMutate: async (newRelation) => {
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"system-relations",
]);
queryClient.setQueryData<TenantRelation[]>(
["system-relations"],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(
["system-relations"],
context.previousRelations,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
},
});
const removeSystemRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
removeSystemRelation(payload.userId, payload.relation),
onMutate: async (targetRelation) => {
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"system-relations",
]);
queryClient.setQueryData<TenantRelation[]>(
["system-relations"],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter(
(r) => r !== targetRelation.relation,
),
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(
["system-relations"],
context.previousRelations,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
},
});
const handleSystemRelationChange = async (
userId: string,
menuKey: string,
currentVal: "none" | "read" | "write",
newVal: "none" | "read" | "write",
) => {
if (currentVal === newVal) return;
try {
if (currentVal === "read") {
await removeSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_viewers`,
});
} else if (currentVal === "write") {
await removeSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_managers`,
});
}
if (newVal === "read") {
await addSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_viewers`,
});
} else if (newVal === "write") {
await addSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_managers`,
});
}
// 🌟 Trigger a single consolidated success toast at the very end
toast.success(
t(
"msg.admin.system.relations.update_success",
"시스템 메뉴 권한이 성공적으로 변경되었습니다.",
),
);
} catch {
// Individual mutations handle error toast via onError
}
};
const handleRemoveAllSystemRelations = async (
userId: string,
userRelations: string[],
) => {
if (
!window.confirm(
t(
"msg.admin.system.relations.remove_all_confirm",
"이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?",
),
)
) {
return;
}
for (const rel of userRelations) {
await removeSystemRelationMutation.mutateAsync({ userId, relation: rel });
}
if (activeUserId === userId) {
setActiveUserId(null);
}
};
const usersQuery = useQuery({
queryKey: ["admin-users-search", searchTerm],
queryFn: () => fetchUsers(20, 0, searchTerm),
enabled: isDialogOpen && searchTerm.length >= 2,
});
const handleAddSystemUser = (userId: string) => {
addSystemRelationMutation.mutate({ userId, relation: "overview_viewers" });
setActiveUserId(userId);
setIsDialogOpen(false);
setSearchTerm("");
};
const searchResults = usersQuery.data?.items || [];
// Categorized system menus with descriptions and icons
const systemMenuCategories = [
{
title: t(
"ui.admin.permissions_direct.cat_dashboard",
"핵심 대시보드 및 분석",
),
menus: [
{
label: t("ui.admin.nav.overview", "개요"),
relation: "overview",
desc: t(
"msg.admin.permissions_direct.desc_overview",
"바론 전체 사양 및 시스템 상태 개요 정보",
),
icon: LayoutDashboard,
},
{
label: t("ui.admin.nav.audit_logs", "감사 로그"),
relation: "audit_logs",
desc: t(
"msg.admin.permissions_direct.desc_audit_logs",
"시스템 전역 보안 감사 및 접속 이력 로그",
),
icon: NotebookTabs,
},
],
},
{
title: t("ui.admin.permissions_direct.cat_resources", "핵심 리소스 관리"),
menus: [
{
label: t("ui.admin.nav.tenants", "테넌트"),
relation: "tenants",
desc: t(
"msg.admin.permissions_direct.desc_tenants",
"고객 테넌트 목록, 신규 부모-자식 테넌트 관리",
),
icon: Building2,
},
{
label: t("ui.admin.nav.org_chart", "조직도"),
relation: "org_chart",
desc: t(
"msg.admin.permissions_direct.desc_org_chart",
"조직도 가시화 및 트리 배치 확인",
),
icon: Network,
},
{
label: t("ui.admin.nav.users", "사용자"),
relation: "users",
desc: t(
"msg.admin.permissions_direct.desc_users",
"가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입",
),
icon: Users,
},
],
},
{
title: t(
"ui.admin.permissions_direct.cat_integrations",
"인프라 연동 및 보안",
),
menus: [
{
label: t("ui.admin.nav.worksmobile", "Worksmobile"),
relation: "worksmobile",
desc: t(
"msg.admin.permissions_direct.desc_worksmobile",
"라인웍스 연동 및 사내 임직원 패스워드 강제 동기화",
),
icon: Share2,
},
{
label: t("ui.admin.nav.api_keys", "API 키"),
relation: "api_keys",
desc: t(
"msg.admin.permissions_direct.desc_api_keys",
"조직도 연동을 위한 전역 서드파티 토큰 관리",
),
icon: Key,
},
],
},
{
title: t(
"ui.admin.permissions_direct.cat_system",
"아이덴티티 및 게이트 관리",
),
menus: [
{
label: t("ui.admin.nav.ory_ssot", "Ory SSOT 시스템"),
relation: "ory_ssot",
desc: t(
"msg.admin.permissions_direct.desc_ory_ssot",
"Redis 아이덴티티 미러 캐시 및 PostgreSQL read model 정합성 갱신",
),
icon: Database,
},
{
label: t("ui.admin.nav.data_integrity", "데이터 정합성"),
relation: "data_integrity",
desc: t(
"msg.admin.permissions_direct.desc_data_integrity",
"고아 레코드 검출 및 DB 정합성 최종 검증기",
),
icon: ShieldCheck,
},
{
label: t("ui.admin.nav.auth_guard", "인증 가드"),
relation: "auth_guard",
desc: t(
"msg.admin.permissions_direct.desc_auth_guard",
"정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터",
),
icon: KeyRound,
},
{
label: t("ui.admin.nav.permissions_direct", "권한 부여"),
relation: "permissions_direct",
desc: t(
"msg.admin.permissions_direct.desc_permissions_direct",
"본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널",
),
icon: Shield,
},
],
},
];
const filteredRelations = systemRelations.filter(
(r) =>
r.name.toLowerCase().includes(userSearchTerm.toLowerCase()) ||
r.email.toLowerCase().includes(userSearchTerm.toLowerCase()),
);
const selectedUser = systemRelations.find((r) => r.userId === activeUserId);
if (profile && !isSuperAdmin) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<ShieldCheck className="h-8 w-8 text-primary" />
{t("ui.admin.nav.permissions_direct", "권한 부여")}
</h1>
<p className="text-muted-foreground">
{t(
"msg.admin.permissions_direct.description",
"테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다.",
)}
</p>
</div>
{/* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */}
<div className="flex flex-col lg:flex-row gap-6 h-[720px] border border-border rounded-xl bg-card overflow-hidden shadow-sm">
{/* Left Panel: User List */}
<div className="w-full lg:w-80 border-r border-border flex flex-col bg-muted/10 h-full">
<div className="p-4 border-b border-border space-y-3 flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm text-foreground">
{t("ui.admin.permissions_direct.user_list", "대상 사용자")} (
{filteredRelations.length})
</h3>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => setIsDialogOpen(true)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "이름 또는 이메일 검색...")}
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
name="user-search"
className="pl-8 h-8 text-xs"
/>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{filteredRelations.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
{t(
"msg.admin.permissions_direct.no_users_found",
"등록된 사용자가 없습니다.",
)}
</div>
) : (
filteredRelations.map((user) => {
const isSelected = activeUserId === user.userId;
const activeCount = user.relations.length;
return (
<button
type="button"
key={user.userId}
onClick={() => setActiveUserId(user.userId)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-all ${
isSelected
? "bg-primary/10 text-primary border-l-4 border-primary shadow-sm"
: "hover:bg-muted/50 text-foreground"
}`}
>
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-8 w-8 border border-border">
<AvatarFallback className="bg-primary/10 text-primary font-bold text-xs uppercase">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="text-sm font-semibold truncate">
{user.name}
</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[150px]">
{user.email}
</span>
</div>
</div>
<Badge
variant={isSelected ? "default" : "secondary"}
className="text-[9px] px-1.5 py-0.5"
>
{activeCount}
</Badge>
</button>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Right Panel: Toggle settings grid */}
<div className="flex-1 flex flex-col h-full bg-background">
{selectedUser ? (
<>
{/* User Detail Header */}
<div className="p-5 border-b border-border flex items-center justify-between flex-shrink-0 bg-muted/5">
<div className="flex items-center gap-4">
<Avatar className="h-11 w-11 border">
<AvatarFallback className="bg-primary/5 text-primary font-extrabold text-sm uppercase">
{selectedUser.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<h2 className="text-lg font-bold flex items-center gap-2">
{selectedUser.name}
<Badge variant="outline" className="text-xs font-normal">
{selectedUser.relations.length}{" "}
{t("ui.admin.permissions_direct.allowed", "개 허용됨")}
</Badge>
</h2>
<span className="text-xs text-muted-foreground">
{selectedUser.email}
</span>
</div>
</div>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/20 hover:bg-destructive/10"
onClick={() =>
handleRemoveAllSystemRelations(
selectedUser.userId,
selectedUser.relations,
)
}
>
<Trash2 className="h-4 w-4 mr-2" />
{t(
"ui.admin.permissions_direct.revoke_all",
"모든 권한 회수",
)}
</Button>
</div>
{/* Categorized Toggle Grid */}
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
{systemMenuCategories.map((category) => (
<div key={category.title} className="space-y-3">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{category.title}
</h4>
<Card className="border border-border/60 shadow-none bg-card">
<CardContent className="p-0 divide-y divide-border/40">
{category.menus.map((menu) => {
const isWrite = selectedUser.relations.includes(
`${menu.relation}_managers`,
);
const isRead = selectedUser.relations.includes(
`${menu.relation}_viewers`,
);
const serverValue: "none" | "read" | "write" =
isWrite ? "write" : isRead ? "read" : "none";
const permissionValue =
localSystemPermissions[selectedUser.userId]?.[
menu.relation
] ?? serverValue;
const Icon = menu.icon;
return (
<div
key={menu.relation}
className="flex items-center justify-between p-4 hover:bg-muted/10 transition-colors"
>
<div className="flex items-start gap-4 pr-4 min-w-0">
<div className="p-2 rounded-lg bg-secondary/50 text-foreground flex-shrink-0 mt-0.5">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{menu.label}
</span>
{(menu.relation === "ory_ssot" ||
menu.relation === "data_integrity") && (
<Badge
variant="secondary"
className="text-[10px] py-0.5 px-1.5 font-semibold text-destructive bg-destructive/10 border-destructive/20"
>
{t(
"ui.admin.permissions_direct.super_admin_only",
"Super Admin 전용",
)}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground line-clamp-1">
{menu.desc}
</span>
</div>
</div>
<select
name={`system-menu-permission-${menu.relation}`}
value={permissionValue}
disabled={
menu.relation === "ory_ssot" ||
menu.relation === "data_integrity"
}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
// 🌟 1단계: 로컬 임시 상태 즉시 갱신 (0ms 반응 보장)
setLocalSystemPermissions((prev) => ({
...prev,
[selectedUser.userId]: {
...(prev[selectedUser.userId] ?? {}),
[menu.relation]: nextVal,
},
}));
// 🌟 2단계: 백그라운드 비동기 API 요청 수행
handleSystemRelationChange(
selectedUser.userId,
menu.relation,
permissionValue,
nextVal,
);
}}
className="flex h-9 w-[180px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</div>
);
})}
</CardContent>
</Card>
</div>
))}
</div>
</ScrollArea>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center text-muted-foreground bg-muted/5 gap-3">
<ShieldCheck className="h-12 w-12 text-muted-foreground opacity-30" />
<div>
<h3 className="text-sm font-semibold text-foreground">
{t(
"ui.admin.permissions_direct.no_user_selected",
"사용자가 선택되지 않았습니다.",
)}
</h3>
<p className="text-xs mt-1">
{t(
"msg.admin.permissions_direct.no_user_selected_desc",
"왼쪽의 사용자 리스트에서 권한을 변경할 인원을 선택해 주세요.",
)}
</p>
</div>
</div>
)}
</div>
</div>
{/* User Search Dialog for System relations */}
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
setIsDialogOpen(false);
setSearchTerm("");
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{t(
"ui.admin.permissions_direct.dialog_title_system",
"시스템 권한 관리 유저 추가",
)}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.tenants.admins.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.admins.dialog_search_placeholder",
"사용자 검색 (최소 2자)...",
)}
className="pl-10 h-11"
autoFocus
value={searchTerm}
name="system-user-dialog-search"
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
{searchTerm.length < 2 ? (
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p className="text-sm">
{t(
"ui.admin.tenants.admins.dialog_search_hint",
"검색어를 입력해 주세요.",
)}
</p>
</div>
) : usersQuery.isLoading ? (
<div className="p-10 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</div>
) : searchResults.length === 0 ? (
<div className="p-10 text-center text-muted-foreground">
{t(
"ui.admin.tenants.admins.dialog_no_results",
"검색 결과가 없습니다.",
)}
</div>
) : (
<div className="divide-y divide-border">
{searchResults.map((user) => {
const isAlreadyInMatrix = systemRelations.some(
(r) => r.userId === user.id,
);
return (
<div
key={user.id}
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{user.name.charAt(0)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.name}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
<Button
size="sm"
variant={isAlreadyInMatrix ? "ghost" : "outline"}
disabled={
isAlreadyInMatrix ||
addSystemRelationMutation.isPending
}
onClick={() => handleAddSystemUser(user.id)}
>
{isAlreadyInMatrix ? (
<Badge variant="secondary" className="font-normal">
{t(
"ui.admin.tenants.relations.already_added",
"이미 추가됨",
)}
</Badge>
) : (
<>
<Plus className="h-3 w-3 mr-1" />{" "}
{t("ui.common.add", "추가")}
</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,711 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
addTenantRelation,
fetchTenantRelations,
fetchUsers,
removeTenantRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
interface TenantFineGrainedPermissionsTabProps {
tenantIdProp?: string;
}
export function TenantFineGrainedPermissionsTab({
tenantIdProp,
}: TenantFineGrainedPermissionsTabProps = {}) {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdProp || tenantIdParam || "";
const { hasPermission } = useTenantPermission(tenantId);
const isWritable = hasPermission("manage_admins");
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
// 🌟 테넌트 탭별 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
const [localTenantPermissions, setLocalTenantPermissions] = useState<
Record<string, Record<string, "none" | "read" | "write">>
>({});
const relationsQuery = useQuery({
queryKey: ["tenant-relations", tenantId],
queryFn: () => fetchTenantRelations(tenantId),
enabled: !!tenantId,
});
const _relationsData = relationsQuery.data ?? [];
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
useEffect(() => {
if (relationsQuery.data) {
const initialMap: Record<
string,
Record<string, "none" | "read" | "write">
> = {};
for (const user of relationsQuery.data) {
initialMap[user.userId] = {};
const tabs = ["profile", "permissions", "organization", "schema"];
for (const tab of tabs) {
const isWrite = user.relations.includes(`${tab}_managers`);
const isRead = user.relations.includes(`${tab}_viewers`);
initialMap[user.userId][tab] = isWrite
? "write"
: isRead
? "read"
: "none";
}
}
setLocalTenantPermissions(initialMap);
}
}, [relationsQuery.data]);
const relations = relationsQuery.data ?? [];
const invalidateAllQueries = () => {
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-relations", tenantId],
});
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
};
const addRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
addTenantRelation(tenantId, payload.userId, payload.relation),
onMutate: async (newRelation) => {
await queryClient.cancelQueries({
queryKey: ["tenant-relations", tenantId],
});
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"tenant-relations",
tenantId,
]);
queryClient.setQueryData<TenantRelation[]>(
["tenant-relations", tenantId],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(
["tenant-relations", tenantId],
context.previousRelations,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
},
});
const removeRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
removeTenantRelation(tenantId, payload.userId, payload.relation),
onMutate: async (targetRelation) => {
await queryClient.cancelQueries({
queryKey: ["tenant-relations", tenantId],
});
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"tenant-relations",
tenantId,
]);
queryClient.setQueryData<TenantRelation[]>(
["tenant-relations", tenantId],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter(
(r) => r !== targetRelation.relation,
),
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(
["tenant-relations", tenantId],
context.previousRelations,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
},
});
const handleRelationChange = async (
userId: string,
tab: "profile" | "permissions" | "organization" | "schema",
currentVal: "none" | "read" | "write",
newVal: "none" | "read" | "write",
) => {
const readRel = `${tab}_viewers`;
const writeRel = `${tab}_managers`;
if (currentVal === newVal) return;
try {
if (currentVal === "read") {
await removeRelationMutation.mutateAsync({ userId, relation: readRel });
} else if (currentVal === "write") {
await removeRelationMutation.mutateAsync({
userId,
relation: writeRel,
});
}
if (newVal === "read") {
await addRelationMutation.mutateAsync({ userId, relation: readRel });
} else if (newVal === "write") {
await addRelationMutation.mutateAsync({ userId, relation: writeRel });
}
invalidateAllQueries();
// 🌟 Trigger a single consolidated success toast at the very end
toast.success(
t(
"msg.admin.tenants.relations.update_success",
"세부 권한이 성공적으로 변경되었습니다.",
),
);
} catch {
// Individual mutations handle error toast via onError
}
};
const handleRemoveAllRelations = async (
userId: string,
userRelations: string[],
) => {
if (
!window.confirm(
t(
"msg.admin.tenants.relations.remove_all_confirm",
"이 사용자의 모든 세부 권한을 삭제하시겠습니까?",
),
)
) {
return;
}
for (const rel of userRelations) {
await removeRelationMutation.mutateAsync({ userId, relation: rel });
}
invalidateAllQueries();
};
const usersQuery = useQuery({
queryKey: ["admin-users-search", searchTerm],
queryFn: () => fetchUsers(20, 0, searchTerm),
enabled: isDialogOpen && searchTerm.length >= 2,
});
const handleAddUser = (userId: string) => {
addRelationMutation.mutate(
{ userId, relation: "profile_viewers" },
{
onSettled: () => {
invalidateAllQueries();
},
},
);
setIsDialogOpen(false);
setSearchTerm("");
};
if (!tenantId) return null;
const searchResults = usersQuery.data?.items || [];
return (
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t(
"ui.admin.tenants.relations.title",
"세부 권한 설정 (Fine-grained Permissions)",
)}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.relations.subtitle",
"사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setIsDialogOpen(true)}
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t(
"ui.admin.tenants.relations.add_button",
"세부 권한 사용자 추가",
)}
</Button>
</CardHeader>
<CardContent className="pt-0">
<div className="rounded-md border border-border overflow-hidden">
<Table>
<TableHeader className="bg-secondary/40">
<TableRow>
<TableHead className="font-bold">
{t("ui.common.name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_permissions", "권한 관리")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</TableHead>
<TableHead className="font-bold text-center w-20">
{t("ui.common.action", "작업")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{relations.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center py-12 text-muted-foreground"
>
{t(
"msg.admin.tenants.relations.empty",
"세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요.",
)}
</TableCell>
</TableRow>
) : (
relations.map((user) => {
const profileVal = user.relations.includes(
"profile_managers",
)
? "write"
: user.relations.includes("profile_viewers")
? "read"
: "none";
const permissionsVal = user.relations.includes(
"permissions_managers",
)
? "write"
: user.relations.includes("permissions_viewers")
? "read"
: "none";
const organizationVal = user.relations.includes(
"organization_managers",
)
? "write"
: user.relations.includes("organization_viewers")
? "read"
: "none";
const schemaVal = user.relations.includes("schema_managers")
? "write"
: user.relations.includes("schema_viewers")
? "read"
: "none";
const curProfileVal =
localTenantPermissions[user.userId]?.profile ??
profileVal;
const curPermissionsVal =
localTenantPermissions[user.userId]?.permissions ??
permissionsVal;
const curOrganizationVal =
localTenantPermissions[user.userId]?.organization ??
organizationVal;
const curSchemaVal =
localTenantPermissions[user.userId]?.schema ?? schemaVal;
return (
<TableRow
key={user.userId}
className="hover:bg-muted/10 transition-colors"
>
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="font-semibold text-foreground">
{user.name}
</span>
<span className="text-xs text-muted-foreground italic">
{user.email}
</span>
</div>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curProfileVal}
disabled={!isWritable}
name={`tenant-fine-grained-profile-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
profile: nextVal,
},
}));
handleRelationChange(
user.userId,
"profile",
profileVal,
nextVal,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curPermissionsVal}
disabled={!isWritable}
name={`tenant-fine-grained-permissions-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
permissions: nextVal,
},
}));
handleRelationChange(
user.userId,
"permissions",
permissionsVal,
nextVal,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curOrganizationVal}
disabled={!isWritable}
name={`tenant-fine-grained-organization-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
organization: nextVal,
},
}));
handleRelationChange(
user.userId,
"organization",
organizationVal,
nextVal,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curSchemaVal}
disabled={!isWritable}
name={`tenant-fine-grained-schema-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
schema: nextVal,
},
}));
handleRelationChange(
user.userId,
"schema",
schemaVal,
nextVal,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
disabled={!isWritable}
onClick={() =>
handleRemoveAllRelations(
user.userId,
user.relations,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Common Dialog for adding users */}
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
setIsDialogOpen(false);
setSearchTerm("");
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{t(
"ui.admin.tenants.relations.dialog_title",
"세부 권한 관리 유저 추가",
)}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.tenants.admins.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.admins.dialog_search_placeholder",
"사용자 검색 (최소 2자)...",
)}
className="pl-10 h-11"
autoFocus
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
{searchTerm.length < 2 ? (
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p className="text-sm">
{t(
"ui.admin.tenants.admins.dialog_search_hint",
"검색어를 입력해 주세요.",
)}
</p>
</div>
) : usersQuery.isLoading ? (
<div className="p-10 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</div>
) : searchResults.length === 0 ? (
<div className="p-10 text-center text-muted-foreground">
{t(
"ui.admin.tenants.admins.dialog_no_results",
"검색 결과가 없습니다.",
)}
</div>
) : (
<div className="divide-y divide-border">
{searchResults.map((user) => {
const isAlreadyInMatrix = relations.some(
(r) => r.userId === user.id,
);
return (
<div
key={user.id}
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{user.name.charAt(0)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.name}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
<Button
size="sm"
variant={isAlreadyInMatrix ? "ghost" : "outline"}
disabled={
isAlreadyInMatrix || addRelationMutation.isPending
}
onClick={() => handleAddUser(user.id)}
>
{isAlreadyInMatrix ? (
<Badge variant="secondary" className="font-normal">
{t(
"ui.admin.tenants.relations.already_added",
"이미 추가됨",
)}
</Badge>
) : (
<>
<Plus className="h-3 w-3 mr-1" />{" "}
{t("ui.common.add", "추가")}
</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -62,6 +62,7 @@ import {
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
@@ -126,6 +127,7 @@ interface UserGroupTreeNodeProps {
AxiosError<{ error?: string }>,
{ groupId: string; userId: string }
>;
isWritable?: boolean;
}
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
@@ -137,6 +139,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
onAddSubGroup,
addMemberMutation,
removeMemberMutation,
isWritable = true,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children.length > 0;
@@ -200,6 +203,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
e.stopPropagation();
onAddSubGroup(node.id);
}}
disabled={!isWritable}
>
<Plus size={14} />
</Button>
@@ -210,6 +214,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
e.stopPropagation();
onDelete(node.id);
}}
disabled={!isWritable}
>
<Trash2 size={14} className="text-destructive" />
</Button>
@@ -229,6 +234,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
onAddSubGroup={onAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
isWritable={isWritable}
/>
))}
</>
@@ -240,6 +246,11 @@ function TenantGroupsPage() {
const tenantId = params.tenantId ?? "";
const _queryClient = useQueryClient();
const { hasPermission } = useTenantPermission(tenantId);
const isWritable =
hasPermission("manage_organization") || hasPermission("manage");
const canView = hasPermission("view_organization") || hasPermission("view");
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
@@ -387,6 +398,16 @@ function TenantGroupsPage() {
},
});
if (!canView) {
return (
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
<h3 className="text-xl font-bold text-destructive">
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
</h3>
</div>
);
}
const groupTree = groupsQuery.data
? buildGroupTree(groupsQuery.data, tenantId)
: [];
@@ -423,6 +444,7 @@ function TenantGroupsPage() {
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
disabled={!isWritable}
placeholder={t(
"ui.admin.groups.form.name_placeholder",
"예: 개발팀, 인사팀",
@@ -437,6 +459,7 @@ function TenantGroupsPage() {
id="unitType"
value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)}
disabled={!isWritable}
placeholder={t(
"ui.admin.groups.form.unit_level_placeholder",
"예: 본부, 팀, 셀",
@@ -449,9 +472,10 @@ function TenantGroupsPage() {
</Label>
<select
id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={newGroupParentId || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)}
disabled={!isWritable}
>
<option value="">{t("ui.common.none", "없음")}</option>
{groupsQuery.data?.map((group) => (
@@ -469,6 +493,7 @@ function TenantGroupsPage() {
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
disabled={!isWritable}
placeholder={t(
"ui.admin.groups.form.desc_placeholder",
"그룹 용도 설명",
@@ -478,7 +503,9 @@ function TenantGroupsPage() {
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
disabled={
!newGroupName || createMutation.isPending || !isWritable
}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
@@ -569,6 +596,7 @@ function TenantGroupsPage() {
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
isWritable={isWritable}
/>
))}
</TableBody>

View File

@@ -30,7 +30,6 @@ import {
} from "../../../../../common/core/utils";
import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -378,6 +377,9 @@ function TenantListPage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const isWritable =
profileRole === "super_admin" ||
!!profile?.systemPermissions?.manage_tenants;
const query = useInfiniteQuery({
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
@@ -582,7 +584,11 @@ function TenantListPage() {
return () => window.removeEventListener("message", onMessage);
}, [allTenants, scopePickerOpen]);
if (profile && profileRole !== "super_admin") {
if (
profile &&
profileRole !== "super_admin" &&
!profile?.systemPermissions?.tenants
) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
@@ -841,7 +847,8 @@ function TenantListPage() {
}
actions={
<>
<RoleGuard roles={["super_admin"]}>
{isWritable && (
<>
<input
ref={fileInputRef}
name="tenant-import-file"
@@ -915,7 +922,8 @@ function TenantListPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</RoleGuard>
</>
)}
<Button
variant="outline"
@@ -929,14 +937,14 @@ function TenantListPage() {
{t("ui.common.refresh", "새로고침")}
</span>
</Button>
<RoleGuard roles={["super_admin"]}>
{isWritable && (
<Button asChild size="sm" className="h-9">
<Link to="/tenants/new">
<Plus size={16} />
{t("ui.admin.tenants.add", "테넌트 추가")}
</Link>
</Button>
</RoleGuard>
)}
</>
}
/>
@@ -1072,7 +1080,7 @@ function TenantListPage() {
{t("ui.common.apply", "적용")}
</Button>
<div className="w-px h-4 bg-background/20 mx-1" />
<RoleGuard roles={["super_admin"]}>
{isWritable && (
<Button
variant="ghost"
size="sm"
@@ -1084,7 +1092,7 @@ function TenantListPage() {
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</RoleGuard>
)}
</div>
<Button
variant="ghost"

View File

@@ -25,6 +25,7 @@ import {
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import { useTenantPermission } from "../hooks/useTenantPermission";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
@@ -52,6 +53,10 @@ export function TenantProfilePage() {
enabled: tenantId.length > 0,
});
const { hasPermission } = useTenantPermission(tenantId);
const isWritable = hasPermission("manage_profile") || hasPermission("manage");
const canView = hasPermission("view_profile") || hasPermission("view");
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
@@ -203,6 +208,16 @@ export function TenantProfilePage() {
);
}
if (!canView) {
return (
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
<h3 className="text-xl font-bold text-destructive">
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
</h3>
</div>
);
}
const handleDelete = () => {
if (isProtectedSeedTenant) {
return;
@@ -261,13 +276,21 @@ export function TenantProfilePage() {
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!isWritable}
/>
</div>
<div data-testid="tenant-slug-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
disabled={!isWritable}
/>
</div>
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
<ParentTenantSelector
@@ -283,6 +306,7 @@ export function TenantProfilePage() {
excludeTenantId={tenantId}
compact
controlTestId="tenant-parent-picker-control"
disabled={!isWritable}
/>
</div>
</div>
@@ -300,6 +324,7 @@ export function TenantProfilePage() {
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
disabled={!isWritable}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
@@ -346,9 +371,10 @@ export function TenantProfilePage() {
id="tenant-org-unit-type"
name="tenant-org-unit-type"
data-testid="tenant-org-unit-type-select"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)}
disabled={!isWritable}
>
<option value="">{t("ui.common.none", "없음")}</option>
{orgUnitTypeOptions.map((option) => (
@@ -365,13 +391,14 @@ export function TenantProfilePage() {
<select
id="tenant-visibility"
name="tenant-visibility"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={tenantVisibility}
onChange={(event) =>
setTenantVisibility(
event.target.value as TenantVisibility,
)
}
disabled={!isWritable}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
@@ -392,11 +419,12 @@ export function TenantProfilePage() {
</Label>
<select
id="worksmobileExcluded"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={worksmobileExcluded ? "excluded" : "enabled"}
onChange={(event) =>
setWorksmobileExcluded(event.target.value === "excluded")
}
disabled={!isWritable}
>
<option value="enabled">
{t(
@@ -424,6 +452,7 @@ export function TenantProfilePage() {
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={!isWritable}
/>
</div>
<div className="space-y-1">
@@ -442,6 +471,7 @@ export function TenantProfilePage() {
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder="example.com, example.kr"
disabled={!isWritable}
/>
</div>
<div className="space-y-1">
@@ -454,6 +484,7 @@ export function TenantProfilePage() {
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
disabled={!isWritable}
>
{t("ui.common.status.active", "활성")}
</Button>
@@ -462,6 +493,7 @@ export function TenantProfilePage() {
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
disabled={!isWritable}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
@@ -480,7 +512,9 @@ export function TenantProfilePage() {
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending || isProtectedSeedTenant}
disabled={
deleteMutation.isPending || isProtectedSeedTenant || !isWritable
}
title={
isProtectedSeedTenant
? t(
@@ -499,7 +533,7 @@ export function TenantProfilePage() {
variant="default"
className="bg-green-600 hover:bg-green-700"
onClick={handleApprove}
disabled={approveMutation.isPending}
disabled={approveMutation.isPending || !isWritable}
>
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
</Button>
@@ -512,7 +546,8 @@ export function TenantProfilePage() {
disabled={
updateMutation.isPending ||
tenantQuery.isLoading ||
name.trim() === ""
name.trim() === "" ||
!isWritable
}
>
<Save size={16} />

View File

@@ -14,9 +14,9 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { useTenantPermission } from "../hooks/useTenantPermission";
import {
createSchemaField,
isSchemaFieldType,
@@ -28,13 +28,11 @@ export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canAccess = profileRole === "super_admin";
const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(
tenantId ?? "",
);
const canView = hasPermission("view_schema") || hasPermission("view");
const isWritable = hasPermission("manage_schema") || hasPermission("manage");
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
@@ -42,7 +40,7 @@ export function TenantSchemaPage() {
if (!tenantId) throw new Error("Tenant ID is required");
return fetchTenant(tenantId);
},
enabled: !!tenantId && canAccess,
enabled: !!tenantId && canView,
});
const [fields, setFields] = useState<SchemaField[]>([]);
@@ -85,7 +83,7 @@ export function TenantSchemaPage() {
},
});
if (isProfileLoading) {
if (isPermissionLoading) {
return (
<div className="p-8 text-center animate-pulse text-muted-foreground">
{t("msg.common.loading", "로딩 중...")}
@@ -93,7 +91,7 @@ export function TenantSchemaPage() {
);
}
if (!canAccess) {
if (!canView) {
return (
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
<h3 className="text-xl font-bold text-destructive">
@@ -147,7 +145,7 @@ export function TenantSchemaPage() {
)}
</CardDescription>
</div>
<Button onClick={addField} size="sm">
<Button onClick={addField} size="sm" disabled={!isWritable}>
<Plus size={16} className="mr-2" />
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
</Button>
@@ -182,6 +180,7 @@ export function TenantSchemaPage() {
"예: employee_id",
)}
className="h-10"
disabled={!isWritable}
/>
</div>
<div className="space-y-2">
@@ -198,6 +197,7 @@ export function TenantSchemaPage() {
"예: 사번",
)}
className="h-10"
disabled={!isWritable}
/>
</div>
<div className="space-y-2">
@@ -207,8 +207,9 @@ export function TenantSchemaPage() {
<select
id={`tenant-schema-field-type-${field.key || index}`}
name={`tenant-schema-field-type-${field.key || index}`}
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary disabled:opacity-60"
value={field.type}
disabled={!isWritable}
onChange={(e) => {
const nextType = e.target.value;
if (isSchemaFieldType(nextType)) {
@@ -271,10 +272,11 @@ export function TenantSchemaPage() {
name={`tenant-schema-field-required-${field.key || index}`}
type="checkbox"
checked={field.required}
disabled={!isWritable}
onChange={(e) =>
updateField(index, { required: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
/>
<span className="text-sm font-medium">
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
@@ -285,10 +287,11 @@ export function TenantSchemaPage() {
name={`tenant-schema-field-admin-only-${field.key || index}`}
type="checkbox"
checked={field.adminOnly}
disabled={!isWritable}
onChange={(e) =>
updateField(index, { adminOnly: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
/>
<span className="text-sm font-medium">
{t(
@@ -302,6 +305,7 @@ export function TenantSchemaPage() {
name={`tenant-schema-field-login-id-${field.key || index}`}
type="checkbox"
checked={field.isLoginId || false}
disabled={!isWritable}
onChange={(e) =>
updateField(index, {
isLoginId: e.target.checked,
@@ -309,7 +313,7 @@ export function TenantSchemaPage() {
type: e.target.checked ? "text" : field.type,
})
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
/>
<span className="text-sm font-medium text-blue-600">
{t(
@@ -323,7 +327,7 @@ export function TenantSchemaPage() {
name={`tenant-schema-field-indexed-${field.key || index}`}
type="checkbox"
checked={field.indexed || field.isLoginId || false}
disabled={field.isLoginId}
disabled={field.isLoginId || !isWritable}
onChange={(e) =>
updateField(index, { indexed: e.target.checked })
}
@@ -342,10 +346,11 @@ export function TenantSchemaPage() {
name={`tenant-schema-field-unsigned-${field.key || index}`}
type="checkbox"
checked={field.unsigned}
disabled={!isWritable}
onChange={(e) =>
updateField(index, { unsigned: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
/>
<span className="text-sm font-medium">
{t(
@@ -359,6 +364,7 @@ export function TenantSchemaPage() {
<div className="space-y-2">
<Input
value={field.validation}
disabled={!isWritable}
onChange={(e) =>
updateField(index, { validation: e.target.value })
}
@@ -375,6 +381,7 @@ export function TenantSchemaPage() {
size="icon"
className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)}
disabled={!isWritable}
>
<Trash2 size={18} />
</Button>
@@ -388,7 +395,9 @@ export function TenantSchemaPage() {
<div className="flex justify-end pt-2">
<Button
onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading}
disabled={
updateMutation.isPending || tenantQuery.isLoading || !isWritable
}
className="px-8 h-11"
>
<Save size={18} className="mr-2" />

View File

@@ -159,7 +159,9 @@ function UserCreatePage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canManageUsers = canManageTenantScopedUsers(profile);
const canManageUsers =
canManageTenantScopedUsers(profile) ||
!!profile?.systemPermissions?.manage_users;
const {
register,

View File

@@ -654,6 +654,17 @@ function UserDetailPage() {
const isAdmin = profileRole === "super_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
const isWritable =
isAdmin ||
isSelf ||
canManageCurrentUser ||
!!profile?.systemPermissions?.manage_users;
const canViewUser =
isAdmin ||
isSelf ||
canManageCurrentUser ||
!!profile?.systemPermissions?.users ||
!!profile?.systemPermissions?.manage_users;
const watchedStatus = watch("status");
const [newSubEmail, setNewSubEmail] = React.useState("");
@@ -1235,7 +1246,7 @@ function UserDetailPage() {
);
}
if (!isAdmin && !isSelf && !canManageCurrentUser) {
if (profile && !canViewUser) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<ShieldAlert size={48} className="text-destructive" />
@@ -1944,6 +1955,7 @@ function UserDetailPage() {
</CardContent>
</Card>
{isWritable && (
<div className="flex justify-end pt-4">
<Button
type="submit"
@@ -1960,6 +1972,7 @@ function UserDetailPage() {
</span>
</Button>
</div>
)}
</TabsContent>
<TabsContent

View File

@@ -382,6 +382,8 @@ function UserListPage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const isWritable =
profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
const { data: tenantsData } = useQuery({
queryKey: ["tenants", "all"],
@@ -796,8 +798,9 @@ function UserListPage() {
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setBulkUploadOpen(true);
if (isWritable) setBulkUploadOpen(true);
}}
disabled={!isWritable}
className="cursor-pointer"
>
<Upload size={16} className="mr-2 opacity-50" />
@@ -889,12 +892,19 @@ function UserListPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{isWritable ? (
<Button asChild size="sm" className="h-9">
<Link to="/users/new">
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
</Link>
</Button>
) : (
<Button size="sm" className="h-9" disabled>
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
</Button>
)}
</>
}
/>
@@ -1172,7 +1182,8 @@ function UserListPage() {
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
user.id === profile?.id ||
!isWritable
}
>
<SelectTrigger
@@ -1341,7 +1352,8 @@ function UserListPage() {
}}
disabled={
(!selectedBulkStatus && !selectedBulkPermission) ||
bulkUpdateMutation.isPending
bulkUpdateMutation.isPending ||
!isWritable
}
data-testid="bulk-apply-btn"
>
@@ -1354,6 +1366,7 @@ function UserListPage() {
size="sm"
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
onClick={handleBulkDelete}
disabled={!isWritable}
data-testid="bulk-delete-btn"
>
<Trash2 size={14} />

View File

@@ -33,6 +33,19 @@ export type TenantSummary = {
config?: Record<string, unknown>;
memberCount: number; // 해당 테넌트 직접 소속 인원
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
userPermissions?: {
view: boolean;
manage: boolean;
manage_admins: boolean;
view_profile?: boolean;
manage_profile?: boolean;
view_permissions?: boolean;
manage_permissions?: boolean;
view_organization?: boolean;
manage_organization?: boolean;
view_schema?: boolean;
manage_schema?: boolean;
};
createdAt: string;
updatedAt: string;
};
@@ -469,6 +482,61 @@ export async function removeTenantOwner(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
}
export type TenantRelation = {
userId: string;
name: string;
email: string;
relations: string[];
};
export async function fetchTenantRelations(tenantId: string) {
const { data } = await apiClient.get<{ items: TenantRelation[] }>(
`/v1/admin/tenants/${tenantId}/relations`,
);
return data.items;
}
export async function addTenantRelation(
tenantId: string,
userId: string,
relation: string,
) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/relations`, {
userId,
relation,
});
}
export async function removeTenantRelation(
tenantId: string,
userId: string,
relation: string,
) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/relations`, {
data: { userId, relation },
});
}
export async function fetchSystemRelations() {
const { data } = await apiClient.get<{ items: TenantRelation[] }>(
`/v1/admin/system/relations`,
);
return data.items;
}
export async function addSystemRelation(userId: string, relation: string) {
await apiClient.post(`/v1/admin/system/relations`, {
userId,
relation,
});
}
export async function removeSystemRelation(userId: string, relation: string) {
await apiClient.delete(`/v1/admin/system/relations`, {
data: { userId, relation },
});
}
// Group Management
export type GroupMember = {
id: string;
@@ -1192,6 +1260,32 @@ export async function fetchUserRpHistory(userId: string) {
return data;
}
export type SystemPermissions = {
overview: boolean;
tenants: boolean;
org_chart: boolean;
worksmobile: boolean;
ory_ssot: boolean;
data_integrity: boolean;
users: boolean;
permissions_direct: boolean;
auth_guard: boolean;
api_keys: boolean;
audit_logs: boolean;
manage_overview?: boolean;
manage_tenants?: boolean;
manage_org_chart?: boolean;
manage_worksmobile?: boolean;
manage_ory_ssot?: boolean;
manage_data_integrity?: boolean;
manage_users?: boolean;
manage_permissions_direct?: boolean;
manage_auth_guard?: boolean;
manage_api_keys?: boolean;
manage_audit_logs?: boolean;
};
export type UserProfileResponse = {
id: string;
email: string;
@@ -1205,6 +1299,7 @@ export type UserProfileResponse = {
metadata?: Record<string, unknown>;
tenant?: TenantSummary;
manageableTenants?: TenantSummary[];
systemPermissions?: SystemPermissions;
};
export async function fetchMe() {

View File

@@ -972,6 +972,7 @@ org_chart = "Org Chart"
api_keys = "API Keys"
audit_logs = "Audit Logs"
auth_guard = "Auth Guard"
permissions_direct = "Direct Permissions"
data_integrity = "Data Integrity"
logout = "Logout"
overview = "Overview"
@@ -996,10 +997,6 @@ title = "Redis identity cache"
[ui.admin.ory_ssot.forbidden]
title = "Access denied"
[ui.admin.ory_ssot.projection_card]
description = "PostgreSQL read model status used by admin search and statistics."
title = "Backend user read model"
[ui.admin.ory_ssot.status]
failed = "failed"
not_ready = "not ready"
@@ -1008,11 +1005,8 @@ ready = "ready"
[ui.admin.ory_ssot.summary]
cache_keys = "Cache keys"
last_refreshed = "Last refreshed"
last_synced = "Last read-model refresh"
local_users = "Local users"
observed_identities = "Observed identities"
status = "Status"
updated_at = "Updated at"
[ui.admin.auth_guard]
subtitle = "Verify admin privileges and ReBAC relationships against the policy engine."
@@ -1184,6 +1178,7 @@ tab_organization = "Organization Manage"
tab_permissions = "Permissions"
tab_profile = "Profile"
tab_schema = "Tab Schema"
tab_relations = "Fine-grained Permissions"
title = "Details"
[ui.admin.tenants.list]
@@ -1494,7 +1489,6 @@ email = "Email"
name = "Name"
role = "Role"
[ui.common.role]
admin = "Admin"
rp_admin = "RP Admin"
@@ -2006,3 +2000,24 @@ verify = "Verify"
[ui.userfront.signup.success]
action = "Action"
[ui.admin.permissions_direct]
tab_tenant = "Tenant Features"
tab_system = "Admin Control"
tab_system_title = "Global Sidebar Access Control"
select_tenant = "Select target tenant"
select_tenant_desc = "Select target tenant to assign fine-grained permissions."
placeholder = "-- Select Tenant --"
add_system_user = "Add User to Admin Control"
dialog_title_system = "Add User to Global Permissions"
[msg.admin.permissions_direct]
description = "Directly assign and manage tab-level direct permissions and global sidebar menu access."
tab_system_desc = "Directly grant users access to each sidebar menu page. Super admins always bypass and pass all access checks."
system_empty = "No users with custom global menu permissions found. Add users to start managing."
select_prompt = "Select a tenant from the dropdown above to manage its fine-grained features."
[msg.admin.system.relations]
add_success = "Global menu permission added successfully."
remove_success = "Global menu permission revoked successfully."
remove_all_confirm = "Are you sure you want to revoke all global menu permissions for this user?"

View File

@@ -976,6 +976,7 @@ org_chart = "조직도"
api_keys = "API 키"
audit_logs = "감사 로그"
auth_guard = "인증 가드"
permissions_direct = "권한 부여"
data_integrity = "데이터 정합성"
logout = "로그아웃"
overview = "개요"
@@ -1000,10 +1001,6 @@ title = "Redis identity cache"
[ui.admin.ory_ssot.forbidden]
title = "접근 권한이 없습니다"
[ui.admin.ory_ssot.projection_card]
description = "관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다."
title = "Backend 사용자 read model"
[ui.admin.ory_ssot.status]
failed = "실패"
not_ready = "준비되지 않음"
@@ -1012,11 +1009,8 @@ ready = "준비됨"
[ui.admin.ory_ssot.summary]
cache_keys = "Cache keys"
last_refreshed = "마지막 refresh"
last_synced = "마지막 read-model refresh"
local_users = "Local users"
observed_identities = "관측 identity"
status = "상태"
updated_at = "상태 갱신"
[ui.admin.auth_guard]
subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다."
@@ -1188,6 +1182,7 @@ tab_organization = "조직 관리"
tab_permissions = "권한"
tab_profile = "프로필"
tab_schema = "사용자 스키마"
tab_relations = "세부 권한"
title = "상세"
[ui.admin.tenants.list]
@@ -1498,7 +1493,6 @@ email = "이메일"
name = "이름"
role = "역할"
[ui.common.role]
admin = "Admin"
rp_admin = "RP Admin"
@@ -2006,3 +2000,24 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"
[ui.admin.permissions_direct]
tab_tenant = "테넌트 기능 권한"
tab_system = "시스템 메뉴 권한 (Admin Control)"
tab_system_title = "글로벌 메뉴 접근 제어 (Admin Control)"
select_tenant = "대상 테넌트 선택"
select_tenant_desc = "세부 기능 권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요."
placeholder = "-- 테넌트 선택 --"
add_system_user = "시스템 권한 사용자 추가"
dialog_title_system = "시스템 권한 관리 유저 추가"
[msg.admin.permissions_direct]
description = "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다."
tab_system_desc = "사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다."
system_empty = "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요."
select_prompt = "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다."
[msg.admin.system.relations]
add_success = "시스템 메뉴 권한이 추가되었습니다."
remove_success = "시스템 메뉴 권한이 회수되었습니다."
remove_all_confirm = "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?"

View File

@@ -193,7 +193,6 @@ worksmobile_excluded = ""
worksmobile_sync = ""
allowed_domains = ""
[msg.admin.ory_ssot]
flush_confirm = ""
flush_error = ""
@@ -981,6 +980,7 @@ org_chart = ""
api_keys = ""
audit_logs = ""
auth_guard = ""
permissions_direct = ""
data_integrity = ""
logout = ""
overview = ""
@@ -1005,10 +1005,6 @@ title = ""
[ui.admin.ory_ssot.forbidden]
title = ""
[ui.admin.ory_ssot.projection_card]
description = ""
title = ""
[ui.admin.ory_ssot.status]
failed = ""
not_ready = ""
@@ -1017,11 +1013,8 @@ ready = ""
[ui.admin.ory_ssot.summary]
cache_keys = ""
last_refreshed = ""
last_synced = ""
local_users = ""
observed_identities = ""
status = ""
updated_at = ""
[ui.admin.auth_guard]
subtitle = ""
@@ -1193,6 +1186,7 @@ tab_organization = ""
tab_permissions = ""
tab_profile = ""
tab_schema = ""
tab_relations = ""
title = ""
[ui.admin.tenants.list]
@@ -1451,7 +1445,6 @@ email = ""
name = ""
role = ""
[ui.common.role]
admin = ""
rp_admin = ""
@@ -1961,3 +1954,24 @@ verify = ""
[ui.userfront.signup.success]
action = ""
[ui.admin.permissions_direct]
tab_tenant = ""
tab_system = ""
tab_system_title = ""
select_tenant = ""
select_tenant_desc = ""
placeholder = ""
add_system_user = ""
dialog_title_system = ""
[msg.admin.permissions_direct]
description = ""
tab_system_desc = ""
system_empty = ""
select_prompt = ""
[msg.admin.system.relations]
add_success = ""
remove_success = ""
remove_all_confirm = ""

View File

@@ -2,6 +2,10 @@ import { expect, test } from "@playwright/test";
test.describe("Authentication", () => {
test.beforeEach(async ({ page }) => {
page.on("console", (msg) => console.log("BROWSER LOG:", msg.text()));
page.on("pageerror", (err) =>
console.error("BROWSER EXCEPTION:", err.message),
);
// 1. Force state
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
@@ -70,8 +74,24 @@ test.describe("Authentication", () => {
// 3. Catch-all for others
await page.route(/.*\/api\/v1\/.*/, async (route) => {
if (route.request().url().includes("/user/me")) {
return route.fallback();
}
if (route.request().method() === "GET") {
await route.fulfill({ json: { items: [], total: 0 } });
await route.fulfill({
json: {
items: [],
total: 0,
summary: {
failures: 0,
warnings: 0,
pass: 0,
success: 0,
total: 0,
},
sections: [],
},
});
} else {
await route.fulfill({ status: 200, json: {} });
}

View File

@@ -198,7 +198,11 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
test.describe("일반 사용자 (Tenant Member) 제한", () => {
test.beforeEach(async ({ page }) => {
await setupAuth(page, "user");
await setupAuth(page, "user", {
systemPermissions: {
audit_logs: true,
},
});
await page.goto("/");
await expect(page.locator("aside")).toBeVisible({ timeout: 10000 });
});
@@ -291,4 +295,64 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
).not.toBeVisible();
});
});
test.describe("세부 기능 권한(System Permissions)을 가진 비-슈퍼어드민", () => {
test("테넌트 조회 권한(tenants)이 있을 때 테넌트 목록 페이지 진입 가능 및 쓰기 기능 제한 확인", async ({
page,
}) => {
await setupAuth(page, "tenant_admin", {
tenantId: "t1",
tenantSlug: "t1",
systemPermissions: {
tenants: true,
manage_tenants: false,
},
});
await page.goto("/");
await expect(page.locator("aside")).toBeVisible({ timeout: 10000 });
// 테넌트 목록 메뉴 노출 및 클릭 진입 확인
await expect(page.locator('a[href="/tenants"]')).toBeVisible();
await page.goto("/tenants");
// 차단 메시지 비노출 확인
await expect(
page.getByText(
/접근 권한이 없습니다|이 작업을 수행할 권한이 없습니다/i,
),
).not.toBeVisible();
// "테넌트 1" 목록 노출 확인
await expect(page.getByText("테넌트 1")).toBeVisible();
// 수정 권한(manage_tenants)이 없으므로 쓰기 버튼 비노출 확인
await expect(
page.getByRole("link", { name: /테넌트 추가/i }),
).not.toBeVisible();
await expect(page.getByTestId("tenant-data-mgmt-btn")).not.toBeVisible();
});
test("테넌트 관리 권한(manage_tenants)까지 있을 때 테넌트 추가 및 데이터 관리 버튼 활성화 확인", async ({
page,
}) => {
await setupAuth(page, "tenant_admin", {
tenantId: "t1",
tenantSlug: "t1",
systemPermissions: {
tenants: true,
manage_tenants: true,
},
});
await page.goto("/tenants");
// "테넌트 1" 목록 노출 확인
await expect(page.getByText("테넌트 1")).toBeVisible();
// 수정 권한(manage_tenants)이 있으므로 쓰기 버튼(테넌트 추가, 데이터 관리) 노출 확인
await expect(
page.getByRole("link", { name: /테넌트 추가/i }),
).toBeVisible();
await expect(page.getByTestId("tenant-data-mgmt-btn")).toBeVisible();
});
});
});

View File

@@ -764,6 +764,14 @@ func main() {
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
admin.Get("/tenants/:id/relations", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage_admins"), tenantHandler.ListRelations)
admin.Post("/tenants/:id/relations", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage_admins"), tenantHandler.AddRelation)
admin.Delete("/tenants/:id/relations", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage_admins"), tenantHandler.RemoveRelation)
admin.Get("/system/relations", requireSuperAdmin, tenantHandler.ListSystemRelations)
admin.Post("/system/relations", requireSuperAdmin, tenantHandler.AddSystemRelation)
admin.Delete("/system/relations", requireSuperAdmin, tenantHandler.RemoveSystemRelation)
admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview)
admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison)
admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches)

View File

@@ -69,6 +69,32 @@ type SignupRequest struct {
// User Profile Models
type SystemPermissions struct {
Overview bool `json:"overview"`
Tenants bool `json:"tenants"`
OrgChart bool `json:"org_chart"`
Worksmobile bool `json:"worksmobile"`
OrySSOT bool `json:"ory_ssot"`
DataIntegrity bool `json:"data_integrity"`
Users bool `json:"users"`
PermissionsDirect bool `json:"permissions_direct"`
AuthGuard bool `json:"auth_guard"`
ApiKeys bool `json:"api_keys"`
AuditLogs bool `json:"audit_logs"`
ManageOverview bool `json:"manage_overview"`
ManageTenants bool `json:"manage_tenants"`
ManageOrgChart bool `json:"manage_org_chart"`
ManageWorksmobile bool `json:"manage_worksmobile"`
ManageOrySSOT bool `json:"manage_ory_ssot"`
ManageDataIntegrity bool `json:"manage_data_integrity"`
ManageUsers bool `json:"manage_users"`
ManagePermissionsDirect bool `json:"manage_permissions_direct"`
ManageAuthGuard bool `json:"manage_auth_guard"`
ManageApiKeys bool `json:"manage_api_keys"`
ManageAuditLogs bool `json:"manage_audit_logs"`
}
type UserProfileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -87,6 +113,7 @@ type UserProfileResponse struct {
Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
SystemPermissions *SystemPermissions `json:"systemPermissions,omitempty"` // [New] 글로벌 메뉴 접근 권한
}
type UpdateUserRequest struct {

View File

@@ -4928,6 +4928,125 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai
}
}
if h.KetoService != nil {
subject := "User:" + profile.ID
var sp domain.SystemPermissions
if profile.Role == "super_admin" {
sp = domain.SystemPermissions{
Overview: true,
Tenants: true,
OrgChart: true,
Worksmobile: true,
OrySSOT: true,
DataIntegrity: true,
Users: true,
PermissionsDirect: true,
AuthGuard: true,
ApiKeys: true,
AuditLogs: true,
ManageOverview: true,
ManageTenants: true,
ManageOrgChart: true,
ManageWorksmobile: true,
ManageOrySSOT: true,
ManageDataIntegrity: true,
ManageUsers: true,
ManagePermissionsDirect: true,
ManageAuthGuard: true,
ManageApiKeys: true,
ManageAuditLogs: true,
}
} else {
// Query Keto in parallel for maximum performance
type checkResult struct {
menu string
allowed bool
}
menus := map[string]string{
"overview": "access_overview",
"manage_overview": "manage_overview",
"tenants": "access_tenants",
"manage_tenants": "manage_tenants",
"org_chart": "access_org_chart",
"manage_org_chart": "manage_org_chart",
"worksmobile": "access_worksmobile",
"manage_worksmobile": "manage_worksmobile",
"ory_ssot": "access_ory_ssot",
"manage_ory_ssot": "manage_ory_ssot",
"data_integrity": "access_data_integrity",
"manage_data_integrity": "manage_data_integrity",
"users": "access_users",
"manage_users": "manage_users",
"permissions_direct": "access_permissions_direct",
"manage_permissions_direct": "manage_permissions_direct",
"auth_guard": "access_auth_guard",
"manage_auth_guard": "manage_auth_guard",
"api_keys": "access_api_keys",
"manage_api_keys": "manage_api_keys",
"audit_logs": "access_audit_logs",
"manage_audit_logs": "manage_audit_logs",
}
ch := make(chan checkResult, len(menus))
for m, rel := range menus {
go func(menuName, relation string) {
allowed, _ := h.KetoService.CheckPermission(ctx, subject, "System", "system", relation)
ch <- checkResult{menu: menuName, allowed: allowed}
}(m, rel)
}
for range menus {
res := <-ch
switch res.menu {
case "overview":
sp.Overview = res.allowed
case "manage_overview":
sp.ManageOverview = res.allowed
case "tenants":
sp.Tenants = res.allowed
case "manage_tenants":
sp.ManageTenants = res.allowed
case "org_chart":
sp.OrgChart = res.allowed
case "manage_org_chart":
sp.ManageOrgChart = res.allowed
case "worksmobile":
sp.Worksmobile = res.allowed
case "manage_worksmobile":
sp.ManageWorksmobile = res.allowed
case "ory_ssot":
sp.OrySSOT = res.allowed
case "manage_ory_ssot":
sp.ManageOrySSOT = res.allowed
case "data_integrity":
sp.DataIntegrity = res.allowed
case "manage_data_integrity":
sp.ManageDataIntegrity = res.allowed
case "users":
sp.Users = res.allowed
case "manage_users":
sp.ManageUsers = res.allowed
case "permissions_direct":
sp.PermissionsDirect = res.allowed
case "manage_permissions_direct":
sp.ManagePermissionsDirect = res.allowed
case "auth_guard":
sp.AuthGuard = res.allowed
case "manage_auth_guard":
sp.ManageAuthGuard = res.allowed
case "api_keys":
sp.ApiKeys = res.allowed
case "manage_api_keys":
sp.ManageApiKeys = res.allowed
case "audit_logs":
sp.AuditLogs = res.allowed
case "manage_audit_logs":
sp.ManageAuditLogs = res.allowed
}
}
}
profile.SystemPermissions = &sp
}
return profile
}
@@ -8426,7 +8545,7 @@ func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs [
seen := map[string]struct{}{}
for _, scope := range append([]string{"openid"}, scopes...) {
scope = strings.TrimSpace(scope)
if scope == "" || isRefreshTokenScopeAlias(scope) {
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
continue
}
if _, ok := seen[scope]; ok {

View File

@@ -464,7 +464,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
appendIfPresent := func(scope string) {
scope = strings.TrimSpace(scope)
if scope == "" || isRefreshTokenScopeAlias(scope) {
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
return
}
if _, ok := seen[scope]; ok {
@@ -485,7 +485,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
for _, scope := range combined {
scope = strings.TrimSpace(scope)
if scope == "" || isRefreshTokenScopeAlias(scope) {
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
continue
}
if _, ok := seen[scope]; ok {

View File

@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
@@ -153,7 +154,7 @@ func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAlias
[]string{"openid", "offline", "profile", "offline_access"},
)
assert.Equal(t, []string{"openid", "tenant", "profile", "email"}, merged)
assert.Equal(t, []string{"openid", "tenant", "profile", "offline_access", "email"}, merged)
}
func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) {
@@ -166,10 +167,11 @@ func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T)
parsed, err := url.Parse(urlString)
assert.NoError(t, err)
scopes := parsed.Query().Get("scope")
scopeItems := strings.Fields(scopes)
assert.Equal(t, "openid profile email", scopes)
assert.NotContains(t, scopes, "offline")
assert.NotContains(t, scopes, "offline_access")
assert.Equal(t, "openid profile offline_access email", scopes)
assert.NotContains(t, scopeItems, "offline")
assert.Contains(t, scopeItems, "offline_access")
}
func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) {

View File

@@ -3848,7 +3848,7 @@ func normalizeClientScopes(scopes []string) []string {
seen := make(map[string]struct{}, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "" || isRefreshTokenScopeAlias(scope) {
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
continue
}
if _, ok := seen[scope]; ok {
@@ -3860,9 +3860,9 @@ func normalizeClientScopes(scopes []string) []string {
return normalized
}
func isRefreshTokenScopeAlias(scope string) bool {
func isLegacyRefreshTokenScopeAlias(scope string) bool {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "offline", "offline_access":
case "offline":
return true
default:
return false

View File

@@ -2229,9 +2229,9 @@ func TestCreateClient_StripsOfflineScopesAndKeepsRefreshTokenGrant(t *testing.T)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.Equal(t, "openid profile email", captured.Scope)
assert.Equal(t, "openid profile offline_access email", captured.Scope)
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
assert.NotContains(t, strings.Fields(captured.Scope), "offline_access")
assert.Contains(t, strings.Fields(captured.Scope), "offline_access")
assert.Contains(t, captured.GrantTypes, "refresh_token")
}
@@ -2296,9 +2296,9 @@ func TestUpdateClient_StripsStoredOfflineScopesAndKeepsRefreshTokenGrant(t *test
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "openid profile email", captured.Scope)
assert.Equal(t, "openid profile offline_access email", captured.Scope)
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
assert.NotContains(t, strings.Fields(captured.Scope), "offline_access")
assert.Contains(t, strings.Fields(captured.Scope), "offline_access")
assert.Contains(t, captured.GrantTypes, "refresh_token")
}

View File

@@ -84,6 +84,21 @@ func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
h.Worksmobile = syncer
}
type tenantPermissions struct {
View bool `json:"view"`
Manage bool `json:"manage"`
ManageAdmins bool `json:"manage_admins"`
ViewProfile bool `json:"view_profile"`
ManageProfile bool `json:"manage_profile"`
ViewPermissions bool `json:"view_permissions"`
ManagePermissions bool `json:"manage_permissions"`
ViewOrganization bool `json:"view_organization"`
ManageOrganization bool `json:"manage_organization"`
ViewSchema bool `json:"view_schema"`
ManageSchema bool `json:"manage_schema"`
}
type tenantSummary struct {
ID string `json:"id"`
Type string `json:"type"`
@@ -96,6 +111,7 @@ type tenantSummary struct {
Config domain.JSONMap `json:"config,omitempty"`
MemberCount int64 `json:"memberCount"`
TotalMemberCount int64 `json:"totalMemberCount"`
UserPermissions *tenantPermissions `json:"userPermissions,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
@@ -1709,6 +1725,83 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
summary.MemberCount = memberCounts[tenant.ID]
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
// Populate Keto-based permissions for the current user
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if ok && profile != nil {
role := domain.NormalizeRole(profile.Role)
if role == domain.RoleSuperAdmin {
summary.UserPermissions = &tenantPermissions{
View: true,
Manage: true,
ManageAdmins: true,
ViewProfile: true,
ManageProfile: true,
ViewPermissions: true,
ManagePermissions: true,
ViewOrganization: true,
ManageOrganization: true,
ViewSchema: true,
ManageSchema: true,
}
} else {
// Query Keto in parallel for maximum performance
subject := "User:" + profile.ID
type checkResult struct {
relation string
allowed bool
err error
}
ch := make(chan checkResult, 11)
relations := []string{
"view", "manage", "manage_admins",
"view_profile", "manage_profile",
"view_permissions", "manage_permissions",
"view_organization", "manage_organization",
"view_schema", "manage_schema",
}
for _, rel := range relations {
go func(r string) {
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "Tenant", tenant.ID, r)
ch <- checkResult{relation: r, allowed: allowed, err: err}
}(rel)
}
perms := &tenantPermissions{}
for range relations {
res := <-ch
if res.err != nil {
slog.Error("Failed to check Keto permission in GetTenant", "error", res.err, "relation", res.relation, "userID", profile.ID, "tenantID", tenant.ID)
continue
}
switch res.relation {
case "view":
perms.View = res.allowed
case "manage":
perms.Manage = res.allowed
case "manage_admins":
perms.ManageAdmins = res.allowed
case "view_profile":
perms.ViewProfile = res.allowed
case "manage_profile":
perms.ManageProfile = res.allowed
case "view_permissions":
perms.ViewPermissions = res.allowed
case "manage_permissions":
perms.ManagePermissions = res.allowed
case "view_organization":
perms.ViewOrganization = res.allowed
case "manage_organization":
perms.ManageOrganization = res.allowed
case "view_schema":
perms.ViewSchema = res.allowed
case "manage_schema":
perms.ManageSchema = res.allowed
}
}
summary.UserPermissions = perms
}
}
return c.JSON(summary)
}
@@ -3652,3 +3745,419 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
"sharedWith": link.Name,
})
}
type tenantRelationRequest struct {
UserID string `json:"userId"`
Relation string `json:"relation"`
}
func (h *TenantHandler) ListRelations(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
allowedRelations := map[string]bool{
"profile_viewers": true,
"profile_managers": true,
"permissions_viewers": true,
"permissions_managers": true,
"organization_viewers": true,
"organization_managers": true,
"schema_viewers": true,
"schema_managers": true,
}
type userRelationInfo struct {
UserID string `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
Relations []string `json:"relations"`
}
userMap := make(map[string][]string)
for _, rel := range relations {
if !allowedRelations[rel.Relation] {
continue
}
if !strings.HasPrefix(rel.SubjectID, "User:") {
continue
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
userMap[userID] = append(userMap[userID], rel.Relation)
}
items := []userRelationInfo{}
for userID, rels := range userMap {
name := "Unknown"
email := "Unknown"
if h.KratosAdmin != nil {
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
if n, ok := identity.Traits["name"].(string); ok {
name = n
}
if e, ok := identity.Traits["email"].(string); ok {
email = e
}
}
}
if name == "Unknown" && email == "Unknown" && h.UserRepo != nil {
user, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && user != nil {
name = user.Name
email = user.Email
} else if userID == "00000000-0000-0000-0000-000000000000" {
name = "Dev Mock User"
email = "mock@hmac.kr"
}
}
items = append(items, userRelationInfo{
UserID: userID,
Name: name,
Email: email,
Relations: rels,
})
}
return c.JSON(fiber.Map{
"items": items,
})
}
func (h *TenantHandler) AddRelation(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
var req tenantRelationRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.UserID == "" || req.Relation == "" {
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
}
allowedRelations := map[string]bool{
"profile_viewers": true,
"profile_managers": true,
"permissions_viewers": true,
"permissions_managers": true,
"organization_viewers": true,
"organization_managers": true,
"schema_viewers": true,
"schema_managers": true,
}
if !allowedRelations[req.Relation] {
return errorJSON(c, fiber.StatusBadRequest, "invalid or unsupported relation")
}
if h.Keto != nil {
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID)
if err == nil && len(relations) > 0 {
return errorJSON(c, fiber.StatusConflict, "이미 해당 세부 권한이 등록된 사용자입니다.")
}
}
var directWriteErr error
if h.Keto != nil {
directWriteErr = h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID)
}
if h.KetoOutbox != nil {
status := domain.KetoOutboxStatusPending
var processedAt *time.Time
if directWriteErr == nil && h.Keto != nil {
status = domain.KetoOutboxStatusProcessed
now := time.Now()
processedAt = &now
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: req.Relation,
Subject: "User:" + req.UserID,
Action: domain.KetoOutboxActionCreate,
Status: status,
ProcessedAt: processedAt,
})
}
if directWriteErr != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) RemoveRelation(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
var req tenantRelationRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.UserID == "" || req.Relation == "" {
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
}
var directWriteErr error
if h.Keto != nil {
directWriteErr = h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID)
}
if h.KetoOutbox != nil {
status := domain.KetoOutboxStatusPending
var processedAt *time.Time
if directWriteErr == nil && h.Keto != nil {
status = domain.KetoOutboxStatusProcessed
now := time.Now()
processedAt = &now
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: req.Relation,
Subject: "User:" + req.UserID,
Action: domain.KetoOutboxActionDelete,
Status: status,
ProcessedAt: processedAt,
})
}
if directWriteErr != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) ListSystemRelations(c *fiber.Ctx) error {
relations, err := h.Keto.ListRelations(c.Context(), "System", "system", "", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
allowedRelations := map[string]bool{
"overview_viewers": true,
"tenants_viewers": true,
"org_chart_viewers": true,
"worksmobile_viewers": true,
"ory_ssot_viewers": true,
"data_integrity_viewers": true,
"users_viewers": true,
"permissions_direct_viewers": true,
"auth_guard_viewers": true,
"api_keys_viewers": true,
"audit_logs_viewers": true,
"overview_managers": true,
"tenants_managers": true,
"org_chart_managers": true,
"worksmobile_managers": true,
"ory_ssot_managers": true,
"data_integrity_managers": true,
"users_managers": true,
"permissions_direct_managers": true,
"auth_guard_managers": true,
"api_keys_managers": true,
"audit_logs_managers": true,
}
type userRelationInfo struct {
UserID string `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
Relations []string `json:"relations"`
}
userMap := make(map[string][]string)
for _, rel := range relations {
if !allowedRelations[rel.Relation] {
continue
}
if !strings.HasPrefix(rel.SubjectID, "User:") {
continue
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
userMap[userID] = append(userMap[userID], rel.Relation)
}
items := []userRelationInfo{}
for userID, rels := range userMap {
name := "Unknown"
email := "Unknown"
if h.KratosAdmin != nil {
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
if n, ok := identity.Traits["name"].(string); ok {
name = n
}
if e, ok := identity.Traits["email"].(string); ok {
email = e
}
}
}
if name == "Unknown" && email == "Unknown" && h.UserRepo != nil {
user, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && user != nil {
name = user.Name
email = user.Email
} else if userID == "00000000-0000-0000-0000-000000000000" {
name = "Dev Mock User"
email = "mock@hmac.kr"
}
}
items = append(items, userRelationInfo{
UserID: userID,
Name: name,
Email: email,
Relations: rels,
})
}
return c.JSON(fiber.Map{
"items": items,
})
}
func (h *TenantHandler) AddSystemRelation(c *fiber.Ctx) error {
var req tenantRelationRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.UserID == "" || req.Relation == "" {
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
}
allowedRelations := map[string]bool{
"overview_viewers": true,
"tenants_viewers": true,
"org_chart_viewers": true,
"worksmobile_viewers": true,
"ory_ssot_viewers": true,
"data_integrity_viewers": true,
"users_viewers": true,
"permissions_direct_viewers": true,
"auth_guard_viewers": true,
"api_keys_viewers": true,
"audit_logs_viewers": true,
"overview_managers": true,
"tenants_managers": true,
"org_chart_managers": true,
"worksmobile_managers": true,
"ory_ssot_managers": true,
"data_integrity_managers": true,
"users_managers": true,
"permissions_direct_managers": true,
"auth_guard_managers": true,
"api_keys_managers": true,
"audit_logs_managers": true,
}
if !allowedRelations[req.Relation] {
return errorJSON(c, fiber.StatusBadRequest, "invalid or unsupported relation")
}
if h.Keto != nil {
relations, err := h.Keto.ListRelations(c.Context(), "System", "system", req.Relation, "User:"+req.UserID)
if err == nil && len(relations) > 0 {
return errorJSON(c, fiber.StatusConflict, "이미 해당 세부 권한이 등록된 사용자입니다.")
}
}
var directWriteErr error
if h.Keto != nil {
directWriteErr = h.Keto.CreateRelation(c.Context(), "System", "system", req.Relation, "User:"+req.UserID)
}
if h.KetoOutbox != nil {
status := domain.KetoOutboxStatusPending
var processedAt *time.Time
if directWriteErr == nil && h.Keto != nil {
status = domain.KetoOutboxStatusProcessed
now := time.Now()
processedAt = &now
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "System",
Object: "system",
Relation: req.Relation,
Subject: "User:" + req.UserID,
Action: domain.KetoOutboxActionCreate,
Status: status,
ProcessedAt: processedAt,
})
}
if directWriteErr != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) RemoveSystemRelation(c *fiber.Ctx) error {
var req tenantRelationRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.UserID == "" || req.Relation == "" {
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
}
var directWriteErr error
if h.Keto != nil {
directWriteErr = h.Keto.DeleteRelation(c.Context(), "System", "system", req.Relation, "User:"+req.UserID)
}
if h.KetoOutbox != nil {
status := domain.KetoOutboxStatusPending
var processedAt *time.Time
if directWriteErr == nil && h.Keto != nil {
status = domain.KetoOutboxStatusProcessed
now := time.Now()
processedAt = &now
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "System",
Object: "system",
Relation: req.Relation,
Subject: "User:" + req.UserID,
Action: domain.KetoOutboxActionDelete,
Status: status,
ProcessedAt: processedAt,
})
}
if directWriteErr != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

@@ -0,0 +1,151 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/testsupport"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestTenantHandler_GetTenant_SuperAdmin(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
db := newTenantHandlerSeedDeleteDB(t)
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
t.Fatalf("failed to migrate tenant domains: %v", err)
}
// Create a test tenant in DB with a valid UUID
tenant := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000010",
Name: "Super Admin Test Tenant",
Slug: "super-admin-test-tenant",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
app := fiber.New()
mockSvc := new(MockTenantService)
mockKeto := new(devMockKetoService)
h := &TenantHandler{
DB: db,
Service: mockSvc,
Keto: mockKeto,
}
// We'll simulate middleware setting "user_profile" for a Super Admin
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
profile := &domain.UserProfileResponse{
ID: "user-super-admin-id",
Role: domain.RoleSuperAdmin,
}
c.Locals("user_profile", profile)
return h.GetTenant(c)
})
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000010", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got tenantSummary
err = json.NewDecoder(resp.Body).Decode(&got)
if err != nil {
t.Fatalf("failed to decode response: %v", err)
}
assert.Equal(t, "00000000-0000-0000-0000-000000000010", got.ID)
assert.Equal(t, "Super Admin Test Tenant", got.Name)
assert.NotNil(t, got.UserPermissions)
assert.True(t, got.UserPermissions.View)
assert.True(t, got.UserPermissions.Manage)
assert.True(t, got.UserPermissions.ManageAdmins)
}
func TestTenantHandler_GetTenant_NormalUser(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
db := newTenantHandlerSeedDeleteDB(t)
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
t.Fatalf("failed to migrate tenant domains: %v", err)
}
// Create a test tenant in DB with a valid UUID
tenant := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000020",
Name: "Normal User Test Tenant",
Slug: "normal-user-test-tenant",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
app := fiber.New()
mockSvc := new(MockTenantService)
mockKeto := new(devMockKetoService)
h := &TenantHandler{
DB: db,
Service: mockSvc,
Keto: mockKeto,
}
// Mock Keto response: allowed view/manage but not manage_admins
subject := "User:user-normal-id"
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "view").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage_admins").Return(false, nil)
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", mock.Anything).Return(false, nil).Maybe()
// We'll simulate middleware setting "user_profile" for a regular admin/user
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
profile := &domain.UserProfileResponse{
ID: "user-normal-id",
Role: domain.RoleUser,
}
c.Locals("user_profile", profile)
return h.GetTenant(c)
})
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000020", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got tenantSummary
err = json.NewDecoder(resp.Body).Decode(&got)
if err != nil {
t.Fatalf("failed to decode response: %v", err)
}
assert.Equal(t, "00000000-0000-0000-0000-000000000020", got.ID)
assert.Equal(t, "Normal User Test Tenant", got.Name)
assert.NotNil(t, got.UserPermissions)
assert.True(t, got.UserPermissions.View)
assert.True(t, got.UserPermissions.Manage)
assert.False(t, got.UserPermissions.ManageAdmins)
mockKeto.AssertExpectations(t)
}

View File

@@ -0,0 +1,276 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/testsupport"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestTenantHandler_Relations(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
db := newTenantHandlerSeedDeleteDB(t)
if err := db.AutoMigrate(&domain.TenantDomain{}, &domain.KetoOutbox{}); err != nil {
t.Fatalf("failed to migrate tenant domains or outbox: %v", err)
}
// Create a test tenant in DB with a valid UUID
tenantID := "00000000-0000-0000-0000-000000000030"
tenant := domain.Tenant{
ID: tenantID,
Name: "Relation Test Tenant",
Slug: "relation-test-tenant",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
mockSvc := new(MockTenantService)
mockKeto := new(devMockKetoService)
realOutbox := repository.NewKetoOutboxRepository(db)
h := &TenantHandler{
DB: db,
Service: mockSvc,
Keto: mockKeto,
KetoOutbox: realOutbox,
}
userID := "user-relation-1"
t.Run("ListRelations - Returns correct relations aggregated by user", func(t *testing.T) {
app := fiber.New()
app.Get("/tenants/:id/relations", h.ListRelations)
mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "", "").Return([]service.RelationTuple{
{
Namespace: "Tenant",
Object: tenantID,
Relation: "schema_managers",
SubjectID: "User:" + userID,
},
{
Namespace: "Tenant",
Object: tenantID,
Relation: "profile_viewers",
SubjectID: "User:" + userID,
},
{
Namespace: "Tenant",
Object: tenantID,
Relation: "unrelated_relation", // Should be filtered out
SubjectID: "User:" + userID,
},
}, nil).Once()
req := httptest.NewRequest("GET", "/tenants/"+tenantID+"/relations", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got struct {
Items []struct {
UserID string `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
Relations []string `json:"relations"`
} `json:"items"`
}
err = json.NewDecoder(resp.Body).Decode(&got)
if err != nil {
t.Fatalf("failed to decode response: %v", err)
}
assert.Len(t, got.Items, 1)
assert.Equal(t, userID, got.Items[0].UserID)
assert.Contains(t, got.Items[0].Relations, "schema_managers")
assert.Contains(t, got.Items[0].Relations, "profile_viewers")
assert.NotContains(t, got.Items[0].Relations, "unrelated_relation")
mockKeto.AssertExpectations(t)
})
t.Run("AddRelation - Inserts into KetoOutbox DB table", func(t *testing.T) {
app := fiber.New()
app.Post("/tenants/:id/relations", h.AddRelation)
mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once()
mockKeto.On("CreateRelation", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return(nil).Once()
body, _ := json.Marshal(map[string]string{
"userId": userID,
"relation": "schema_managers",
})
req := httptest.NewRequest("POST", "/tenants/"+tenantID+"/relations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Verify row was written to the keto_outboxes DB table
var outboxEntries []domain.KetoOutbox
if err := db.Where("object = ? AND relation = ? AND action = ?", tenantID, "schema_managers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil {
t.Fatalf("failed to query outbox: %v", err)
}
assert.Len(t, outboxEntries, 1)
assert.Equal(t, "Tenant", outboxEntries[0].Namespace)
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status)
mockKeto.AssertExpectations(t)
})
t.Run("RemoveRelation - Inserts delete action into KetoOutbox DB table", func(t *testing.T) {
app := fiber.New()
app.Delete("/tenants/:id/relations", h.RemoveRelation)
mockKeto.On("DeleteRelation", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return(nil).Once()
body, _ := json.Marshal(map[string]string{
"userId": userID,
"relation": "schema_managers",
})
req := httptest.NewRequest("DELETE", "/tenants/"+tenantID+"/relations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Verify delete action row was written to the keto_outboxes DB table
var outboxEntries []domain.KetoOutbox
if err := db.Where("object = ? AND relation = ? AND action = ?", tenantID, "schema_managers", domain.KetoOutboxActionDelete).Find(&outboxEntries).Error; err != nil {
t.Fatalf("failed to query outbox: %v", err)
}
assert.Len(t, outboxEntries, 1)
assert.Equal(t, "Tenant", outboxEntries[0].Namespace)
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status)
})
}
func TestTenantHandler_SystemRelations(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
db := newTenantHandlerSeedDeleteDB(t)
if err := db.AutoMigrate(&domain.KetoOutbox{}); err != nil {
t.Fatalf("failed to migrate outbox: %v", err)
}
mockSvc := new(MockTenantService)
mockKeto := new(devMockKetoService)
realOutbox := repository.NewKetoOutboxRepository(db)
h := &TenantHandler{
DB: db,
Service: mockSvc,
Keto: mockKeto,
KetoOutbox: realOutbox,
}
userID := "user-system-1"
t.Run("ListSystemRelations - Returns correct system relations", func(t *testing.T) {
app := fiber.New()
app.Get("/system/relations", h.ListSystemRelations)
mockKeto.On("ListRelations", mock.Anything, "System", "system", "", "").Return([]service.RelationTuple{
{
Namespace: "System",
Object: "system",
Relation: "ory_ssot_viewers",
SubjectID: "User:" + userID,
},
{
Namespace: "System",
Object: "system",
Relation: "audit_logs_viewers",
SubjectID: "User:" + userID,
},
}, nil).Once()
req := httptest.NewRequest("GET", "/system/relations", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got struct {
Items []struct {
UserID string `json:"userId"`
Relations []string `json:"relations"`
} `json:"items"`
}
err = json.NewDecoder(resp.Body).Decode(&got)
if err != nil {
t.Fatalf("failed to decode response: %v", err)
}
assert.Len(t, got.Items, 1)
assert.Equal(t, userID, got.Items[0].UserID)
assert.Contains(t, got.Items[0].Relations, "ory_ssot_viewers")
assert.Contains(t, got.Items[0].Relations, "audit_logs_viewers")
mockKeto.AssertExpectations(t)
})
t.Run("AddSystemRelation - Inserts into KetoOutbox DB table with System namespace", func(t *testing.T) {
app := fiber.New()
app.Post("/system/relations", h.AddSystemRelation)
mockKeto.On("ListRelations", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once()
mockKeto.On("CreateRelation", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return(nil).Once()
body, _ := json.Marshal(map[string]string{
"userId": userID,
"relation": "ory_ssot_viewers",
})
req := httptest.NewRequest("POST", "/system/relations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var outboxEntries []domain.KetoOutbox
if err := db.Where("object = ? AND relation = ? AND action = ?", "system", "ory_ssot_viewers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil {
t.Fatalf("failed to query outbox: %v", err)
}
assert.Len(t, outboxEntries, 1)
assert.Equal(t, "System", outboxEntries[0].Namespace)
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status)
mockKeto.AssertExpectations(t)
})
}

View File

@@ -234,7 +234,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
slog.Warn("Skipping local user sync during AddMember because identity read is unavailable", "user", userID, "error", identityErr)
}
} else {
slog.Warn("Skipping local user sync during AddMember because identity projection is unavailable", "user", userID, "error", err)
slog.Warn("Skipping local user sync during AddMember because identity read is unavailable", "user", userID, "error", err)
}
}
if localUser != nil {

View File

@@ -1,11 +1,19 @@
[msg]
[msg.admin]
[msg.admin.audit]
subtitle = "View administrator activity history."
[msg.common]
loading_more = "Loading more logs..."
copied = "Copied."
error = "Error"
forbidden = "Access denied."
loading = "Loading..."
no_results = "No results found."
loading_more = "Loading more logs..."
no_description = "No Description."
no_results = "No results found."
parsing = "Parsing data..."
requesting = "Requesting..."
saving = "Saving..."
@@ -20,20 +28,38 @@ loading = "Loading audit logs..."
[msg.common.audit.registry]
count = "{{count}} logs"
[msg.admin.audit]
subtitle = "View administrator activity history."
[msg.dev]
[msg.dev.audit]
subtitle = "View developer activity history within the current app scope."
[ui]
[ui.admin]
[ui.admin.integrity]
fetch_error = "Unable to load the final integrity check result."
[ui.admin.integrity.section]
tenant_integrity = "Tenant integrity"
user_integrity = "User integrity"
[ui.admin.integrity.summary]
failures_text = "Failures {{count}}"
title = "Final integrity check"
[ui.admin.overview]
[ui.admin.overview.chart]
description = "Check the graph by all or selected organizations."
title = "Login request status by company and app"
[ui.common]
no_results = "No results to display."
apply = "Apply"
action = "Action"
actions = "Actions"
add = "Add"
all = "All"
apply = "Apply"
admin_only = "Admin Only"
all = "All"
apply = "Apply"
approve = "Approve"
assign = "Assign"
@@ -61,42 +87,45 @@ export_with_ids = "Include UUID"
export_without_ids = "Export without UUID"
fail = "Fail"
go_home = "Go Home"
info = "Info"
view = "View"
hyphen = "-"
info = "Info"
language = "Language"
language_en = "English"
language_ko = "Korean"
load_more = "Load more"
loading = "Loading..."
manage = "Manage"
move = "Move"
move_org = "Move to another organization"
na = "N/A"
name = "Name"
never = "Never"
next = "Next"
no_results = "No results to display."
none = "None"
page_of = "Page {{page}} of {{total}}"
prev = "Prev"
previous = "Previous"
qr = "QR"
reject = "Reject"
rejected = "Rejected"
reset = "Reset"
read = "Read"
read_only = "Read Only"
refresh = "Refresh"
reject = "Reject"
rejected = "Rejected"
remove = "Remove"
remove_org = "Remove from organization"
resend = "Resend"
reset = "Reset"
retry = "Retry"
row = "Row"
save = "Save"
search = "Search"
search_group = "Search groups..."
searching = "Searching..."
select = "Select"
select_file = "Select File"
select_placeholder = "Select Placeholder"
load_more = "Load more"
show_more = "Show More"
language = "Language"
language_ko = "Korean"
language_en = "English"
submit = "Submit"
submitting = "Submitting..."
success = "Success"
@@ -105,6 +134,8 @@ theme_light = "Light"
theme_toggle = "Theme Toggle"
unassigned = "Unassigned"
unknown = "Unknown"
view = "View"
write = "Write"
[ui.common.audit]
load_more = "Load more"
@@ -114,12 +145,6 @@ title = "Audit Logs"
actor_id = "Copy User ID"
target = "Copy Client ID"
[ui.common.audit.filters]
user_id = "Filter by User ID"
client_id = "Filter by Client ID"
action = "Filter by Action (e.g. ROTATE_SECRET)"
status_all = "All Status"
[ui.common.audit.details]
actor = "User ID"
actor_id = "User ID · {{value}}"
@@ -135,24 +160,38 @@ path = "Path · {{value}}"
request = "Request"
request_id = "Request ID · {{value}}"
result = "Result"
tenant = "Tenant · {{value}}"
target = "Client ID · {{value}}"
tenant = "Tenant · {{value}}"
[ui.common.audit.filters]
action = "Filter by Action (e.g. ROTATE_SECRET)"
client_id = "Filter by Client ID"
status_all = "All Status"
user_id = "Filter by User ID"
[ui.common.audit.registry]
title = "Audit registry"
[ui.common.audit.table]
no_logs = "No logs to display."
action = "Action"
actor = "User ID"
client_id = "Client ID"
user_id = "User ID"
no_logs = "No logs to display."
status = "Status"
target = "Client ID"
time = "Time"
user_id = "User ID"
[ui.common.overview]
title = "Operational Status"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.chart]
[ui.common.chart.axis]
x = "X-axis: Period"
y = "Y-axis: Login Requests"
[ui.common.chart.period]
day = "Day"
@@ -162,29 +201,12 @@ week = "Week"
[ui.common.chart.series_summary]
login_users = "Login {{login}} / Users {{subjects}}"
[ui.common.chart.axis]
x = "X-axis: Period"
y = "Y-axis: Login Requests"
[ui.admin.integrity]
fetch_error = "Unable to load the final integrity check result."
[ui.admin.integrity.summary]
failures_text = "Failures {{count}}"
title = "Final integrity check"
[ui.admin.integrity.section]
tenant_integrity = "Tenant integrity"
user_integrity = "User integrity"
[ui.admin.overview.chart]
description = "Check the graph by all or selected organizations."
title = "Login request status by company and app"
[ui.common.badge]
[ui.common.custom_claim_permission]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
user_and_admin = "User and admin"
[ui.common.overview]
title = "Operational Status"
[ui.common.status]
active = "Active"
@@ -197,10 +219,3 @@ pending = "Pending"
success = "Success"
unchanged = "Unchanged"
updated = "Updated"
[ui.common]
searching = "Searching..."
[ui.common.custom_claim_permission]
admin_only = "Admin only"
user_and_admin = "User and admin"

View File

@@ -1,11 +1,19 @@
[msg]
[msg.admin]
[msg.admin.audit]
subtitle = "관리자 작업 이력을 조회합니다."
[msg.common]
loading_more = "추가 로그를 불러오는 중..."
copied = "복사되었습니다."
error = "오류가 발생했습니다."
forbidden = "접근 권한이 없습니다."
loading = "로딩 중..."
no_results = "검색 결과가 없습니다."
loading_more = "추가 로그를 불러오는 중..."
no_description = "설명이 없습니다."
no_results = "검색 결과가 없습니다."
parsing = "데이터 파싱 중..."
requesting = "요청 중..."
saving = "저장 중..."
@@ -20,20 +28,38 @@ loading = "Loading audit logs..."
[msg.common.audit.registry]
count = "총 {{count}}개 로그"
[msg.admin.audit]
subtitle = "관리자 작업 이력을 조회합니다."
[msg.dev]
[msg.dev.audit]
subtitle = "현재 앱 범위의 개발자 작업 이력을 조회합니다."
[ui]
[ui.admin]
[ui.admin.integrity]
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
[ui.admin.integrity.section]
tenant_integrity = "테넌트 정합성"
user_integrity = "사용자 정합성"
[ui.admin.integrity.summary]
failures_text = "실패 {{count}}건"
title = "정합성 최종 검증"
[ui.admin.overview]
[ui.admin.overview.chart]
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
title = "회사별 앱별 로그인 요청 현황"
[ui.common]
no_results = "표시할 결과가 없습니다."
apply = "적용"
action = "작업"
actions = "액션"
add = "추가"
all = "전체"
apply = "적용"
admin_only = "관리자 전용"
all = "전체"
apply = "적용"
approve = "승인"
assign = "할당"
@@ -61,42 +87,45 @@ export_with_ids = "UUID 포함"
export_without_ids = "UUID 제외 내보내기"
fail = "실패"
go_home = "홈으로"
info = "상세 안내"
view = "보기"
hyphen = "-"
info = "상세 안내"
language = "언어"
language_en = "English"
language_ko = "한국어"
load_more = "더 보기"
loading = "로딩 중..."
manage = "관리"
move = "이동"
move_org = "타 조직으로 이동"
na = "N/A"
name = "이름"
never = "Never"
next = "다음"
no_results = "표시할 결과가 없습니다."
none = "없음"
page_of = "Page {{page}} of {{total}}"
prev = "이전"
previous = "이전"
qr = "QR"
reject = "반려"
rejected = "반려됨"
reset = "초기화"
read = "조회 가능 (Read)"
read_only = "읽기 전용"
refresh = "새로고침"
reject = "반려"
rejected = "반려됨"
remove = "제외"
remove_org = "조직에서 제외"
resend = "재발송"
reset = "초기화"
retry = "다시 시도"
row = "행"
save = "저장"
search = "검색"
search_group = "그룹 검색..."
searching = "검색 중..."
select = "선택"
select_file = "파일 선택"
select_placeholder = "선택하세요"
load_more = "더 보기"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
submit = "신청하기"
submitting = "제출 중..."
success = "성공"
@@ -105,6 +134,8 @@ theme_light = "Light"
theme_toggle = "테마 전환"
unassigned = "미배정"
unknown = "Unknown"
view = "보기"
write = "수정 가능 (Write)"
[ui.common.audit]
load_more = "더 보기"
@@ -114,12 +145,6 @@ title = "감사 로그"
actor_id = "사용자 ID 복사"
target = "클라이언트 ID 복사"
[ui.common.audit.filters]
user_id = "사용자 ID로 검색"
client_id = "클라이언트 ID로 검색"
action = "액션으로 검색 (예: ROTATE_SECRET)"
status_all = "전체 상태"
[ui.common.audit.details]
actor = "사용자 ID"
actor_id = "사용자 ID · {{value}}"
@@ -135,24 +160,38 @@ path = "Path · {{value}}"
request = "Request"
request_id = "Request ID · {{value}}"
result = "Result"
tenant = "Tenant · {{value}}"
target = "클라이언트 ID · {{value}}"
tenant = "Tenant · {{value}}"
[ui.common.audit.filters]
action = "액션으로 검색 (예: ROTATE_SECRET)"
client_id = "클라이언트 ID로 검색"
status_all = "전체 상태"
user_id = "사용자 ID로 검색"
[ui.common.audit.registry]
title = "감사 로그 레지스트리"
[ui.common.audit.table]
no_logs = "표시할 로그가 없습니다."
action = "작업"
actor = "사용자 ID"
client_id = "클라이언트 ID"
user_id = "사용자 ID"
no_logs = "표시할 로그가 없습니다."
status = "상태"
target = "클라이언트 ID"
time = "시간"
user_id = "사용자 ID"
[ui.common.overview]
title = "운영 현황"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.chart]
[ui.common.chart.axis]
x = "X축: 기간"
y = "Y축: 로그인 요청 수"
[ui.common.chart.period]
day = "일"
@@ -162,29 +201,12 @@ week = "주"
[ui.common.chart.series_summary]
login_users = "로그인 {{login}} / 사용자 {{subjects}}"
[ui.common.chart.axis]
x = "X축: 기간"
y = "Y축: 로그인 요청 수"
[ui.common.custom_claim_permission]
admin_only = "관리자만 가능"
user_and_admin = "사용자와 관리자"
[ui.admin.integrity]
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
[ui.admin.integrity.summary]
failures_text = "실패 {{count}}건"
title = "정합성 최종 검증"
[ui.admin.integrity.section]
tenant_integrity = "테넌트 정합성"
user_integrity = "사용자 정합성"
[ui.admin.overview.chart]
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
title = "회사별 앱별 로그인 요청 현황"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.overview]
title = "운영 현황"
[ui.common.status]
active = "활성"
@@ -197,10 +219,3 @@ pending = "준비 중"
success = "성공"
unchanged = "동일"
updated = "수정"
[ui.common]
searching = "검색 중..."
[ui.common.custom_claim_permission]
admin_only = "관리자만 가능"
user_and_admin = "사용자와 관리자"

View File

@@ -1,11 +1,19 @@
[msg]
[msg.admin]
[msg.admin.audit]
subtitle = ""
[msg.common]
loading_more = ""
copied = ""
error = ""
forbidden = ""
loading = ""
no_results = ""
loading_more = ""
no_description = ""
no_results = ""
parsing = ""
requesting = ""
saving = ""
@@ -20,20 +28,38 @@ loading = ""
[msg.common.audit.registry]
count = ""
[msg.admin.audit]
subtitle = ""
[msg.dev]
[msg.dev.audit]
subtitle = ""
[ui]
[ui.admin]
[ui.admin.integrity]
fetch_error = ""
[ui.admin.integrity.section]
tenant_integrity = ""
user_integrity = ""
[ui.admin.integrity.summary]
failures_text = ""
title = ""
[ui.admin.overview]
[ui.admin.overview.chart]
description = ""
title = ""
[ui.common]
no_results = ""
apply = "Apply"
action = ""
actions = ""
add = ""
all = ""
apply = ""
admin_only = ""
all = ""
apply = ""
approve = ""
assign = ""
@@ -61,42 +87,45 @@ export_with_ids = ""
export_without_ids = ""
fail = ""
go_home = ""
info = ""
view = ""
hyphen = ""
info = ""
language = ""
language_en = ""
language_ko = ""
load_more = ""
loading = ""
manage = ""
move = ""
move_org = ""
na = ""
name = ""
never = ""
next = ""
no_results = ""
none = ""
page_of = ""
prev = ""
previous = ""
qr = ""
reject = ""
rejected = ""
reset = ""
read = ""
read_only = ""
refresh = ""
reject = ""
rejected = ""
remove = ""
remove_org = ""
resend = ""
reset = ""
retry = ""
row = ""
save = ""
search = ""
search_group = ""
searching = ""
select = ""
select_file = ""
select_placeholder = ""
load_more = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
submit = ""
submitting = ""
success = ""
@@ -105,6 +134,8 @@ theme_light = ""
theme_toggle = ""
unassigned = ""
unknown = ""
view = ""
write = ""
[ui.common.audit]
load_more = ""
@@ -114,12 +145,6 @@ title = ""
actor_id = ""
target = ""
[ui.common.audit.filters]
user_id = ""
client_id = ""
action = ""
status_all = ""
[ui.common.audit.details]
actor = ""
actor_id = ""
@@ -135,24 +160,38 @@ path = ""
request = ""
request_id = ""
result = ""
tenant = ""
target = ""
tenant = ""
[ui.common.audit.filters]
action = ""
client_id = ""
status_all = ""
user_id = ""
[ui.common.audit.registry]
title = ""
[ui.common.audit.table]
no_logs = ""
action = ""
actor = ""
client_id = ""
user_id = ""
no_logs = ""
status = ""
target = ""
time = ""
user_id = ""
[ui.common.overview]
title = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.chart]
[ui.common.chart.axis]
x = ""
y = ""
[ui.common.chart.period]
day = ""
@@ -162,29 +201,12 @@ week = ""
[ui.common.chart.series_summary]
login_users = ""
[ui.common.chart.axis]
x = ""
y = ""
[ui.admin.integrity]
fetch_error = ""
[ui.admin.integrity.summary]
failures_text = ""
title = ""
[ui.admin.integrity.section]
tenant_integrity = ""
user_integrity = ""
[ui.admin.overview.chart]
description = ""
title = ""
[ui.common.badge]
[ui.common.custom_claim_permission]
admin_only = ""
command_only = ""
system = ""
user_and_admin = ""
[ui.common.overview]
title = ""
[ui.common.status]
active = ""
@@ -197,10 +219,3 @@ pending = ""
success = ""
unchanged = ""
updated = ""
[ui.common]
searching = ""
[ui.common.custom_claim_permission]
admin_only = ""
user_and_admin = ""

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 KiB

View File

@@ -12,6 +12,7 @@
"lint": "biome check .",
"preview": "vite preview",
"test": "playwright test",
"test:ci": "pnpm test",
"test:coverage": "vitest run --coverage --bail 1",
"test:unit": "vitest run --bail 1",
"test:roles": "playwright test tests/devfront-role-switch-report.spec.ts",

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act } from "react";
import { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AppLayout from "./AppLayout";
@@ -49,6 +49,24 @@ vi.mock("../../lib/i18n", () => ({
const roots: Root[] = [];
type TestWindow = Window & {
__baronNavigate?: (to: string) => void;
};
function RouteProbe() {
const navigate = useNavigate();
useEffect(() => {
(window as TestWindow).__baronNavigate = navigate;
return () => {
delete (window as TestWindow).__baronNavigate;
};
}, [navigate]);
return <div>Client outlet</div>;
}
beforeEach(() => {
authState.isAuthenticated = true;
authState.isLoading = false;
@@ -89,7 +107,7 @@ async function renderLayout(initialEntry = "/clients") {
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/" element={<AppLayout />}>
<Route path="clients" element={<div>Client outlet</div>} />
<Route path="clients" element={<RouteProbe />} />
<Route path="profile" element={<div>Profile outlet</div>} />
</Route>
</Routes>
@@ -181,4 +199,15 @@ describe("devfront AppLayout", () => {
expect(authState.signinSilent).toHaveBeenCalled();
});
it("attempts silent renewal when route changes and the session is expiring", async () => {
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
await renderLayout();
await act(async () => {
(window as TestWindow).__baronNavigate?.("/profile");
});
expect(authState.signinSilent).toHaveBeenCalled();
});
});

View File

@@ -201,4 +201,85 @@ describe("ClientConsentsPage RP custom claims", () => {
}),
);
});
it("keeps date claim inputs and timezone selectors on the same row", async () => {
fetchClientMock.mockResolvedValue({
...clientDetail,
client: {
...clientDetail.client,
metadata: {
id_token_claims: [
{
namespace: "rp_claims",
key: "contract_date",
value: "",
valueType: "date",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
},
});
fetchConsentsMock.mockResolvedValue({
items: [
{
subject: "user-1",
userName: "Consent User",
clientId: "client-a",
clientName: "Claims App",
grantedScopes: ["openid", "profile"],
authenticatedAt: "2026-06-11T09:00:00Z",
createdAt: "2026-06-10T09:00:00Z",
status: "active",
tenantId: "tenant-1",
tenantName: "Hanmac",
rpMetadata: {
contract_date: 1781017200,
contract_date_permissions: {
readPermission: "admin_only",
writePermission: "admin_only",
},
},
},
],
});
fetchRPUserMetadataMock.mockResolvedValue({
clientId: "client-a",
userId: "user-1",
metadata: {
contract_date: 1781017200,
contract_date_permissions: {
readPermission: "admin_only",
writePermission: "admin_only",
},
},
});
const { container } = await renderPage();
const editButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("사용자 Claim 설정") ||
button.textContent?.includes("User Claim Settings"),
);
await act(async () => {
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const dateInput = container.querySelector(
'input[aria-label="contract_date date"]',
);
const timeZoneSelect = container.querySelector(
'select[aria-label="contract_date timezone"]',
);
expect(dateInput).not.toBeNull();
expect(timeZoneSelect).not.toBeNull();
expect(dateInput?.parentElement).toBe(timeZoneSelect?.parentElement);
expect(dateInput?.parentElement?.className).toContain("items-center");
expect(dateInput?.parentElement?.className).not.toContain("flex-col");
});
});

View File

@@ -1060,7 +1060,14 @@ function ClientConsentsPage() {
aria-label={`${row.key} ${row.valueType}`}
/>
) : (
<div className="flex flex-col gap-2">
<div
className={cn(
"flex gap-2",
row.valueType === "date" || row.valueType === "datetime"
? "items-center"
: "flex-col",
)}
>
<Input
type={rpClaimInputType(row.valueType)}
inputMode={rpClaimInputMode(row.valueType)}
@@ -1087,7 +1094,7 @@ function ClientConsentsPage() {
timeZone: event.target.value,
})
}
className="h-10 rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="h-10 min-w-[160px] rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={`${row.key} timezone`}
>
{timeZoneOptions.map((timeZone) => (

View File

@@ -409,7 +409,7 @@ describe("ClientGeneralPage RP claims", () => {
);
});
it("shows supported scopes and custom claims without integrated offline_access from the add scope button", async () => {
it("shows supported scopes including offline_access and custom claims from the add scope button", async () => {
const { container } = await renderPage();
const addScopeButton = Array.from(
@@ -422,7 +422,7 @@ describe("ClientGeneralPage RP claims", () => {
});
await flush();
expect(container.textContent).not.toContain("offline_access");
expect(container.textContent).toContain("offline_access");
expect(container.textContent).toContain("old_claim");
const customClaimButton = Array.from(

View File

@@ -759,6 +759,15 @@ function ClientGeneralPage() {
description: tenantScopeDescription,
source: "standard",
},
{
id: "standard-offline-access",
name: "offline_access",
description: t(
"msg.dev.clients.scopes.offline_access",
"refresh token 발급 요청",
),
source: "standard",
},
],
[tenantScopeDescription],
);
@@ -2789,6 +2798,7 @@ function ClientGeneralPage() {
) : (
<div className="flex flex-col gap-2">
<Input
key={claim.valueType}
type={claimDefaultInputType(claim.valueType)}
inputMode={claimDefaultInputMode(
claim.valueType,

View File

@@ -99,7 +99,7 @@ test.describe("DevFront RP claim cache", () => {
await expect(claimKeyInput).toHaveValue("new_claim");
});
test("adds supported scopes and custom claim keys from the scope picker without offline_access", async ({
test("adds supported scopes and custom claim keys from the scope picker including offline_access", async ({
page,
}) => {
const state = {
@@ -142,9 +142,9 @@ test.describe("DevFront RP claim cache", () => {
.getByRole("button", { name: /스코프 추가|Scope 추가|Add Scope/i })
.click();
await expect(page.getByText("offline_access", { exact: true })).toHaveCount(
0,
);
await expect(
page.getByText("offline_access", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /employee_code/ }),
).toBeVisible();
@@ -328,7 +328,11 @@ test.describe("DevFront RP claim cache", () => {
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first()
.fill("3.14");
const responsePromise = page.waitForResponse(
"**/api/v1/dev/clients/client-claims",
);
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await responsePromise;
await expect
.poll(
@@ -357,13 +361,7 @@ test.describe("DevFront RP claim cache", () => {
const defaultValueInput = page
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first();
await expect(defaultValueInput).toHaveAttribute("inputmode", "numeric");
await expect(defaultValueInput).toHaveAttribute("pattern", "-?[0-9]*");
await defaultValueInput.fill("3.14");
await expect(
page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();

View File

@@ -0,0 +1,239 @@
import { expect, test } from "@playwright/test";
import {
getPersistedOidcUser,
installDevApiMock,
seedAuth,
} from "./helpers/devfront-fixtures";
import { captureEvidence } from "./helpers/evidence";
type ClaimScenario = {
title: string;
role: "super_admin" | "user";
tenantName: string;
userMeTenantId: string;
userMeCompanyCode: string;
profileClaims: Record<string, unknown>;
expectedProfileAssertions: Record<string, unknown>;
expectTenantsToBeAbsent?: boolean;
};
const claimScenarios: ClaimScenario[] = [
{
title: "Server Side App preserves tenant and rp claims",
role: "super_admin",
tenantName: "Server Side Tenant",
userMeTenantId: "tenant-server",
userMeCompanyCode: "server-hq",
profileClaims: {
tenant_id: "tenant-server",
companyCode: "server-hq",
profile: {
names: {
name: "서버 앱 사용자",
},
emails: ["server@example.com"],
},
joined_tenants: ["tenant-server", "tenant-ops"],
tenants: {
"tenant-server": {
department: "Platform",
grade: "Lead",
},
"tenant-ops": {
department: "Operations",
grade: "Member",
},
},
rp_claims: {
approvalLevel: "A",
},
metadata: {
rp_custom_claims: {
"server-app": {
approvalLevel: "A",
},
},
},
},
expectedProfileAssertions: {
tenant_id: "tenant-server",
companyCode: "server-hq",
joined_tenants: ["tenant-server", "tenant-ops"],
rp_claims: {
approvalLevel: "A",
},
},
},
{
title: "PKCE preserves nested profile claims without tenant map expansion",
role: "user",
tenantName: "PKCE Tenant",
userMeTenantId: "tenant-pkce",
userMeCompanyCode: "pkce-hq",
profileClaims: {
tenant_id: "tenant-pkce",
companyCode: "pkce-hq",
profile: {
names: {
name: "PKCE 사용자",
},
emails: ["pkce@example.com"],
},
joined_tenants: ["tenant-pkce"],
rp_claims: {
features: ["sso", "claims"],
},
metadata: {
rp_custom_claims: {
"pkce-app": {
features: ["sso", "claims"],
},
},
},
},
expectedProfileAssertions: {
tenant_id: "tenant-pkce",
companyCode: "pkce-hq",
joined_tenants: ["tenant-pkce"],
rp_claims: {
features: ["sso", "claims"],
},
},
expectTenantsToBeAbsent: true,
},
{
title: "Headless login keeps session claims together with rp claims",
role: "super_admin",
tenantName: "Headless Tenant",
userMeTenantId: "tenant-headless",
userMeCompanyCode: "headless-hq",
profileClaims: {
tenant_id: "tenant-headless",
companyCode: "headless-hq",
profile: {
names: {
name: "헤드리스 사용자",
},
emails: ["headless@example.com"],
},
joined_tenants: ["tenant-headless", "tenant-support"],
tenants: {
"tenant-headless": {
department: "Automation",
grade: "Manager",
},
"tenant-support": {
department: "Support",
grade: "Agent",
},
},
rp_claims: {
approvalLevel: "B",
loginMode: "headless",
},
sid: "session-headless-1",
session_id: "session-headless-1",
metadata: {
rp_custom_claims: {
"headless-app": {
approvalLevel: "B",
loginMode: "headless",
},
},
},
},
expectedProfileAssertions: {
tenant_id: "tenant-headless",
companyCode: "headless-hq",
joined_tenants: ["tenant-headless", "tenant-support"],
rp_claims: {
approvalLevel: "B",
loginMode: "headless",
},
sid: "session-headless-1",
session_id: "session-headless-1",
},
},
];
test.describe("DevFront login claims", () => {
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status === "passed") {
await captureEvidence(page, testInfo, testInfo.title);
}
});
for (const scenario of claimScenarios) {
test(scenario.title, async ({ page }) => {
await seedAuth(page, {
role: scenario.role,
profile: scenario.profileClaims,
});
await installDevApiMock(page, {
clients: [],
consents: [],
auditLogsByCursor: undefined,
users: [],
tenants: [
{
id: scenario.userMeTenantId,
name: scenario.tenantName,
slug: scenario.userMeCompanyCode,
},
],
});
await page.route("**/api/v1/user/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "playwright-user",
loginId: "playwright@example.com",
email: "playwright@example.com",
name: "Playwright User",
phoneNumber: "",
department: "QA",
tenantId: "",
tenantName: "",
role: scenario.role,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
}),
});
});
await page.goto("/profile");
await expect(
page.getByRole("heading", { name: "내 정보" }),
).toBeVisible();
const storedUser = await getPersistedOidcUser(page);
expect(storedUser).not.toBeNull();
expect(storedUser?.profile).toMatchObject(
scenario.expectedProfileAssertions,
);
if (scenario.expectTenantsToBeAbsent) {
expect(storedUser?.profile).not.toHaveProperty("tenants");
} else {
expect(storedUser?.profile).toHaveProperty("tenants");
}
await expect(
page.getByText(String(scenario.profileClaims.tenant_id)),
).toBeVisible();
await expect(page.getByText(scenario.userMeCompanyCode)).toBeVisible();
await page.getByRole("button", { name: "권한 및 역할" }).click();
await expect(
page.getByRole("heading", { name: "시스템 역할" }),
).toBeVisible();
await expect(
page.getByText(
scenario.role === "super_admin"
? /^(시스템 관리자|Super Admin|SUPER_ADMIN)$/i
: /^(일반 사용자|General User|USER)$/i,
),
).toBeVisible();
});
}
});

View File

@@ -73,6 +73,22 @@ export type DeveloperRequest = {
adminNotes?: string; // 추가
};
export type SeedAuthOptions = {
role?: string;
accessToken?: string;
idToken?: string;
refreshToken?: string;
sessionState?: string;
expiresInSeconds?: number;
state?: Record<string, unknown>;
profile?: Record<string, unknown>;
tenantId?: string;
companyCode?: string;
email?: string;
name?: string;
phone?: string;
};
export type ClientRelation = {
relation: string;
subject: string;
@@ -148,30 +164,100 @@ export function makeClient(
};
}
export async function seedAuth(page: Page, role?: string) {
function resolveSeedAuthOptions(
roleOrOptions?: string | SeedAuthOptions,
): Required<Pick<SeedAuthOptions, "role">> & SeedAuthOptions {
if (typeof roleOrOptions === "string") {
return { role: roleOrOptions };
}
return { role: roleOrOptions?.role ?? "super_admin", ...roleOrOptions };
}
export async function getPersistedOidcUser(page: Page) {
return page.evaluate(() => {
const storage = window.localStorage;
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (
key === null ||
!key.startsWith("oidc.user:") ||
!key.endsWith(":devfront")
) {
continue;
}
const rawValue = storage.getItem(key);
if (!rawValue) {
continue;
}
try {
return JSON.parse(rawValue) as Record<string, unknown>;
} catch {
return null;
}
}
return null;
});
}
export async function seedAuth(
page: Page,
roleOrOptions?: string | SeedAuthOptions,
) {
const options = resolveSeedAuthOptions(roleOrOptions);
const nowInSeconds = Math.floor(Date.now() / 1000);
seededRoles.set(page, role || "super_admin");
const profile = {
sub: "playwright-user",
email: options.email ?? "playwright@example.com",
name: options.name ?? "Playwright User",
phone: options.phone ?? "",
role: options.profile?.role ?? options.role,
tenant_id: options.tenantId ?? "tenant-a",
companyCode: options.companyCode ?? "tenant-a",
...options.profile,
};
seededRoles.set(
page,
typeof profile.role === "string" ? profile.role : options.role,
);
await page.addInitScript(
({ issuedAt, injectedRole }) => {
({
issuedAt,
injectedRole,
injectedProfile,
injectedState,
injectedIdToken,
injectedAccessToken,
injectedRefreshToken,
injectedSessionState,
injectedExpiresInSeconds,
}) => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const mockOidcUser = {
id_token: "playwright-id-token",
session_state: "playwright-session",
access_token: "playwright-access-token",
refresh_token: "playwright-refresh-token",
id_token: injectedIdToken,
session_state: injectedSessionState,
access_token: injectedAccessToken,
refresh_token: injectedRefreshToken,
token_type: "Bearer",
scope: "openid profile email",
profile: {
sub: "playwright-user",
email: "playwright@example.com",
name: "Playwright User",
...(injectedRole ? { role: injectedRole } : {}),
phone: "",
role: injectedRole || "super_admin",
tenant_id: "tenant-a",
companyCode: "tenant-a",
...(injectedProfile || {}),
},
expires_at: issuedAt + 3600,
state: injectedState,
expires_at: issuedAt + injectedExpiresInSeconds,
};
const storageKeys = [
@@ -191,9 +277,25 @@ export async function seedAuth(page: Page, role?: string) {
}
window.localStorage.setItem("dev_role", injectedRole || "super_admin");
window.localStorage.setItem("dev_tenant_id", "tenant-a");
window.localStorage.setItem(
"dev_tenant_id",
typeof injectedProfile.tenant_id === "string"
? injectedProfile.tenant_id
: "tenant-a",
);
},
{
issuedAt: nowInSeconds,
injectedRole:
typeof profile.role === "string" ? profile.role : options.role,
injectedProfile: profile,
injectedState: options.state ?? { returnTo: "/clients" },
injectedIdToken: options.idToken ?? "playwright-id-token",
injectedAccessToken: options.accessToken ?? "playwright-access-token",
injectedRefreshToken: options.refreshToken ?? "playwright-refresh-token",
injectedSessionState: options.sessionState ?? "playwright-session",
injectedExpiresInSeconds: options.expiresInSeconds ?? 3600,
},
{ issuedAt: nowInSeconds, injectedRole: role ?? "" },
);
await page.route("**/oidc/**", async (route) => {

View File

@@ -6,11 +6,126 @@ class System implements Namespace {
related: {
super_admins: User[]
authenticated_users: User[]
// 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 - 조회(Read)
overview_viewers: User[]
tenants_viewers: User[]
org_chart_viewers: User[]
worksmobile_viewers: User[]
ory_ssot_viewers: User[]
data_integrity_viewers: User[]
users_viewers: User[]
permissions_direct_viewers: User[]
auth_guard_viewers: User[]
api_keys_viewers: User[]
audit_logs_viewers: User[]
// 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 - 수정(Write)
overview_managers: User[]
tenants_managers: User[]
org_chart_managers: User[]
worksmobile_managers: User[]
ory_ssot_managers: User[]
data_integrity_managers: User[]
users_managers: User[]
permissions_direct_managers: User[]
auth_guard_managers: User[]
api_keys_managers: User[]
audit_logs_managers: User[]
}
permits = {
manage_all: (ctx: Context): boolean =>
this.related.super_admins.includes(ctx.subject)
this.related.super_admins.includes(ctx.subject),
// 🌟 글로벌 메뉴 허가 규칙 (Permit Rules) - 조회(access_)와 수정(manage_) 완전 분리 이원화
access_overview: (ctx: Context): boolean =>
this.related.overview_viewers.includes(ctx.subject) ||
this.permits.manage_overview(ctx),
manage_overview: (ctx: Context): boolean =>
this.related.overview_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx),
access_tenants: (ctx: Context): boolean =>
this.related.tenants_viewers.includes(ctx.subject) ||
this.permits.manage_tenants(ctx),
manage_tenants: (ctx: Context): boolean =>
this.related.tenants_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx),
access_org_chart: (ctx: Context): boolean =>
this.related.org_chart_viewers.includes(ctx.subject) ||
this.permits.manage_org_chart(ctx),
manage_org_chart: (ctx: Context): boolean =>
this.related.org_chart_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx),
access_worksmobile: (ctx: Context): boolean =>
this.related.worksmobile_viewers.includes(ctx.subject) ||
this.permits.manage_worksmobile(ctx),
manage_worksmobile: (ctx: Context): boolean =>
this.related.worksmobile_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx),
access_ory_ssot: (ctx: Context): boolean =>
this.related.ory_ssot_viewers.includes(ctx.subject) ||
this.permits.manage_ory_ssot(ctx),
manage_ory_ssot: (ctx: Context): boolean =>
this.related.ory_ssot_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx),
access_data_integrity: (ctx: Context): boolean =>
this.related.data_integrity_viewers.includes(ctx.subject) ||
this.permits.manage_data_integrity(ctx),
manage_data_integrity: (ctx: Context): boolean =>
this.related.data_integrity_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx),
access_users: (ctx: Context): boolean =>
this.related.users_viewers.includes(ctx.subject) ||
this.permits.manage_users(ctx),
manage_users: (ctx: Context): boolean =>
this.related.users_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx),
access_permissions_direct: (ctx: Context): boolean =>
this.related.permissions_direct_viewers.includes(ctx.subject) ||
this.permits.manage_permissions_direct(ctx),
manage_permissions_direct: (ctx: Context): boolean =>
this.related.permissions_direct_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx),
access_auth_guard: (ctx: Context): boolean =>
this.related.auth_guard_viewers.includes(ctx.subject) ||
this.permits.manage_auth_guard(ctx),
manage_auth_guard: (ctx: Context): boolean =>
this.related.auth_guard_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx),
access_api_keys: (ctx: Context): boolean =>
this.related.api_keys_viewers.includes(ctx.subject) ||
this.permits.manage_api_keys(ctx),
manage_api_keys: (ctx: Context): boolean =>
this.related.api_keys_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx),
access_audit_logs: (ctx: Context): boolean =>
this.related.audit_logs_viewers.includes(ctx.subject) ||
this.permits.manage_audit_logs(ctx),
manage_audit_logs: (ctx: Context): boolean =>
this.related.audit_logs_managers.includes(ctx.subject) ||
this.permits.manage_all(ctx)
}
}
@@ -22,9 +137,63 @@ class Tenant implements Namespace {
parents: Tenant[]
developer_console_viewer: (User | SubjectSet<System, "super_admins">)[]
developer_console_grant_manager: (User | SubjectSet<System, "super_admins">)[]
// 🌟 신규 직접 관계 (Direct Relations) 정의
profile_viewers: (User | SubjectSet<System, "super_admins">)[]
profile_managers: (User | SubjectSet<System, "super_admins">)[]
permissions_viewers: (User | SubjectSet<System, "super_admins">)[]
permissions_managers: (User | SubjectSet<System, "super_admins">)[]
organization_viewers: (User | SubjectSet<System, "super_admins">)[]
organization_managers: (User | SubjectSet<System, "super_admins">)[]
schema_viewers: (User | SubjectSet<System, "super_admins">)[]
schema_managers: (User | SubjectSet<System, "super_admins">)[]
}
permits = {
// 1. 프로필 (Profile) 탭 허가 규칙
view_profile: (ctx: Context): boolean =>
this.related.profile_viewers.includes(ctx.subject) ||
this.permits.manage_profile(ctx) ||
this.permits.view(ctx), // 멤버/관리자/소유자는 기본 조회 가능
manage_profile: (ctx: Context): boolean =>
this.related.profile_managers.includes(ctx.subject) ||
this.permits.manage(ctx), // 관리자/소유자는 기본 수정 가능
// 2. 권한 관리 (Permissions) 탭 허가 규칙
view_permissions: (ctx: Context): boolean =>
this.related.permissions_viewers.includes(ctx.subject) ||
this.permits.manage_permissions(ctx) ||
this.permits.view(ctx),
manage_permissions: (ctx: Context): boolean =>
this.related.permissions_managers.includes(ctx.subject) ||
this.permits.manage_admins(ctx), // 소유자는 기본 관리 가능
// 3. 조직 관리 (Organization) 탭 허가 규칙
view_organization: (ctx: Context): boolean =>
this.related.organization_viewers.includes(ctx.subject) ||
this.permits.manage_organization(ctx) ||
this.permits.view(ctx),
manage_organization: (ctx: Context): boolean =>
this.related.organization_managers.includes(ctx.subject) ||
this.permits.manage(ctx),
// 4. 사용자 스키마 (Schema) 탭 허가 규칙
view_schema: (ctx: Context): boolean =>
this.related.schema_viewers.includes(ctx.subject) ||
this.permits.manage_schema(ctx) ||
this.permits.view(ctx),
manage_schema: (ctx: Context): boolean =>
this.related.schema_managers.includes(ctx.subject) ||
this.permits.manage(ctx),
// --- 기존 마스터 및 상속 규칙 보존 ---
view: (ctx: Context): boolean =>
this.related.members.includes(ctx.subject) ||
this.related.admins.includes(ctx.subject) ||

View File

@@ -0,0 +1,181 @@
# [RFC/Design] adminfront: 각 탭별 ReBAC 기반 세부 권한 직접 부여 기능 설계
## 1. 배경 및 목적
현재 `adminfront` 테넌트 상세 페이지는 대략적인 역할 기반 제어(Coarse-grained RBAC/ReBAC) 형태로만 동작합니다.
운영자는 사용자를 **"소유자(Owner)"** 또는 **"테넌트 관리자(Admin)"**로만 임명할 수 있으며, 이 역할에 의해 테넌트 하위의 4개 탭(프로필, 권한 관리, 조직 관리, 사용자 스키마)의 읽기/쓰기 권한이 통째로 결정됩니다.
하지만 더욱 세밀한 운영 권한 관리가 필요하다는 비즈니스 요구사항에 따라, **"사용자 A에게는 조직 관리 및 스키마 읽기 권한만 부여"**, **"사용자 B에게는 스키마 수정 권한만 부여"**와 같이 탭 레벨에서 세분화된(Fine-grained) 권한을 직접 지정할 수 있는 기능을 신설합니다.
이 설계는 `devfront`에서 이슈 #1029를 통해 구현 완료한 **"RP 세부 관계 직접 부여"** 철학과 완벽히 동일하며, Ory Keto(ReBAC) 및 아웃박스 정합성 엔진을 관통하여 설계됩니다.
---
## 2. 세부 설계 사양
### 2.1 Ory Keto OPL 스키마 변경 (`docker/ory/keto/namespaces.ts`)
`Tenant` 네임스페이스 하위에 각 탭별 읽기(`_viewers`)와 쓰기(`_managers`)를 결정하는 **물리적인 직접 관계(Direct Relations)**를 추가합니다.
기존 `members`, `admins`, `owners`에 의한 상속 허가 식(Permits)을 유지하여 하위 호환성 및 기존 관리체계의 안정성을 완벽히 보장합니다.
```typescript
class Tenant implements Namespace {
related: {
owners: (User | SubjectSet<System, "super_admins">)[]
admins: (User | SubjectSet<System, "super_admins">)[]
members: (User | SubjectSet<System, "super_admins"> | SubjectSet<Tenant, "admins"> | SubjectSet<Tenant, "owners">)[]
parents: Tenant[]
developer_console_viewer: (User | SubjectSet<System, "super_admins">)[]
developer_console_grant_manager: (User | SubjectSet<System, "super_admins">)[]
// 🌟 신규 직접 관계 (Direct Relations) 정의
profile_viewers: (User | SubjectSet<System, "super_admins">)[]
profile_managers: (User | SubjectSet<System, "super_admins">)[]
permissions_viewers: (User | SubjectSet<System, "super_admins">)[]
permissions_managers: (User | SubjectSet<System, "super_admins">)[]
organization_viewers: (User | SubjectSet<System, "super_admins">)[]
organization_managers: (User | SubjectSet<System, "super_admins">)[]
schema_viewers: (User | SubjectSet<System, "super_admins">)[]
schema_managers: (User | SubjectSet<System, "super_admins">)[]
}
permits = {
// 1. 프로필 (Profile) 탭 허가 규칙
view_profile: (ctx: Context): boolean =>
this.related.profile_viewers.includes(ctx.subject) ||
this.permits.manage_profile(ctx) ||
this.permits.view(ctx), // 멤버/관리자/소유자는 기본 조회 가능
manage_profile: (ctx: Context): boolean =>
this.related.profile_managers.includes(ctx.subject) ||
this.permits.manage(ctx), // 관리자/소유자는 기본 수정 가능
// 2. 권한 관리 (Permissions) 탭 허가 규칙
view_permissions: (ctx: Context): boolean =>
this.related.permissions_viewers.includes(ctx.subject) ||
this.permits.manage_permissions(ctx) ||
this.permits.view(ctx),
manage_permissions: (ctx: Context): boolean =>
this.related.permissions_managers.includes(ctx.subject) ||
this.permits.manage_admins(ctx), // 소유자는 기본 관리 가능
// 3. 조직 관리 (Organization) 탭 허가 규칙
view_organization: (ctx: Context): boolean =>
this.related.organization_viewers.includes(ctx.subject) ||
this.permits.manage_organization(ctx) ||
this.permits.view(ctx),
manage_organization: (ctx: Context): boolean =>
this.related.organization_managers.includes(ctx.subject) ||
this.permits.manage(ctx),
// 4. 사용자 스키마 (Schema) 탭 허가 규칙
view_schema: (ctx: Context): boolean =>
this.related.schema_viewers.includes(ctx.subject) ||
this.permits.manage_schema(ctx) ||
this.permits.view(ctx),
manage_schema: (ctx: Context): boolean =>
this.related.schema_managers.includes(ctx.subject) ||
this.permits.manage(ctx),
// --- 기존 마스터 및 상속 규칙 보존 ---
view: (ctx: Context): boolean =>
this.related.members.includes(ctx.subject) ||
this.related.admins.includes(ctx.subject) ||
this.related.owners.includes(ctx.subject) ||
this.related.parents.traverse((p) => p.permits.view(ctx)),
manage: (ctx: Context): boolean =>
this.related.admins.includes(ctx.subject) ||
this.related.owners.includes(ctx.subject) ||
this.related.parents.traverse((p) => p.permits.manage(ctx)),
manage_admins: (ctx: Context): boolean =>
this.related.owners.includes(ctx.subject) ||
this.related.parents.traverse((p) => p.permits.manage_admins(ctx))
}
}
```
---
### 2.2 백엔드 API 설계 (`backend/internal/handler/tenant_handler.go`)
세부 권한 부여/회수 API는 해당 테넌트의 최상위 권한 관리자만 수행할 수 있도록 **`Tenant#manage_admins`** 허가 규칙으로 강력하게 인가 보호합니다.
#### A. 세부 권한 관계 전체 조회 API
* **Endpoint**: `GET /api/v1/admin/tenants/:id/relations`
* **인가 필터**: `RequireKetoPermission(config, "Tenant", "manage_admins")`
* **반환 DTO**:
```json
{
"items": [
{
"userId": "00000000-0000-0000-0000-000000000010",
"name": "홍길동",
"email": "kildong@hmac.kr",
"relations": ["profile_managers", "schema_viewers"]
}
]
}
```
#### B. 세부 권한 관계 부여 API
* **Endpoint**: `POST /api/v1/admin/tenants/:id/relations`
* **인가 필터**: `RequireKetoPermission(config, "Tenant", "manage_admins")`
* **Payload**:
```json
{
"userId": "00000000-0000-0000-0000-000000000010",
"relation": "profile_managers"
}
```
* **동작**: 트랜잭셔널 아웃박스에 적재하여 Keto에 `Tenant:<ID>#profile_managers@User:<UserID>` 튜플 반영.
#### C. 세부 권한 관계 회수 API
* **Endpoint**: `DELETE /api/v1/admin/tenants/:id/relations`
* **인가 필터**: `RequireKetoPermission(config, "Tenant", "manage_admins")`
* **Payload**:
```json
{
"userId": "00000000-0000-0000-0000-000000000010",
"relation": "profile_managers"
}
```
* **동작**: 트랜잭셔널 아웃박스에 적재하여 Keto 내 튜플 삭제 반영.
---
### 2.3 프론트엔드 UI 설계
사용자에게 역할(Role) 외에 세부적인 설정을 직관적으로 관리할 수 있도록, 기존 **"권한 관리"** 탭 하단에 **"세부 권한 설정 (Fine-grained Permissions)"** 섹션을 신설합니다.
#### A. 구성 요소
1. **유저 검색/추가 패널**: 테넌트 소속 사용자를 검색하여 격리 설정 테이블(Matrix)에 추가합니다.
2. **세부 권한 격리 매트릭스 (Matrix Table)**:
* 컬럼: `이름` | `이메일` | `테넌트 프로필` | `권한 관리` | `조직 관리` | `사용자 스키마` | `작업`
* 각 탭 컬럼은 드롭다운 셀렉트 박스로 채워집니다:
* **`권한 없음 (None)`** / **`조회 가능 (Read)`** / **`수정 가능 (Write)`**
3. **상태 동기화 연동**:
* 셀렉트 박스에서 `조회 가능(Read)` 선택 시: `_viewers` 관계 추가(`POST`) & `_managers` 관계 회수(`DELETE`).
* 셀렉트 박스에서 `수정 가능(Write)` 선택 시: `_managers` 관계 추가(`POST`) & `_viewers` 관계 회수(`DELETE`).
* 셀렉트 박스에서 `권한 없음(None)` 선택 시: 둘 다 회수(`DELETE`).
---
## 3. 작업 계획 및 테스트 전략
1. **OPL 컴파일 및 빌드 검증**:
* namespaces.ts 수정 후 Keto OPL 테스트를 구동하여 컴파일 문법에 문제가 없는지 사전 검증합니다.
2. **백엔드 구현 및 DB 연동**:
* `tenant_handler.go`에 신규 핸들러 추가 후 gg/gorm 아웃박스 통합을 완료합니다.
3. **프론트엔드 연동 및 Matrix UI 개발**:
* `TenantAdminsAndOwnersTab.tsx` 하단부 카드에 매트릭스 테이블 영역을 추가합니다.
4. **유형 및 단위 테스트**:
* 신설된 REST API 명세를 테스트하는 고성능 백엔드 단위 테스트를 작성합니다.
* 프론트엔드에서 체크박스 변경 시 올바른 릴레이션이 트리거되는지 검증하는 Vitest 렌더 테스트를 작성합니다.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -74,13 +74,25 @@ function parseTomlKeys(filePath) {
if (line.startsWith('[[') && line.endsWith(']]')) {
const sectionName = line.slice(2, -2).trim();
currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : [];
currentSection = sectionName ? sectionName.split('.').map((p) => {
p = p.trim();
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
p = p.slice(1, -1).trim();
}
return p;
}).filter(Boolean) : [];
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
const sectionName = line.slice(1, -1).trim();
currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : [];
currentSection = sectionName ? sectionName.split('.').map((p) => {
p = p.trim();
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
p = p.slice(1, -1).trim();
}
return p;
}).filter(Boolean) : [];
continue;
}
@@ -94,8 +106,8 @@ function parseTomlKeys(filePath) {
continue;
}
if (key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1);
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
key = key.slice(1, -1).trim();
}
const fullKey = [...currentSection, key].join('.');

View File

@@ -5,10 +5,42 @@ const fs = require('fs');
const path = require('path');
const ROOT = process.cwd();
const LOCALES_DIR = path.join(ROOT, 'locales');
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
const KO_PATH = path.join(LOCALES_DIR, 'ko.toml');
const EN_PATH = path.join(LOCALES_DIR, 'en.toml');
const LOCALE_SPECS = [
{
name: 'root',
label: 'root locales',
dir: path.join(ROOT, 'locales'),
template: 'template.toml',
langs: ['ko.toml', 'en.toml'],
ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'),
},
{
name: 'common',
label: 'common locales',
dir: path.join(ROOT, 'common', 'locales'),
template: 'template.toml',
langs: ['ko.toml', 'en.toml'],
ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'),
},
];
function shouldIgnoreCodeKey(key) {
return (
key.includes('.msg.') ||
key.includes('.ui.') ||
key.includes('.err.') ||
key.includes('.test.') ||
key.includes('.non.') ||
key.startsWith('ui.admin.users.list.table.') ||
key.startsWith('msg.admin.users.detail.') ||
key.startsWith('msg.dev.clients.') ||
key.startsWith('ui.admin.users.create.') ||
key.startsWith('ui.admin.users.detail.') ||
key.startsWith('ui.dev.clients.') ||
key.startsWith('ui.dev.session.')
);
}
const SKIP_DIRS = new Set([
'.git',
@@ -53,18 +85,33 @@ function parseToml(filePath) {
if (!line || line.startsWith('#')) continue;
if (line.startsWith('[[') && line.endsWith(']]')) {
const name = line.slice(2, -2).trim();
section = name ? name.split('.').map((p) => p.trim()).filter(Boolean) : [];
section = name ? name.split('.').map((p) => {
p = p.trim();
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
p = p.slice(1, -1).trim();
}
return p;
}).filter(Boolean) : [];
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
const name = line.slice(1, -1).trim();
section = name ? name.split('.').map((p) => p.trim()).filter(Boolean) : [];
section = name ? name.split('.').map((p) => {
p = p.trim();
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
p = p.slice(1, -1).trim();
}
return p;
}).filter(Boolean) : [];
continue;
}
const eqIndex = line.indexOf('=');
if (eqIndex === -1) continue;
const key = line.slice(0, eqIndex).trim();
let key = line.slice(0, eqIndex).trim();
if (!key) continue;
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
key = key.slice(1, -1).trim();
}
let valueRaw = line.slice(eqIndex + 1).trim();
let value = '';
if (
@@ -88,13 +135,21 @@ function buildTree(keys, valuesMap) {
let node = root;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!node[part]) node[part] = {};
if (node[part] === undefined) {
node[part] = {};
} else if (typeof node[part] === 'string') {
node[part] = { "": node[part] };
}
node = node[part];
}
const leaf = parts[parts.length - 1];
const value = valuesMap ? (valuesMap.get(key) ?? '') : '';
if (node[leaf] !== undefined && typeof node[leaf] === 'object') {
node[leaf][""] = value;
} else {
node[leaf] = value;
}
}
return root;
}
@@ -105,12 +160,34 @@ function renderToml(tree) {
lines.push(`[${path.join('.')}]`);
}
const keys = Object.keys(node).sort();
const leafKeys = keys.filter((k) => typeof node[k] === 'string');
const childKeys = keys.filter((k) => typeof node[k] === 'object');
for (const key of leafKeys) {
const value = node[key];
lines.push(`${key} = ${JSON.stringify(value)}`);
const leafKeys = [];
const childKeys = [];
for (const key of keys) {
if (typeof node[key] === 'string') {
leafKeys.push(key);
} else if (typeof node[key] === 'object') {
if (node[key][""] !== undefined) {
leafKeys.push(key);
} else {
childKeys.push(key);
}
}
}
for (const key of leafKeys) {
const val = node[key];
if (typeof val === 'string') {
lines.push(`${key} = ${JSON.stringify(val)}`);
} else {
lines.push(`${key} = ${JSON.stringify(val[""])}`);
const subKeys = Object.keys(val).filter((k) => k !== "").sort();
for (const subKey of subKeys) {
lines.push(`${key}.${subKey} = ${JSON.stringify(val[subKey])}`);
}
}
}
for (const key of childKeys) {
lines.push('');
walk(node[key], [...path, key]);
@@ -389,15 +466,26 @@ function keyToEnglish(key) {
}
function main() {
const templateMap = parseToml(TEMPLATE_PATH);
const koMap = parseToml(KO_PATH);
const enMap = parseToml(EN_PATH);
const fallbacks = extractFallbacks();
for (const spec of LOCALE_SPECS) {
const templatePath = path.join(spec.dir, spec.template);
const koPath = path.join(spec.dir, 'ko.toml');
const enPath = path.join(spec.dir, 'en.toml');
const templateMap = parseToml(templatePath);
const koMap = parseToml(koPath);
const enMap = parseToml(enPath);
const ownedFallbackKeys = Array.from(fallbacks.keys()).filter(
(key) => spec.ownsKey(key) && !shouldIgnoreCodeKey(key)
);
const allKeys = new Set([
...templateMap.keys(),
...koMap.keys(),
...enMap.keys(),
...ownedFallbackKeys,
]);
for (const key of allKeys) {
@@ -436,9 +524,10 @@ function main() {
}
const keys = Array.from(allKeys).sort();
fs.writeFileSync(KO_PATH, renderToml(buildTree(keys, koMap)));
fs.writeFileSync(EN_PATH, renderToml(buildTree(keys, enMap)));
fs.writeFileSync(TEMPLATE_PATH, renderToml(buildTree(keys, null)));
fs.writeFileSync(koPath, renderToml(buildTree(keys, koMap)));
fs.writeFileSync(enPath, renderToml(buildTree(keys, enMap)));
fs.writeFileSync(templatePath, renderToml(buildTree(keys, null)));
}
}
main();

View File

@@ -56,11 +56,14 @@ result = "Result: {value}"
session_id = "Session ID: {value}"
status = "Status: pending"
[msg.userfront.audit.filter]
description = "Toggle to view only active sessions."
[msg.userfront.consent]
accept_error = "Failed to process consent: {error}"
client_id = "Client ID: {id}"
client_unknown = "Unknown application"
description = "The service below is requesting access to your account information.\\\\nPlease choose whether to continue."
description = "The service below is requesting access to your account information.\\\\\\\\nPlease choose whether to continue."
load_error = "Failed to load consent information: {error}"
missing_redirect = "Consent was processed, but the redirect URL was missing."
redirect_notice = "After consent, you will be redirected automatically."
@@ -82,15 +85,15 @@ approved_device = "Approved device: {device}"
approved_ip = "Approved IP: {ip}"
audit_empty = "No recent sign-in activity."
audit_load_error = "Could not load sign-in history."
auto_login_supported = "You can sign in without an extra login when opening this linked app."
auth_method = "Auth method: {method}"
auto_login_supported = "You can sign in without an extra login when opening this linked app."
client_id = "Client ID: {id}"
client_id_missing = "No client ID available."
current_status = "Current status: {status}"
last_auth = "Last signed in: {value}"
link_status = "Link status: {status}"
link_missing = "This app does not have a launch URL configured."
link_open_error = "Could not open the app link."
link_status = "Link status: {status}"
render_error = "Dashboard render error: {error}"
session_id_copied = "Session ID copied."
@@ -99,6 +102,19 @@ empty = "No linked apps yet."
empty_detail = "Linked apps and their latest activity will appear here."
error = "Could not load linked apps."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nClick to copy."
copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nTap to copy."
none = "No {label}"
[msg.userfront.dashboard.revoke]
confirm = "Disconnect {app}?\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in."
error = "Could not disconnect the app: {error}"
success = "{app} has been disconnected."
[msg.userfront.dashboard.scopes]
empty = "No scopes were requested."
[msg.userfront.dashboard.sessions]
browser = "Browser: {value}"
empty = "No active sessions."
@@ -109,23 +125,10 @@ recent_app = "Recent app: {app}"
session_id = "Session ID: {id}"
[msg.userfront.dashboard.sessions.revoke]
confirm = "End the session for {target}?\nThat device will need to sign in again."
confirm = "End the session for {target}?\\nThat device will need to sign in again."
error = "Could not end the session: {error}"
success = "The session has been ended."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\\\\\\\\\\\\\nClick to copy."
copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\nTap to copy."
none = "No {label}"
[msg.userfront.dashboard.revoke]
confirm = "Disconnect {app}?\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in."
error = "Could not disconnect the app: {error}"
success = "{app} has been disconnected."
[msg.userfront.dashboard.scopes]
empty = "No scopes were requested."
[msg.userfront.dashboard.timeline]
load_error = "Could not load sign-in history."
@@ -139,22 +142,6 @@ title_generic = "An error occurred."
title_with_code = "Error: {code}"
type = "Error type: {type}"
[msg.userfront.error.tenant]
account = "Account"
account_unknown = "Unknown"
affiliated_tenants = "All affiliated tenants"
allowed_box_title = "Allowed tenants"
allowed_tenants = "Allowed tenants"
detail = "The currently signed-in account cannot access this application."
load_failed = "We could not confirm the account details. Please try again."
loading = "Loading the current account details."
lookup_fallback = "Some fields could not be verified because the access context was incomplete."
page_title = "Access to this application is restricted"
primary_tenant = "Primary affiliated tenant"
tenant = "Tenant"
tenant_unknown = "Unknown"
title = "Access restriction details"
[msg.userfront.error.ory]
"$normalizedCode" = "{error}"
access_denied = "The user denied the consent request."
@@ -171,6 +158,22 @@ temporarily_unavailable = "The authentication server is temporarily unavailable.
unauthorized_client = "The client is not authorized for this request."
unsupported_response_type = "The response type is not supported."
[msg.userfront.error.tenant]
account = "Account"
account_unknown = "Unknown"
affiliated_tenants = "All affiliated tenants"
allowed_box_title = "Allowed tenants"
allowed_tenants = "Allowed tenants"
detail = "The currently signed-in account cannot access this application."
load_failed = "We could not confirm the account details. Please try again."
loading = "Loading the current account details."
lookup_fallback = "Some fields could not be verified because the access context was incomplete."
page_title = "Access to this application is restricted"
primary_tenant = "Primary affiliated tenant"
tenant = "Tenant"
tenant_unknown = "Unknown"
title = "Access restriction details"
[msg.userfront.error.whitelist]
"$normalizedCode" = "{error}"
bad_request = "Please check your input."
@@ -226,14 +229,14 @@ scan_hint = "Scan it with the mobile app."
invalid = "Enter the 2 letters and 6 digits from your code."
[msg.userfront.login.unregistered]
body = "We could not find an account for that information.\\\\\\\\\\\\\\\\nPlease sign up before continuing."
body = "We could not find an account for that information.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\nPlease sign up before continuing."
[msg.userfront.login.verification]
approved = "Approved. Complete sign-in in the original window."
approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly."
approved_remote = "Your requested sign-in is complete."
pending_remote = "Checking the sign-in approval request. Please wait."
close_hint = "You can close this window now."
pending_remote = "Checking the sign-in approval request. Please wait."
success = "Sign-in approval completed."
[msg.userfront.login_success]
@@ -465,6 +468,10 @@ dev_console = "Dev Console"
[ui.userfront.audit]
[ui.userfront.audit.filter]
title = "Manage My Activity"
toggle_label = "Show active sessions only"
[ui.userfront.audit.table]
action = "Action"
app = "App"
@@ -499,17 +506,6 @@ status_history = "Link details"
[ui.userfront.dashboard.activity]
linked = "Linked"
[ui.userfront.dashboard.sessions]
active_badge = "Active"
current_badge = "Current"
current_disabled = "Current session"
unknown_device = "Unknown device"
unknown_session = "Session"
[ui.userfront.dashboard.sessions.revoke]
action = "End session"
title = "End session"
[ui.userfront.dashboard.approved_session]
default = "Default"
userfront = "Approved UserFront session ID"
@@ -521,6 +517,17 @@ title = "Disconnect app"
[ui.userfront.dashboard.scopes]
title = "Consent scopes"
[ui.userfront.dashboard.sessions]
active_badge = "Active"
current_badge = "Current"
current_disabled = "Current session"
unknown_device = "Unknown device"
unknown_session = "Session"
[ui.userfront.dashboard.sessions.revoke]
action = "End session"
title = "End session"
[ui.userfront.dashboard.status]
revoked = "Revoked"
@@ -583,8 +590,8 @@ title = "Account not found"
[ui.userfront.login.verification]
action_label = "Done"
action_label_remote = "Go to sign-in window"
action_label_close = "Close Window"
action_label_remote = "Go to sign-in window"
page_title = "Baron SW Portal"
title = "Approval complete"
title_pending = "Checking approval"
@@ -698,12 +705,3 @@ verify = "Verification"
[ui.userfront.signup.success]
action = "Go to sign-in"
[ui.userfront.audit.filter]
title = "Manage My Activity"
toggle_label = "Show active sessions only"
[msg.userfront.audit.filter]
description = "Toggle to view only active sessions."

View File

@@ -41,231 +41,6 @@ verify_code_failed = "인증 실패: {error}"
[err.userfront.session]
missing = "활성 세션이 없습니다."
[msg.userfront.audit]
browser = "브라우저: {value}"
date = "접속일자: {value}"
device = "접속환경: {value}"
end = "더 이상 항목이 없습니다."
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
ip = "접속 IP: {value}"
load_more_error = "더 불러오지 못했습니다."
result = "인증결과: {value}"
session_id = "Session ID: {value}"
status = "현황: (준비중)"
[msg.userfront.dashboard]
approved_device = "승인 기기: {device}"
approved_ip = "승인 IP: {ip}"
audit_empty = "최근 접속 이력이 없습니다."
audit_load_error = "접속이력을 불러오지 못했습니다."
auth_method = "인증수단: {method}"
client_id = "Client ID: {id}"
client_id_missing = "Client ID 없음"
current_status = "현재 상태: {status}"
last_auth = "최근 인증: {value}"
link_status = "연동 상태: {status}"
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
link_open_error = "해당 링크를 열 수 없습니다."
render_error = "대시보드 렌더링 오류: {error}"
session_id_copied = "세션 ID가 복사되었습니다."
[msg.userfront.error]
detail_contact = "관리자에게 문의해 주세요."
detail_generic = "오류가 발생했습니다."
detail_request = "요청을 처리하는 중 문제가 발생했습니다."
id = "오류 ID: {id}"
title = "인증 과정에서 오류가 발생했습니다"
title_generic = "오류가 발생했습니다"
title_with_code = "오류: {code}"
type = "오류 종류: {type}"
[msg.userfront.error.tenant]
account = "계정"
account_unknown = "알 수 없음"
affiliated_tenants = "전체 소속 테넌트"
allowed_box_title = "접속 가능 테넌트"
allowed_tenants = "접속 가능 테넌트"
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
loading = "현재 계정 정보를 불러오는 중입니다."
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
page_title = "애플리케이션 접근이 제한되었습니다"
primary_tenant = "대표 소속 테넌트"
tenant = "소속 테넌트"
tenant_unknown = "알 수 없음"
title = "접근 제한 정보"
[msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
error = "전송에 실패했습니다: {error}"
input_required = "이메일 또는 휴대폰 번호를 입력해주세요."
sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."
[msg.userfront.login]
cookie_check_failed = "로그인 확인 실패: {error}"
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
link_failed = "오류: {error}"
link_send_failed = "전송 실패: {error}"
link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다."
link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다."
link_timeout = "시간이 경과되었습니다."
no_account = "계정이 없으신가요?"
oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."
qr_expired = "시간이 경과되었습니다."
qr_init_failed = "QR 초기화에 실패했습니다: {error}"
qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다"
token_missing = "로그인 토큰을 확인할 수 없습니다."
verification_failed = "승인 처리에 실패했습니다: {error}"
[msg.userfront.login_success]
subtitle = "성공적으로 로그인되었습니다."
[msg.userfront.consent]
accept_error = "동의 처리에 실패했습니다: {error}"
client_id = "클라이언트 ID: {id}"
client_unknown = "알 수 없는 앱"
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요."
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
scope_count = "총 {count}개"
[msg.userfront.profile]
department_missing = "소속 정보 없음"
department_required = "소속을 입력해주세요."
email_missing = "이메일 없음"
greeting = "안녕하세요, {name}님"
load_failed = "정보를 불러올 수 없습니다."
name_missing = "이름 없음"
name_required = "이름을 입력해주세요."
phone_required = "휴대폰 번호를 입력해주세요."
phone_verify_required = "휴대폰 번호 인증이 필요합니다."
update_failed = "수정 실패: {error}"
update_success = "정보가 수정되었습니다."
[msg.userfront.qr]
camera_error = "카메라 오류: {error}"
permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요."
permission_required = "카메라 권한이 필요합니다."
[msg.userfront.reset]
invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요."
invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)"
invalid_title = "유효하지 않은 링크입니다."
policy_loading = "비밀번호 정책을 불러오는 중입니다..."
success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."
[msg.userfront.sections]
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다."
[msg.userfront.settings]
disabled = "현재 계정 설정 화면은 준비 중입니다."
[msg.userfront.signup]
failed = "가입 실패: {error}"
privacy_full = "개인정보 수집 및 이용 동의 전문..."
tos_full = "서비스 이용약관 전문..."
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.status]
active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "비활성"
ok = "정상"
pending = "준비 중"
success = "성공"
[ui.userfront.app_label]
admin_console = "Admin Console"
baron = "Baron 로그인"
dev_console = "Dev Console"
[ui.userfront.auth_method]
ory = "Ory 세션"
session = "세션"
[ui.userfront.dashboard]
last_auth_label = "최근 인증"
link_status_label = "연동 상태"
status_history = "연동 정보"
[ui.userfront.device]
android = "Mobile(Android)"
ios = "Mobile(iOS)"
linux = "Desktop(Linux)"
macos = "Desktop(macOS)"
windows = "Desktop(Windows)"
[ui.userfront.error]
go_home = "홈으로 이동"
go_login = "로그인으로 이동"
switch_account = "다른 계정으로 로그인"
[ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?"
input_label = "이메일 또는 휴대폰 번호"
submit = "재설정 링크 전송"
title = "비밀번호 재설정"
[ui.userfront.login]
forgot_password = "비밀번호를 잊으셨나요?"
signup = "회원가입"
[ui.userfront.login_success]
later = "나중에 하기 (대시보드로 이동)"
qr = "QR 인증 (카메라 켜기)"
title = "로그인 완료"
[ui.userfront.consent]
accept = "동의하고 계속하기"
requested_scopes = "요청된 권한"
title = "접근 권한 요청"
[ui.userfront.nav]
dashboard = "대시보드"
logout = "로그아웃"
profile = "내 정보"
qr_scan = "QR 스캔"
[ui.userfront.profile]
department_empty = "소속 정보 없음"
manage = "프로필 관리"
user_fallback = "사용자"
[ui.userfront.qr]
rescan = "다시 스캔"
result_success = "승인 완료"
title = "Scan QR Code"
[ui.userfront.reset]
confirm_password = "새 비밀번호 확인"
new_password = "새 비밀번호"
submit = "비밀번호 변경"
subtitle = "새로운 비밀번호 설정"
title = "새 비밀번호 설정"
[ui.userfront.sections]
apps = "나의 App 현황"
audit = "접속이력"
sessions = "활성 세션"
[ui.userfront.session]
active = "세션 활성"
unknown = "알 수 없음"
[ui.userfront.signup]
complete = "가입 완료"
next_step = "다음 단계"
title = "회원가입"
[msg.userfront]
greeting = "안녕하세요, {name}님"
@@ -281,11 +56,14 @@ result = "인증결과: {value}"
session_id = "Session ID: {value}"
status = "현황: (준비중)"
[msg.userfront.audit.filter]
description = "활성화된 세션만 보려면 토글을 켜주세요."
[msg.userfront.consent]
accept_error = "동의 처리에 실패했습니다: {error}"
client_id = "클라이언트 ID: {id}"
client_unknown = "알 수 없는 앱"
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\n계속 진행하려면 동의 여부를 선택해 주세요."
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\\\\\n계속 진행하려면 동의 여부를 선택해 주세요."
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
@@ -307,14 +85,15 @@ approved_device = "승인 기기: {device}"
approved_ip = "승인 IP: {ip}"
audit_empty = "최근 접속 이력이 없습니다."
audit_load_error = "접속이력을 불러오지 못했습니다."
auto_login_supported = "연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다."
auth_method = "인증수단: {method}"
auto_login_supported = "연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다."
client_id = "Client ID: {id}"
client_id_missing = "Client ID 없음"
current_status = "현재 상태: {status}"
last_auth = "최근 인증: {value}"
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
link_open_error = "해당 링크를 열 수 없습니다."
link_status = "연동 상태: {status}"
render_error = "대시보드 렌더링 오류: {error}"
session_id_copied = "세션 ID가 복사되었습니다."
@@ -323,6 +102,19 @@ empty = "연동된 앱이 없습니다."
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
error = "연동 정보를 불러오지 못했습니다."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\\\\\n클릭하면 복사됩니다."
copy_tap = "{label}: {id}\\\\\\\\n탭하면 복사됩니다."
none = "{label} 없음"
[msg.userfront.dashboard.revoke]
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
error = "해지 실패: {error}"
success = "{app} 연동이 해지되었습니다."
[msg.userfront.dashboard.scopes]
empty = "요청된 권한이 없습니다."
[msg.userfront.dashboard.sessions]
browser = "브라우저: {value}"
empty = "활성 세션이 없습니다."
@@ -333,23 +125,10 @@ recent_app = "최근 접속 앱: {app}"
session_id = "세션 ID: {id}"
[msg.userfront.dashboard.sessions.revoke]
confirm = "{target} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다."
confirm = "{target} 세션을 종료하시겠습니까?\\n대상 기기에서는 다시 로그인이 필요합니다."
error = "세션 종료 실패: {error}"
success = "세션이 종료되었습니다."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다."
copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다."
none = "{label} 없음"
[msg.userfront.dashboard.revoke]
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
error = "해지 실패: {error}"
success = "{app} 연동이 해지되었습니다."
[msg.userfront.dashboard.scopes]
empty = "요청된 권한이 없습니다."
[msg.userfront.dashboard.timeline]
load_error = "접속이력을 불러오지 못했습니다."
@@ -363,22 +142,6 @@ title_generic = "오류가 발생했습니다"
title_with_code = "오류: {code}"
type = "오류 종류: {type}"
[msg.userfront.error.tenant]
account = "계정"
account_unknown = "알 수 없음"
affiliated_tenants = "전체 소속 테넌트"
allowed_box_title = "접속 가능 테넌트"
allowed_tenants = "접속 가능 테넌트"
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
loading = "현재 계정 정보를 불러오는 중입니다."
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
page_title = "애플리케이션 접근이 제한되었습니다"
primary_tenant = "대표 소속 테넌트"
tenant = "소속 테넌트"
tenant_unknown = "알 수 없음"
title = "접근 제한 정보"
[msg.userfront.error.ory]
"$normalizedCode" = "{error}"
access_denied = "사용자가 동의를 거부했습니다."
@@ -395,6 +158,22 @@ temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.error.tenant]
account = "계정"
account_unknown = "알 수 없음"
affiliated_tenants = "전체 소속 테넌트"
allowed_box_title = "접속 가능 테넌트"
allowed_tenants = "접속 가능 테넌트"
detail = "현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다."
load_failed = "계정 정보를 확인하지 못했습니다. 다시 시도해 주세요."
loading = "현재 계정 정보를 불러오는 중입니다."
lookup_fallback = "표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다."
page_title = "애플리케이션 접근이 제한되었습니다"
primary_tenant = "대표 소속 테넌트"
tenant = "소속 테넌트"
tenant_unknown = "알 수 없음"
title = "접근 제한 정보"
[msg.userfront.error.whitelist]
"$normalizedCode" = "{error}"
bad_request = "입력값을 확인해 주세요."
@@ -450,14 +229,14 @@ scan_hint = "모바일 앱으로 스캔하세요"
invalid = "문자 2개와 숫자 6자리를 입력해 주세요."
[msg.userfront.login.unregistered]
body = "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주세요."
body = "가입되지 않은 정보입니다.\\\\\\\\n회원가입 후 이용해 주세요."
[msg.userfront.login.verification]
approved = "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."
approved_local = "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다"
approved_remote = "요청하신 로그인이 완료되었습니다"
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
close_hint = "이 창은 이제 닫으셔도 됩니다."
pending_remote = "승인 요청을 확인하고 있습니다. 잠시만 기다려 주세요."
success = "로그인 승인에 성공했습니다."
[msg.userfront.login_success]
@@ -532,6 +311,7 @@ uppercase = "대문자 1개 이상"
[msg.userfront.sections]
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다."
[msg.userfront.settings]
disabled = "현재 계정 설정 화면은 준비 중입니다."
@@ -546,12 +326,12 @@ all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계
description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요."
privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다."
progress = "필수 약관 {total}개 중 {count}개 동의 완료"
title = "서비스 이용을 위해\\\\n약관에 동의해주세요"
title = "서비스 이용을 위해\\\\\\\\n약관에 동의해주세요"
tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다."
[msg.userfront.signup.auth]
affiliate_notice = "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요."
title = "본인 확인을 위해\\\\n인증을 진행해주세요"
title = "본인 확인을 위해\\\\\\\\n인증을 진행해주세요"
[msg.userfront.signup.email]
code_mismatch = "인증코드가 일치하지 않습니다."
@@ -567,7 +347,7 @@ lowercase_required = "소문자가 최소 1개 이상 포함되어야 합니다.
mismatch = "비밀번호가 일치하지 않습니다."
number_required = "숫자가 최소 1개 이상 포함되어야 합니다."
symbol_required = "특수문자가 최소 1개 이상 포함되어야 합니다."
title = "마지막으로\\\\n비밀번호를 설정해주세요"
title = "마지막으로\\\\\\\\n비밀번호를 설정해주세요"
uppercase_required = "대문자가 최소 1개 이상 포함되어야 합니다."
[msg.userfront.signup.password.rule]
@@ -596,7 +376,7 @@ uppercase = "대문자"
[msg.userfront.signup.profile]
affiliate_hint = "가족사 이메일 사용 시 자동으로 선택됩니다."
title = "회원님의\\\\n소속 정보를 알려주세요"
title = "회원님의\\\\\\\\n소속 정보를 알려주세요"
[msg.userfront.signup.success]
body = "성공적으로 가입되었습니다."
@@ -688,6 +468,10 @@ dev_console = "Dev Console"
[ui.userfront.audit]
[ui.userfront.audit.filter]
title = "내 활동 관리"
toggle_label = "활성 세션만 보기"
[ui.userfront.audit.table]
action = "관리"
app = "애플리케이션"
@@ -716,22 +500,12 @@ title = "동의 취소"
[ui.userfront.dashboard]
last_auth_label = "최근 인증"
link_status_label = "연동 상태"
status_history = "상태 이력"
[ui.userfront.dashboard.activity]
linked = "연동됨"
[ui.userfront.dashboard.sessions]
active_badge = "활성화"
current_badge = "접속중"
current_disabled = "현재 세션"
unknown_device = "알 수 없는 기기"
unknown_session = "세션 정보"
[ui.userfront.dashboard.sessions.revoke]
action = "세션 종료"
title = "세션 종료"
[ui.userfront.dashboard.approved_session]
default = "승인한 세션 ID"
userfront = "승인한 Userfront 세션 ID"
@@ -743,6 +517,17 @@ title = "연동 해지"
[ui.userfront.dashboard.scopes]
title = "동의 범위"
[ui.userfront.dashboard.sessions]
active_badge = "활성화"
current_badge = "접속중"
current_disabled = "현재 세션"
unknown_device = "알 수 없는 기기"
unknown_session = "세션 정보"
[ui.userfront.dashboard.sessions.revoke]
action = "세션 종료"
title = "세션 종료"
[ui.userfront.dashboard.status]
revoked = "해지됨"
@@ -805,10 +590,10 @@ title = "미등록 회원"
[ui.userfront.login.verification]
action_label = "확인"
action_label_close = "창 닫기"
action_label_remote = "로그인 창으로 이동하기"
page_title = "Baron SW 포탈"
title = "승인 완료"
action_label_close = "창 닫기"
title_pending = "로그인 승인 확인 중"
title_remote = "로그인 승인 완료"
@@ -872,6 +657,7 @@ title = "새 비밀번호 설정"
[ui.userfront.sections]
apps = "나의 App 현황"
audit = "접속이력"
sessions = "활성 세션"
[ui.userfront.session]
active = "세션 활성"
@@ -919,12 +705,3 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"
[ui.userfront.audit.filter]
title = "내 활동 관리"
toggle_label = "활성 세션만 보기"
[msg.userfront.audit.filter]
description = "활성화된 세션만 보려면 토글을 켜주세요."

View File

@@ -41,203 +41,6 @@ verify_code_failed = ""
[err.userfront.session]
missing = ""
[msg.userfront.error]
detail_contact = ""
detail_generic = ""
detail_request = ""
id = ""
title = ""
title_generic = ""
title_with_code = ""
type = ""
[msg.userfront.error.tenant]
account = ""
account_unknown = ""
affiliated_tenants = ""
allowed_box_title = ""
allowed_tenants = ""
detail = ""
load_failed = ""
loading = ""
lookup_fallback = ""
page_title = ""
primary_tenant = ""
tenant = ""
tenant_unknown = ""
title = ""
[msg.userfront.forgot]
description = ""
dry_send = ""
error = ""
input_required = ""
sent = ""
[msg.userfront.login]
cookie_check_failed = ""
dry_send = ""
link_failed = ""
link_send_failed = ""
link_sent_email = ""
link_sent_phone = ""
link_timeout = ""
no_account = ""
oidc_failed = ""
qr_expired = ""
qr_init_failed = ""
qr_login_required = ""
token_missing = ""
verification_failed = ""
[msg.userfront.login_success]
subtitle = ""
[msg.userfront.consent]
accept_error = ""
client_id = ""
client_unknown = ""
description = ""
load_error = ""
missing_redirect = ""
redirect_notice = ""
scope_count = ""
[msg.userfront.profile]
department_missing = ""
department_required = ""
email_missing = ""
greeting = ""
load_failed = ""
name_missing = ""
name_required = ""
phone_required = ""
phone_verify_required = ""
update_failed = ""
update_success = ""
[msg.userfront.qr]
camera_error = ""
permission_error = ""
permission_required = ""
[msg.userfront.reset]
invalid_body = ""
invalid_link = ""
invalid_title = ""
policy_loading = ""
success = ""
[msg.userfront.sections]
apps_subtitle = ""
audit_subtitle = ""
sessions_subtitle = ""
[msg.userfront.settings]
disabled = ""
[msg.userfront.signup]
failed = ""
privacy_full = ""
tos_full = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.status]
active = ""
blocked = ""
failure = ""
inactive = ""
ok = ""
pending = ""
success = ""
[ui.userfront.app_label]
admin_console = ""
baron = ""
dev_console = ""
[ui.userfront.auth_method]
ory = ""
session = ""
[ui.userfront.dashboard]
link_status_label = ""
last_auth_label = ""
status_history = ""
[ui.userfront.device]
android = ""
ios = ""
linux = ""
macos = ""
windows = ""
[ui.userfront.error]
go_home = ""
go_login = ""
switch_account = ""
[ui.userfront.forgot]
heading = ""
input_label = ""
submit = ""
title = ""
[ui.userfront.login]
forgot_password = ""
signup = ""
[ui.userfront.login_success]
later = ""
qr = ""
title = ""
[ui.userfront.consent]
accept = ""
requested_scopes = ""
title = ""
[ui.userfront.nav]
dashboard = ""
logout = ""
profile = ""
qr_scan = ""
[ui.userfront.profile]
department_empty = ""
manage = ""
user_fallback = ""
[ui.userfront.qr]
rescan = ""
result_success = ""
title = ""
[ui.userfront.reset]
confirm_password = ""
new_password = ""
submit = ""
subtitle = ""
title = ""
[ui.userfront.sections]
apps = ""
audit = ""
sessions = ""
[ui.userfront.session]
active = ""
unknown = ""
[ui.userfront.signup]
complete = ""
next_step = ""
title = ""
[msg.userfront]
greeting = ""
@@ -253,6 +56,9 @@ result = ""
session_id = ""
status = ""
[msg.userfront.audit.filter]
description = ""
[msg.userfront.consent]
accept_error = ""
client_id = ""
@@ -279,14 +85,15 @@ approved_device = ""
approved_ip = ""
audit_empty = ""
audit_load_error = ""
auto_login_supported = ""
auth_method = ""
auto_login_supported = ""
client_id = ""
client_id_missing = ""
current_status = ""
last_auth = ""
link_missing = ""
link_open_error = ""
link_status = ""
render_error = ""
session_id_copied = ""
@@ -295,6 +102,19 @@ empty = ""
empty_detail = ""
error = ""
[msg.userfront.dashboard.approved_session]
copy_click = ""
copy_tap = ""
none = ""
[msg.userfront.dashboard.revoke]
confirm = ""
error = ""
success = ""
[msg.userfront.dashboard.scopes]
empty = ""
[msg.userfront.dashboard.sessions]
browser = ""
empty = ""
@@ -309,19 +129,6 @@ confirm = ""
error = ""
success = ""
[msg.userfront.dashboard.approved_session]
copy_click = ""
copy_tap = ""
none = ""
[msg.userfront.dashboard.revoke]
confirm = ""
error = ""
success = ""
[msg.userfront.dashboard.scopes]
empty = ""
[msg.userfront.dashboard.timeline]
load_error = ""
@@ -335,22 +142,6 @@ title_generic = ""
title_with_code = ""
type = ""
[msg.userfront.error.tenant]
account = ""
account_unknown = ""
affiliated_tenants = ""
allowed_box_title = ""
allowed_tenants = ""
detail = ""
load_failed = ""
loading = ""
lookup_fallback = ""
page_title = ""
primary_tenant = ""
tenant = ""
tenant_unknown = ""
title = ""
[msg.userfront.error.ory]
"$normalizedCode" = ""
access_denied = ""
@@ -367,6 +158,22 @@ temporarily_unavailable = ""
unauthorized_client = ""
unsupported_response_type = ""
[msg.userfront.error.tenant]
account = ""
account_unknown = ""
affiliated_tenants = ""
allowed_box_title = ""
allowed_tenants = ""
detail = ""
load_failed = ""
loading = ""
lookup_fallback = ""
page_title = ""
primary_tenant = ""
tenant = ""
tenant_unknown = ""
title = ""
[msg.userfront.error.whitelist]
"$normalizedCode" = ""
bad_request = ""
@@ -428,8 +235,8 @@ body = ""
approved = ""
approved_local = ""
approved_remote = ""
pending_remote = ""
close_hint = ""
pending_remote = ""
success = ""
[msg.userfront.login_success]
@@ -504,6 +311,7 @@ uppercase = ""
[msg.userfront.sections]
apps_subtitle = ""
audit_subtitle = ""
sessions_subtitle = ""
[msg.userfront.settings]
disabled = ""
@@ -660,6 +468,10 @@ dev_console = ""
[ui.userfront.audit]
[ui.userfront.audit.filter]
title = ""
toggle_label = ""
[ui.userfront.audit.table]
action = ""
app = ""
@@ -688,22 +500,12 @@ title = ""
[ui.userfront.dashboard]
last_auth_label = ""
link_status_label = ""
status_history = ""
[ui.userfront.dashboard.activity]
linked = ""
[ui.userfront.dashboard.sessions]
active_badge = ""
current_badge = ""
current_disabled = ""
unknown_device = ""
unknown_session = ""
[ui.userfront.dashboard.sessions.revoke]
action = ""
title = ""
[ui.userfront.dashboard.approved_session]
default = ""
userfront = ""
@@ -715,6 +517,17 @@ title = ""
[ui.userfront.dashboard.scopes]
title = ""
[ui.userfront.dashboard.sessions]
active_badge = ""
current_badge = ""
current_disabled = ""
unknown_device = ""
unknown_session = ""
[ui.userfront.dashboard.sessions.revoke]
action = ""
title = ""
[ui.userfront.dashboard.status]
revoked = ""
@@ -777,8 +590,8 @@ title = ""
[ui.userfront.login.verification]
action_label = ""
action_label_remote = ""
action_label_close = ""
action_label_remote = ""
page_title = ""
title = ""
title_pending = ""
@@ -844,6 +657,7 @@ title = ""
[ui.userfront.sections]
apps = ""
audit = ""
sessions = ""
[ui.userfront.session]
active = ""
@@ -891,12 +705,3 @@ verify = ""
[ui.userfront.signup.success]
action = ""
[ui.userfront.audit.filter]
title = ""
toggle_label = ""
[msg.userfront.audit.filter]
description = ""

View File

@@ -1,4 +1,4 @@
import 'providers/linked_rps_provider.dart';
import 'models.dart';
String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
final normalizedStatus = rp.status.trim().toLowerCase();

View File

@@ -4,57 +4,7 @@ import 'package:userfront/core/services/auth_proxy_service.dart';
import 'package:userfront/core/services/auth_token_store.dart';
import 'package:userfront/core/services/http_client.dart';
import 'package:userfront/core/services/runtime_env.dart';
class LinkedRp {
final String id;
final String name;
final String logo;
final String url;
final String initUrl;
final bool autoLoginSupported;
final String autoLoginUrl;
final String status;
final List<String> scopes;
final DateTime? lastAuthenticatedAt;
LinkedRp({
required this.id,
required this.name,
required this.logo,
required this.url,
required this.initUrl,
required this.autoLoginSupported,
required this.autoLoginUrl,
required this.status,
required this.scopes,
required this.lastAuthenticatedAt,
});
factory LinkedRp.fromJson(Map<String, dynamic> json) {
final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? '';
DateTime? parsedLastAuth;
if (rawLastAuth.isNotEmpty) {
try {
parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
} catch (_) {
parsedLastAuth = null;
}
}
return LinkedRp(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
logo: json['logo']?.toString() ?? '',
url: json['url']?.toString() ?? '',
initUrl: json['init_url']?.toString() ?? '',
autoLoginSupported: json['auto_login_supported'] == true,
autoLoginUrl: json['auto_login_url']?.toString() ?? '',
status: json['status']?.toString() ?? 'unknown',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastAuthenticatedAt: parsedLastAuth,
);
}
}
import '../models.dart';
class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
@override

View File

@@ -21,7 +21,7 @@ import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../domain/dashboard_providers.dart';
import '../domain/models.dart' hide LinkedRp;
import '../domain/models.dart';
import 'audit_device_utils.dart';
import 'package:userfront/i18n.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart';
import 'package:userfront/features/dashboard/domain/providers/linked_rps_provider.dart';
import 'package:userfront/features/dashboard/domain/models.dart';
LinkedRp _linkedRp({
required String status,