사용자 정보와 프로필 이미지: useAdmin
useAdmin은 Foundry Admin API에서 현재 사용자, 지정한 사용자 목록과 프로필 이미지를 읽습니다. 사용자 생성, 수정과 삭제는 제공하지 않습니다. 앱의 클라이언트는 Developer Console 권한과 호출할 Admin API의 접근 조건을 충족해야 합니다.
반환값과 호출 범위
| 반환값 | 의미 |
|---|---|
users | 현재 사용자 요청의 { currentUser: User } 데이터이며, 배치로 조회한 모든 사용자의 목록은 아닙니다. 로드 전에는 undefined입니다. |
currentUser | 로그인한 사용자이며 로드 전에는 undefined입니다. |
isLoading | 현재 사용자 데이터를 처음 읽는 상태입니다. |
isValidating | 현재 사용자 조회 또는 재검증이 진행 중인 상태입니다. |
isError | 현재 사용자 조회 오류입니다. 배치와 이미지 요청의 오류를 대신하지 않습니다. |
getBatchUserDetails(userIds) | ID별 사용자 정보를 반환하는 비동기 함수입니다. 없는 항목을 조회하면 undefined일 수 있습니다. |
getCurrentProfilePictureUrl(user) | 인수로 받은 사용자의 프로필 이미지를 읽고 Blob URL을 반환합니다. 이름에 current가 있지만 현재 로그인한 사용자로 한정하지 않습니다. |
User 객체에는 프로필 이미지가 포함되지 않습니다. 이름과 이메일은 사용자 조회 결과에서 읽고, 이미지는 별도 요청으로 가져옵니다.
캐시와 배치 처리
사용자 ID별 캐시 키는 user-${userId}입니다. 먼저 SWR 캐시의 State.data를 읽고, 캐시에 없는 ID만 Admin API로 요청합니다. 캐시 갱신에는 현재 SWRConfig 범위의 mutate를 사용하고 revalidate: false로 같은 내용을 다시 요청하지 않습니다.
사용자 일괄 조회는 한 요청에 최대 500개 항목을 허용합니다. 구현은 중복 ID를 제거하고 캐시에 없는 ID를 500개씩 나누어 요청합니다. 따라서 요청 횟수는 캐시에 없는 고유 ID 수에 따라 달라집니다. 빈 배열에는 네트워크 요청 없이 빈 객체를 반환합니다.
훅 구현
dataServices/useAdmin.ts에 둡니다. @osdk/foundry.admin/User의 getCurrent, profilePicture, getBatch를 사용하며, UserDetails는 사용자를 찾지 못한 경우를 표현하는 Partial<Record<string, User>>입니다.
/*
* Copyright 2025 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* Modified example: Use the current SWR provider, bound requests to 500 users, and type missing lookups.
*/
import useSWR, { useSWRConfig } from "swr";
import type { State } from "swr";
import { useOsdkClient } from "@osdk/react";
import { getCurrent, profilePicture, getBatch } from "@osdk/foundry.admin/User";
import { useCallback } from "react";
import type { User } from "@osdk/foundry.admin";
type UserDetails = Partial<Record<string, User>>;
function useAdmin() {
const client = useOsdkClient();
const { cache, mutate } = useSWRConfig();
const getCurrentUserDetails = useCallback(async () => {
const user = await getCurrent(client);
return { currentUser: user };
}, [client]);
const getCurrentProfilePictureUrl = useCallback(async (user: User): Promise<string> => {
const response = await profilePicture(client, user.id);
const blob = await response.blob();
return URL.createObjectURL(blob);
}, [client]);
const { data, isLoading, isValidating, error } = useSWR<{ currentUser: User }>(
"currentUser",
getCurrentUserDetails,
{ revalidateOnFocus: false },
);
const getBatchUserDetails = useCallback(async (userIds: string[]): Promise<UserDetails> => {
const users: UserDetails = {};
const usersToFetch: string[] = [];
for (const userId of new Set(userIds)) {
const cachedUser: State<unknown, unknown> | undefined = cache.get(`user-${userId}`);
if (cachedUser?.data != null) {
users[userId] = cachedUser.data as User;
} else {
usersToFetch.push(userId);
}
}
for (let offset = 0; offset < usersToFetch.length; offset += 500) {
const request = usersToFetch.slice(offset, offset + 500).map(userId => ({ userId }));
const response = await getBatch(client, request);
for (const [userId, user] of Object.entries(response.data)) {
users[userId] = user;
await mutate(`user-${userId}`, user, { revalidate: false });
}
}
return users;
}, [cache, client, mutate]);
return {
users: data,
currentUser: data?.currentUser,
isLoading,
isValidating,
isError: error,
getBatchUserDetails,
getCurrentProfilePictureUrl,
};
}
export default useAdmin;
현재 사용자의 SWR 키는 currentUser이고 포커스 복귀 시 재검증을 끕니다. user-*에 보관한 개별 사용자에는 별도의 fetcher가 등록되지 않으므로 현재 사용자 재검증이 이 항목들을 자동 갱신하지 않습니다. 사용자 정보가 변경되었거나 같은 페이지에서 인증 사용자와 클라이언트를 교체한다면 해당 캐시를 무효화하거나 분리해야 합니다.
배치 요청이 실패하면 Promise가 거부됩니다. 오류를 빈 사용자 객체나 가상의 사용자로 바꾸지 않으므로 호출자가 오류 상태를 표시해야 합니다. 앞선 배치의 캐시가 채워진 뒤 다음 배치가 실패할 수 있으며, 여러 배치를 하나의 트랜잭션으로 처리하지 않습니다.
프로필과 협업자 화면
프로필 화면은 사용자 조회와 이미지 조회의 오류를 구분합니다. 이미지 URL은 사용자 변경이나 컴포넌트 해제 시 해제하며, 컴포넌트 해제 뒤 늦게 도착한 이미지 URL도 즉시 해제합니다.
협업자 화면은 배치 요청 자체의 로딩 상태를 관리합니다. 빈 ID 배열로 바뀌면 이전 목록을 비우며, 이전 요청이 늦게 끝나도 새 화면의 결과를 덮어쓰지 않습니다. 이는 결과 적용을 제어하는 방식이며 이미 전송한 HTTP 요청을 취소하지는 않습니다.
import { useEffect, useState } from "react";
import type { User } from "@osdk/foundry.admin";
import useAdmin from "../dataServices/useAdmin";
export function UserProfile() {
const { currentUser, isLoading, isError, getCurrentProfilePictureUrl } = useAdmin();
const [picture, setPicture] = useState<{ userId: string; url: string }>();
const [pictureError, setPictureError] = useState<string>();
useEffect(() => {
let active = true;
let urlToRelease: string | undefined;
setPicture(undefined);
setPictureError(undefined);
if (currentUser != null) {
void getCurrentProfilePictureUrl(currentUser).then(url => {
if (!active) {
URL.revokeObjectURL(url);
return;
}
urlToRelease = url;
setPicture({ userId: currentUser.id, url });
}).catch(cause => {
if (active) setPictureError(String(cause));
});
}
return () => {
active = false;
if (urlToRelease != null) URL.revokeObjectURL(urlToRelease);
};
}, [currentUser, getCurrentProfilePictureUrl]);
if (isLoading) return <div>Loading user information...</div>;
if (isError) return <div role="alert">Error loading user information</div>;
if (currentUser == null) return <div>No user found</div>;
return (
<div className="user-profile">
{picture?.userId === currentUser.id && <img src={picture.url} alt={`Profile of ${currentUser.username}`} />}
{pictureError && <p role="alert">Profile picture: {pictureError}</p>}
<h1>{currentUser.username}</h1>
<p>Email: {currentUser.email ?? "Not set"}</p>
<p>User ID: {currentUser.id}</p>
</div>
);
}
export function CollaboratorsList({ userIds }: { userIds: string[] }) {
const { getBatchUserDetails } = useAdmin();
const [users, setUsers] = useState<Partial<Record<string, User>>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
useEffect(() => {
let active = true;
setUsers({});
setError(undefined);
setLoading(true);
void getBatchUserDetails(userIds).then(found => {
if (active) setUsers(found);
}).catch(cause => {
if (active) setError(String(cause));
}).finally(() => {
if (active) setLoading(false);
});
return () => { active = false; };
}, [userIds, getBatchUserDetails]);
if (loading) return <div>Loading collaborators...</div>;
if (error) return <div role="alert">Error loading collaborators: {error}</div>;
return (
<div className="collaborators">
<h2>Project Collaborators</h2>
<ul>{Object.entries(users).map(([id, user]) => user == null ? null : (
<li key={id}><span>{user.username}</span> <span>{user.email ?? "Not set"}</span></li>
))}</ul>
</div>
);
}
URL.createObjectURL()로 만든 URL은 필요한 동안만 유지하고 URL.revokeObjectURL()로 해제합니다. 이 훅은 호출자에게 URL 소유권을 넘기므로 다른 화면에서도 같은 정리 절차가 필요합니다. 사용자 이미지 조회는 배치하지 않으며, 여러 사용자의 이미지는 각각 요청합니다.