1
0
forked from baron/baron-sso

feat: integrate orgfront and expose internal ids

This commit is contained in:
2026-04-30 09:33:39 +09:00
parent 02375af08d
commit 9ce7a67f58
116 changed files with 22992 additions and 33 deletions

View File

@@ -0,0 +1,724 @@
import apiClient from "./apiClient";
export type AuditLog = {
event_id: string;
timestamp: string;
user_id: string;
event_type: string;
status: string;
ip_address: string;
user_agent: string;
device_id?: string;
details?: string;
};
export type AuditLogListResponse = {
items: AuditLog[];
limit: number;
cursor?: string;
next_cursor?: string;
};
export type TenantSummary = {
id: string;
type: string; // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
name: string;
slug: string;
description: string;
status: string;
domains?: string[];
parentId?: string;
config?: Record<string, unknown>;
memberCount: number; // Added member count
createdAt: string;
updatedAt: string;
};
export type TenantCreateRequest = {
name: string;
type?: string;
slug?: string;
parentId?: string;
description?: string;
status?: string;
domains?: string[];
config?: Record<string, unknown>;
};
export type TenantListResponse = {
items: TenantSummary[];
limit: number;
offset: number;
total: number;
};
export type TenantUpdateRequest = {
name?: string;
type?: string;
slug?: string;
parentId?: string;
description?: string;
status?: string;
domains?: string[];
config?: Record<string, unknown>;
};
export type ApiKeySummary = {
id: string;
name: string;
client_id: string;
scopes: string[];
status: string;
lastUsedAt?: string;
createdAt: string;
};
export type ApiKeyListResponse = {
items: ApiKeySummary[];
total: number;
};
export type RoleSummary = {
id: string;
name: string;
description: string;
permissions: string[];
createdAt: string;
updatedAt: string;
};
export type RoleListResponse = {
items: RoleSummary[];
total: number;
};
export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor },
});
return data;
}
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
{
params: { limit, offset, parentId },
},
);
return data;
}
export async function fetchTenant(tenantId: string) {
const { data } = await apiClient.get<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,
);
return data;
}
export async function createTenant(payload: TenantCreateRequest) {
const { data } = await apiClient.post<TenantSummary>(
"/v1/admin/tenants",
payload,
);
return data;
}
export async function updateTenant(
tenantId: string,
payload: TenantUpdateRequest,
) {
const { data } = await apiClient.put<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,
payload,
);
return data;
}
export async function deleteTenant(tenantId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
}
export async function deleteTenantsBulk(ids: string[]) {
await apiClient.delete("/v1/admin/tenants/bulk", {
data: { ids },
});
}
export async function approveTenant(tenantId: string) {
const { data } = await apiClient.post<TenantSummary>(
`/v1/admin/tenants/${tenantId}/approve`,
);
return data;
}
export type TenantAdmin = {
id: string;
name: string;
email: string;
};
export async function fetchTenantAdmins(tenantId: string) {
const { data } = await apiClient.get<TenantAdmin[]>(
`/v1/admin/tenants/${tenantId}/admins`,
);
return data;
}
export async function addTenantAdmin(tenantId: string, userId: string) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export async function removeTenantAdmin(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export async function fetchTenantOwners(tenantId: string) {
const { data } = await apiClient.get<TenantAdmin[]>(
`/v1/admin/tenants/${tenantId}/owners`,
);
return data;
}
export async function addTenantOwner(tenantId: string, userId: string) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
}
export async function removeTenantOwner(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
}
// Group Management
export type GroupMember = {
id: string;
name: string;
email: string;
};
export type GroupSummary = {
id: string;
tenantId: string;
parentId?: string;
name: string;
description?: string;
unitType?: string;
members?: GroupMember[];
createdAt?: string;
updatedAt?: string;
};
export type GroupCreateRequest = {
name: string;
parentId?: string;
description?: string;
unitType?: string;
};
export async function fetchGroups(tenantId: string) {
const { data } = await apiClient.get<GroupSummary[]>(
`/v1/admin/tenants/${tenantId}/organization`,
);
return data;
}
export async function fetchGroup(tenantId: string, groupId: string) {
const { data } = await apiClient.get<GroupSummary>(
`/v1/admin/tenants/${tenantId}/organization/${groupId}`,
);
return data;
}
export async function createGroup(
tenantId: string,
payload: GroupCreateRequest,
) {
const { data } = await apiClient.post<GroupSummary>(
`/v1/admin/tenants/${tenantId}/organization`,
payload,
);
return data;
}
export async function deleteGroup(tenantId: string, groupId: string) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/organization/${groupId}`,
);
}
export async function addGroupMember(
tenantId: string,
groupId: string,
userId: string,
) {
await apiClient.post(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/members`,
{ userId },
);
}
export async function removeGroupMember(
tenantId: string,
groupId: string,
userId: string,
) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/members/${userId}`,
);
}
export interface ImportResult {
totalRows: number;
processed: number;
userCreated: number;
userUpdated: number;
tenantCreated: number;
errors: string[];
}
export async function fetchImportProgress(
tenantId: string,
progressId: string,
) {
const { data } = await apiClient.get<{ current: number; total: number }>(
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`,
);
return data;
}
export async function importOrgChart(
tenantId: string,
file: File,
progressId?: string,
) {
const formData = new FormData();
formData.append("file", file);
const url = progressId
? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}`
: `/v1/admin/tenants/${tenantId}/organization/import`;
const { data } = await apiClient.post<{ data: ImportResult }>(url, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return data.data;
}
export type GroupRole = {
tenantId: string;
tenantName: string;
relation: string;
};
export async function fetchGroupRoles(tenantId: string, groupId: string) {
const { data } = await apiClient.get<GroupRole[]>(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
);
return data;
}
export async function assignGroupRole(
tenantId: string,
groupId: string,
targetTenantId: string,
relation: string,
) {
await apiClient.post(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
{ tenantId: targetTenantId, relation },
);
}
export async function removeGroupRole(
tenantId: string,
groupId: string,
targetTenantId: string,
relation: string,
) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles/${targetTenantId}/${relation}`,
);
}
// API Key Management (M2M)
export type ApiKeyCreateRequest = {
name: string;
scopes: string[];
};
export type ApiKeyCreateResponse = {
apiKey: ApiKeySummary;
clientSecret: string;
};
export async function fetchApiKeys(limit = 50, offset = 0) {
const { data } = await apiClient.get<ApiKeyListResponse>(
"/v1/admin/api-keys",
{
params: { limit, offset },
},
);
return data;
}
export async function createApiKey(payload: ApiKeyCreateRequest) {
const { data } = await apiClient.post<ApiKeyCreateResponse>(
"/v1/admin/api-keys",
payload,
);
return data;
}
export async function deleteApiKey(apiKeyId: string) {
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
}
// User Management
export type UserSummary = {
id: string;
email: string;
loginId?: string;
name: string;
phone?: string;
role: string;
status: string;
tenantSlug?: string;
companyCode?: string;
tenant?: TenantSummary;
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
department?: string;
position?: string;
jobTitle?: string;
createdAt: string;
updatedAt: string;
};
export type UserListResponse = {
items: UserSummary[];
limit: number;
offset: number;
total: number;
};
export type UserCreateRequest = {
email: string;
loginId?: string;
password?: string;
name: string;
phone?: string;
role?: string;
tenantSlug?: string;
department?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
};
export type UserCreateResponse = UserSummary & {
initialPassword?: string;
};
export type UserUpdateRequest = {
loginId?: string;
password?: string;
name?: string;
phone?: string;
role?: string;
status?: string;
tenantSlug?: string;
department?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
};
export type BulkUserItem = {
email: string;
loginId?: string;
name: string;
phone?: string;
role?: string;
tenantSlug?: string;
department?: string;
position?: string;
jobTitle?: string;
metadata: Record<string, string>;
};
export type BulkUserResult = {
email: string;
success: boolean;
message?: string;
userId?: string;
};
export type BulkUserResponse = {
results: BulkUserResult[];
};
export async function fetchUsers(
limit = 50,
offset = 0,
search?: string,
tenantSlug?: string,
) {
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search, tenantSlug },
});
return data;
}
export async function fetchUser(userId: string) {
const { data } = await apiClient.get<UserSummary>(
`/v1/admin/users/${userId}`,
);
return data;
}
export async function createUser(payload: UserCreateRequest) {
// Map tenantSlug to companyCode for backend compatibility
const requestPayload: UserCreateRequest & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.post<UserCreateResponse>(
"/v1/admin/users",
requestPayload,
);
return data;
}
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
const params = new URLSearchParams();
if (search) params.append("search", search);
if (tenantSlug) params.append("tenantSlug", tenantSlug);
// Get mock role from storage if exists for dev environment
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
return `${baseUrl}/admin/users/export?${params.toString()}`;
}
export async function bulkCreateUsers(users: BulkUserItem[]) {
const mappedUsers = users.map((u) => {
const mapped: BulkUserItem & { companyCode?: string } = { ...u };
if (u.tenantSlug !== undefined) {
mapped.companyCode = u.tenantSlug;
}
return mapped;
});
const { data } = await apiClient.post<BulkUserResponse>(
"/v1/admin/users/bulk",
{ users: mappedUsers },
);
return data;
}
export async function bulkUpdateUsers(payload: {
userIds: string[];
status?: string;
role?: string;
tenantSlug?: string;
department?: string;
}) {
const requestPayload: typeof payload & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
return data;
}
export async function bulkDeleteUsers(userIds: string[]) {
const { data } = await apiClient.delete("/v1/admin/users/bulk", {
data: { userIds },
});
return data;
}
export async function updateUser(userId: string, payload: UserUpdateRequest) {
const requestPayload: UserUpdateRequest & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.put<UserSummary>(
`/v1/admin/users/${userId}`,
requestPayload,
);
return data;
}
export type PasswordPolicyResponse = {
minLength?: number;
lowercase?: boolean;
uppercase?: boolean;
number?: boolean;
nonAlphanumeric?: boolean;
minCharacterTypes?: number;
};
export async function fetchPasswordPolicy() {
const { data } = await apiClient.get<PasswordPolicyResponse>(
"/v1/auth/password/policy",
);
return data;
}
export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`);
}
export type UserRpHistoryItem = {
client_id: string;
client_name: string;
lastLoginAt: string;
status: string;
};
export async function fetchUserRpHistory(userId: string) {
const { data } = await apiClient.get<UserRpHistoryItem[]>(
`/v1/admin/users/${userId}/rp-history`,
);
return data;
}
export type UserProfileResponse = {
id: string;
email: string;
name: string;
phone: string;
role: string;
department: string;
affiliationType: string;
tenantSlug?: string;
tenantId?: string;
metadata?: Record<string, unknown>;
tenant?: TenantSummary;
manageableTenants?: TenantSummary[];
};
export async function fetchMe() {
const { data } = await apiClient.get<UserProfileResponse>("/v1/user/me");
return data;
}
// Relying Party Management
export type RelyingParty = {
clientId: string;
tenantId: string;
name: string;
description: string;
createdAt: string;
updatedAt: string;
};
export type HydraClientReq = {
client_id?: string;
client_name: string;
client_secret?: string;
redirect_uris: string[];
scope?: string;
token_endpoint_auth_method?: string;
grant_types?: string[];
response_types?: string[];
metadata?: Record<string, unknown>;
};
export async function fetchRelyingParties(tenantId: string) {
const { data } = await apiClient.get<RelyingParty[]>(
`/v1/admin/tenants/${tenantId}/relying-parties`,
);
return data;
}
export async function fetchAllRelyingParties() {
const { data } = await apiClient.get<RelyingParty[]>(
"/v1/admin/relying-parties",
);
return data;
}
export async function createRelyingParty(
tenantId: string,
payload: HydraClientReq,
) {
const { data } = await apiClient.post<RelyingParty>(
`/v1/admin/tenants/${tenantId}/relying-parties`,
payload,
);
return data;
}
export async function fetchRelyingParty(id: string) {
const { data } = await apiClient.get<{
relyingParty: RelyingParty;
oauth2Config: HydraClientReq;
}>(`/v1/admin/relying-parties/${id}`);
return data;
}
export async function updateRelyingParty(id: string, payload: HydraClientReq) {
const { data } = await apiClient.put<RelyingParty>(
`/v1/admin/relying-parties/${id}`,
payload,
);
return data;
}
export async function deleteRelyingParty(id: string) {
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
}
export type RPOwner = {
subject: string;
name?: string;
email?: string;
type: string;
};
export async function fetchRPOwners(clientId: string) {
const { data } = await apiClient.get<RPOwner[]>(
`/v1/admin/relying-parties/${clientId}/owners`,
);
return data;
}
export async function addRPOwner(clientId: string, subject: string) {
await apiClient.post(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}
export async function removeRPOwner(clientId: string, subject: string) {
await apiClient.delete(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}
export async function fetchPublicOrgChart(token: string) {
const { data } = await apiClient.get<{
tenants: TenantSummary[];
users: UserSummary[];
sharedWith: string;
}>("/v1/public/orgchart", {
params: { token },
});
return data;
}

View File

@@ -0,0 +1,52 @@
import axios from "axios";
import { userManager } from "./auth";
const apiClient = axios.create({
baseURL:
import.meta.env.VITE_DEV_API_BASE ??
import.meta.env.VITE_ADMIN_API_BASE ??
"/api",
});
apiClient.interceptors.request.use(async (config) => {
// OIDC Access Token 주입
const user = await userManager.getUser();
if (user?.access_token) {
config.headers.Authorization = `Bearer ${user.access_token}`;
}
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("dev_tenant_id"); // 키 이름을 좀 더 명확하게 변경 고려
if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const status = error.response?.status;
const message =
error.response?.data?.error?.toString().toLowerCase() ??
error.response?.data?.message?.toString().toLowerCase() ??
"";
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
const isLoginPath = window.location.pathname === "/login";
const shouldRedirectToLogin =
status === 401 ||
(status === 403 &&
(message.includes("authentication required") ||
message.includes("invalid session") ||
message.includes("token is not active")));
if (shouldRedirectToLogin && !isAuthPath && !isLoginPath) {
await userManager.removeUser();
window.location.href = "/login";
}
return Promise.reject(error);
},
);
export default apiClient;

22
orgfront/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
export const oidcConfig: AuthProviderProps = {
authority:
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin,
popup_redirect_uri: `${window.location.origin}/auth/callback`,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
};
export const userManager = new UserManager({
...oidcConfig,
authority: oidcConfig.authority || "",
client_id: oidcConfig.client_id || "",
redirect_uri: oidcConfig.redirect_uri || "",
});

315
orgfront/src/lib/devApi.ts Normal file
View File

@@ -0,0 +1,315 @@
import apiClient from "./apiClient";
export type ClientStatus = "active" | "inactive";
export type ClientType = "private" | "pkce";
export type ClientSummary = {
id: string;
name: string;
type: ClientType;
status: ClientStatus;
createdAt?: string;
clientSecret?: string;
tokenEndpointAuthMethod?: string;
jwksUri?: string;
redirectUris: string[];
scopes: string[];
metadata?: Record<string, unknown>;
};
export type ClientListResponse = {
items: ClientSummary[];
limit: number;
offset: number;
};
export type DevStats = {
total_clients: number;
active_sessions: number;
auth_failures_24h: number;
};
export type DevAuditLog = {
event_id: string;
timestamp: string;
user_id: string;
event_type: string;
status: string;
ip_address: string;
user_agent: string;
device_id?: string;
details?: string;
};
export type DevAuditLogListResponse = {
items: DevAuditLog[];
limit: number;
cursor?: string;
next_cursor?: string;
};
export type ClientEndpoints = {
discovery: string;
issuer: string;
authorization: string;
token: string;
userinfo: string;
};
export type ClientDetailResponse = {
client: ClientSummary & {
clientSecret?: string;
metadata?: Record<string, unknown>;
};
endpoints: ClientEndpoints;
headlessJwksCache?: {
clientId: string;
jwksUri: string;
cachedAt: string;
expiresAt: string;
lastCheckedAt?: string;
lastSuccessfulVerificationAt?: string;
lastRefreshStatus?: "success" | "failure" | "pending";
lastError?: string;
consecutiveFailures?: number;
cachedKids?: string[];
etag?: string;
lastModified?: string;
parsedKeys?: Array<{
kid?: string;
kty?: string;
use?: string;
alg?: string;
n?: string;
}>;
};
};
export type ClientUpsertRequest = {
id?: string;
name?: string;
type?: ClientType;
status?: ClientStatus;
redirectUris?: string[];
scopes?: string[];
grantTypes?: string[];
responseTypes?: string[];
tokenEndpointAuthMethod?: string;
jwksUri?: string;
metadata?: Record<string, unknown>;
};
export type ConsentSummary = {
subject: string;
userName?: string;
clientId: string;
clientName?: string;
grantedScopes: string[];
authenticatedAt?: string;
createdAt: string;
deletedAt?: string;
status: "active" | "revoked";
tenantId?: string;
tenantName?: string;
};
export type ConsentListResponse = {
items: ConsentSummary[];
};
// --- Federation / IdP Config Types ---
export type ProviderType = "oidc" | "saml";
export type IdpConfig = {
id: string;
client_id: string; // Changed from tenant_id
provider_type: ProviderType;
display_name: string;
status: "active" | "inactive";
issuer_url?: string;
// OIDC specific fields
oidc_client_id?: string;
oidc_client_secret?: string;
scopes?: string;
// SAML specific fields
metadata_url?: string;
metadata_xml?: string;
entity_id?: string;
acs_url?: string;
createdAt: string;
updatedAt: string;
};
export type IdpConfigCreateRequest = Omit<
IdpConfig,
"id" | "createdAt" | "updatedAt"
>;
export type IdpConfigUpdateRequest = Partial<IdpConfigCreateRequest>;
// --- End Federation Types ---
export async function fetchClients() {
const { data } = await apiClient.get<ClientListResponse>("/dev/clients");
return data;
}
export async function fetchDevStats() {
const { data } = await apiClient.get<DevStats>("/dev/stats");
return data;
}
export async function fetchClient(clientId: string) {
const { data } = await apiClient.get<ClientDetailResponse>(
`/dev/clients/${clientId}`,
);
return data;
}
export async function updateClientStatus(
clientId: string,
status: ClientStatus,
) {
const { data } = await apiClient.patch<ClientDetailResponse>(
`/dev/clients/${clientId}/status`,
{ status },
);
return data;
}
export async function createClient(payload: ClientUpsertRequest) {
const { data } = await apiClient.post<ClientDetailResponse>(
"/dev/clients",
payload,
);
return data;
}
export async function updateClient(
clientId: string,
payload: ClientUpsertRequest,
) {
const { data } = await apiClient.put<ClientDetailResponse>(
`/dev/clients/${clientId}`,
payload,
);
return data;
}
export async function rotateClientSecret(clientId: string) {
const { data } = await apiClient.post<ClientDetailResponse>(
`/dev/clients/${clientId}/secret/rotate`,
);
return data;
}
export async function refreshHeadlessJwksCache(clientId: string) {
const { data } = await apiClient.post<ClientDetailResponse>(
`/dev/clients/${clientId}/headless-jwks/refresh`,
);
return data;
}
export async function revokeHeadlessJwksCache(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}/headless-jwks/cache`);
}
export async function deleteClient(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}`);
}
export async function fetchConsents(
subject: string,
clientId?: string,
status?: string,
) {
const params: Record<string, string> = { subject };
if (clientId) {
params.client_id = clientId;
}
if (status && status !== "all") {
params.status = status;
}
const { data } = await apiClient.get<ConsentListResponse>("/dev/consents", {
params,
});
return data;
}
export async function revokeConsent(subject: string, clientId?: string) {
const params: Record<string, string> = { subject };
if (clientId) {
params.client_id = clientId;
}
await apiClient.delete("/dev/consents", { params });
}
// --- Federation / IdP Config API Calls ---
export async function listIdpConfigsForClient(clientId: string) {
const { data } = await apiClient.get<IdpConfig[]>(
`/dev/clients/${clientId}/idps`,
);
return data;
}
export async function createIdpConfigForClient(
payload: IdpConfigCreateRequest,
) {
const { data } = await apiClient.post<IdpConfig>(
`/dev/clients/${payload.client_id}/idps`,
payload,
);
return data;
}
export async function updateIdpConfig(
clientId: string,
idpId: string,
payload: IdpConfigUpdateRequest,
) {
const { data } = await apiClient.put<IdpConfig>(
`/dev/clients/${clientId}/idps/${idpId}`,
payload,
);
return data;
}
export async function deleteIdpConfig(clientId: string, idpId: string) {
await apiClient.delete(`/dev/clients/${clientId}/idps/${idpId}`);
}
export async function fetchDevAuditLogs(
limit = 50,
cursor?: string,
filters?: {
action?: string;
client_id?: string;
status?: string;
tenant_id?: string;
},
) {
const { data } = await apiClient.get<DevAuditLogListResponse>(
"/dev/audit-logs",
{
params: {
limit,
cursor,
action: filters?.action,
client_id: filters?.client_id,
status: filters?.status,
tenant_id: filters?.tenant_id,
},
},
);
return data;
}
export type TenantSummary = {
id: string;
name: string;
slug: string;
};
export async function fetchMyTenants() {
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
return data;
}

148
orgfront/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,148 @@
const LOCALE_STORAGE_KEY = "locale";
const DEFAULT_LOCALE = "ko";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
type TomlValue = string | TomlObject;
interface TomlObject {
[key: string]: TomlValue;
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
function parseToml(raw: string): TomlObject {
const lines = raw.split(/\r?\n/);
const root: TomlObject = {};
let currentPath: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
const sectionName = line.slice(1, -1).trim();
currentPath = sectionName
? sectionName
.split(".")
.map((part) => part.trim())
.filter(Boolean)
: [];
continue;
}
const eqIndex = line.indexOf("=");
if (eqIndex === -1) {
continue;
}
const key = line.slice(0, eqIndex).trim();
const valueRaw = line.slice(eqIndex + 1).trim();
if (!key) {
continue;
}
let value = valueRaw;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
let cursor: TomlObject = root;
for (const section of currentPath) {
if (!cursor[section] || typeof cursor[section] === "string") {
cursor[section] = {};
}
cursor = cursor[section] as TomlObject;
}
cursor[key] = value;
}
return root;
}
function getValue(target: TomlObject, key: string): string | undefined {
const parts = key.split(".");
let cursor: TomlValue = target;
for (const part of parts) {
if (typeof cursor !== "object" || cursor === null) {
return undefined;
}
cursor = (cursor as TomlObject)[part];
if (cursor === undefined) {
return undefined;
}
}
return typeof cursor === "string" ? cursor : undefined;
}
function detectLocale(): Locale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isSupportedLocale(stored)) {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale && isSupportedLocale(pathLocale)) {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
if (browserLang.startsWith("ko")) {
return "ko";
}
return DEFAULT_LOCALE;
}
// eslint-disable-next-line import/no-unresolved
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// eslint-disable-next-line import/no-unresolved
import koRaw from "../locales/ko.toml?raw";
const translations: Record<Locale, TomlObject> = {
ko: parseToml(koRaw),
en: parseToml(enRaw),
};
function formatTemplate(
template: string,
vars?: Record<string, string | number>,
): string {
if (!vars) {
return template;
}
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
const value = vars[key];
if (value === undefined || value === null) {
return match;
}
return String(value);
});
}
export function t(
key: string,
fallback?: string,
vars?: Record<string, string | number>,
): string {
const locale = detectLocale();
const value = getValue(translations[locale], key);
if (value && value.length > 0) {
return formatTemplate(value, vars);
}
return formatTemplate(fallback ?? key, vars);
}

27
orgfront/src/lib/role.ts Normal file
View File

@@ -0,0 +1,27 @@
export function normalizeRole(rawRole: unknown): string {
if (typeof rawRole !== "string") return "";
const role = rawRole.trim().toLowerCase();
if (role === "tenant_member") return "user";
if (role === "admin") return "tenant_admin";
if (role === "superadmin") return "super_admin";
if (role === "tenantadmin") return "tenant_admin";
if (role === "rpadmin") return "rp_admin";
return role;
}
export function resolveProfileRole(
profile: Record<string, unknown> | undefined,
) {
if (!profile) return "";
const candidates = [
profile.role,
profile.grade,
profile["custom:role"],
profile["custom:grade"],
];
for (const candidate of candidates) {
const normalized = normalizeRole(candidate);
if (normalized) return normalized;
}
return "";
}

View File

@@ -0,0 +1,76 @@
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
type SlidingSessionRenewDecisionParams = {
expiresAtSec?: number | null;
nowMs: number;
isEnabled: boolean;
isAuthenticated: boolean;
isLoading: boolean;
isRenewInFlight: boolean;
lastAttemptAtMs: number;
thresholdMs?: number;
throttleMs?: number;
};
export function shouldAttemptSlidingSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
return false;
}
return true;
}
export function shouldAttemptUnlimitedSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
return false;
}
return true;
}

View File

@@ -0,0 +1,86 @@
import type { TenantSummary } from "./adminApi";
export type TenantNode = TenantSummary & {
children: TenantNode[];
recursiveMemberCount: number;
};
/**
* Builds a hierarchical tree from a flat list of tenants and calculates
* direct and recursive member counts for each node.
*/
export function buildTenantFullTree(
allTenants: TenantSummary[],
rootId?: string,
): { currentBase: TenantNode | null; subTree: TenantNode[] } {
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
const tenantMap = new Map<string, TenantNode>();
for (const t of allTenants) {
tenantMap.set(t.id, {
...t,
children: [],
recursiveMemberCount: t.memberCount || 0,
});
}
// Build initial children relations and prevent simple cycles
for (const t of allTenants) {
if (t.parentId && t.parentId !== t.id) {
const parent = tenantMap.get(t.parentId);
const child = tenantMap.get(t.id);
if (parent && child) {
// Simple cycle prevention during build: don't add if it creates an immediate loop
parent.children.push(child);
}
}
}
const visitedForCalc = new Set<string>();
// Function to calculate recursive counts with cycle protection
const calculateRecursive = (node: TenantNode): number => {
if (visitedForCalc.has(node.id)) {
console.warn(
`Circular dependency detected in tenant tree for ID: ${node.id}`,
);
return 0; // Prevent infinite loop
}
visitedForCalc.add(node.id);
let total = node.memberCount || 0;
for (const child of node.children) {
total += calculateRecursive(child);
}
node.recursiveMemberCount = total;
// We don't remove from visitedForCalc here because a tree shouldn't have
// multiple paths to the same node anyway (it's a tree, not a graph).
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
// a node should only be visited once.
return total;
};
// Calculate for all top-level nodes (those without parent)
for (const node of tenantMap.values()) {
if (!node.parentId) {
visitedForCalc.clear();
calculateRecursive(node);
}
}
// If a specific rootId is provided, find and return its subtree
if (rootId) {
const base = tenantMap.get(rootId);
if (base) {
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
visitedForCalc.clear();
calculateRecursive(base);
return { currentBase: base, subTree: base.children };
}
return { currentBase: null, subTree: [] };
}
// If no rootId, return all top-level roots as subTree
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
return { currentBase: null, subTree: roots };
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}