From 6cf808a4df5a6cc128d85de61037969109edcd15 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 21 Feb 2026 13:45:21 -0500 Subject: [PATCH] frontend components for API keys --- frontend/src/api.js | 116 ++++++++++++++++++++----- frontend/src/components/forms/User.jsx | 76 ++++++++++++++++ 2 files changed, 170 insertions(+), 22 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index c3107aab..98c94322 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -13,6 +13,7 @@ import useChannelsTableStore from './store/channelsTable'; import useStreamsTableStore from './store/streamsTable'; import useUsersStore from './store/users'; import useConnectStore from './store/connect'; +import Limiter from './utils'; // If needed, you can set a base host or keep it empty if relative requests const host = import.meta.env.DEV @@ -105,6 +106,41 @@ export default class API { return await useAuthStore.getState().getToken(); } + /** + * Fetch all pages for a paginated endpoint when you already know totalCount. + * Builds page calls from totalCount and pageSize and aggregates all results. + * - endpoint: path like "/api/channels/channels/" + * - params: URLSearchParams for filters (will not be mutated) + * - totalCount: total number of matching items + * - pageSize: items per page + * Returns a flat array of results. Supports both array and {results, next} responses. + */ + static async fetchAllByCount(endpoint, params, totalCount, pageSize = 200) { + const total = Number(totalCount) || 0; + const size = Number(pageSize) || 200; + const totalPages = Math.max(1, Math.ceil(total / size)); + + const requests = []; + for (let page = 1; page <= totalPages; page++) { + const q = new URLSearchParams(params || new URLSearchParams()); + q.set('page', String(page)); + q.set('page_size', String(size)); + const url = `${host}${endpoint}?${q.toString()}`; + requests.push(request(url)); + } + + const responses = await Promise.all(requests); + const all = []; + for (const data of responses) { + if (Array.isArray(data)) { + all.push(...data); + } else if (Array.isArray(data?.results)) { + all.push(...data.results); + } + } + return all; + } + static async fetchSuperUser() { try { return await request(`${host}/api/accounts/initialize-superuser/`, { @@ -181,32 +217,39 @@ export default class API { try { // Paginate through channels to avoid heavy single response const pageSize = 200; - let page = 1; - let allChannels = []; + const allChannels = []; - while (true) { - const data = await request( - `${host}/api/channels/channels/?page=${page}&page_size=${pageSize}` - ); + // Get first page to get total results count + const data = await request( + `${host}/api/channels/channels/?page=1&page_size=${pageSize}` + ); - // Backward compatibility: if endpoint returns an array (legacy), just return it - if (Array.isArray(data)) { - allChannels = data; - break; - } - - const results = Array.isArray(data?.results) ? data.results : []; - allChannels = allChannels.concat(results); - - const hasMore = Boolean(data?.next); - if (!hasMore || results.length === 0) { - break; - } - - page += 1; + // Backward compatibility: if endpoint returns an array (legacy), just return it + if (Array.isArray(data)) { + return data; } - return allChannels; + allChannels.concat(Array.isArray(data?.results) ? data.results : []); + + const totalPages = Math.max(1, Math.ceil(data.count / pageSize)) - 1; + const apiCalls = []; + for (let page = 2; page <= totalPages; page++) { + apiCalls.push( + new Promise(async (resolve) => { + const response = await request( + `${host}/api/channels/channels/?page=${page}&page_size=${pageSize}` + ); + + return resolve( + Array.isArray(response?.results) ? response.results : [] + ); + }) + ); + } + + const allResults = await Limiter.all(5, apiCalls); + + return allResults; } catch (e) { errorNotification('Failed to retrieve channels', e); } @@ -2832,6 +2875,35 @@ export default class API { } } + static async generateApiKey({ user_id = null, name = '' } = {}) { + try { + const body = {}; + if (user_id) body.user_id = user_id; + if (name) body.name = name; + + const response = await request( + `${host}/api/accounts/api-keys/generate/`, + { + method: 'POST', + body, + } + ); + + // If the backend returned an updated user, refresh the users store + try { + if (response && response.user) { + useUsersStore.getState().updateUser(response.user); + } + } catch (e) { + // ignore store update errors + } + + return response; + } catch (e) { + errorNotification('Failed to generate API key', e); + } + } + static async updateUser(id, body) { try { const response = await request(`${host}/api/accounts/users/${id}/`, { diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx index 29c93f30..825d4405 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -17,10 +17,12 @@ import { Tooltip, } from '@mantine/core'; import { RotateCcwKey, X } from 'lucide-react'; +import { Copy, Key } from 'lucide-react'; import { useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; import useAuthStore from '../../store/auth'; +import { copyToClipboard } from '../../utils'; const User = ({ user = null, isOpen, onClose }) => { const profiles = useChannelsStore((s) => s.profiles); @@ -29,6 +31,9 @@ const User = ({ user = null, isOpen, onClose }) => { const [, setEnableXC] = useState(false); const [selectedProfiles, setSelectedProfiles] = useState(new Set()); + const [generating, setGenerating] = useState(false); + const [generatedKey, setGeneratedKey] = useState(null); + const [userAPIKey, setUserAPIKey] = useState(user?.api_key || null); const form = useForm({ mode: 'uncontrolled', @@ -139,6 +144,10 @@ const User = ({ user = null, isOpen, onClose }) => { if (customProps.xc_password) { setEnableXC(true); } + + if (user?.api_key) { + setUserAPIKey(user.api_key); + } } else { form.reset(); } @@ -157,6 +166,34 @@ const User = ({ user = null, isOpen, onClose }) => { const showPermissions = authUser.user_level == USER_LEVELS.ADMIN && authUser.id !== user?.id; + const canGenerateKey = + authUser.user_level == USER_LEVELS.ADMIN || authUser.id === user?.id; + + const onGenerateKey = async () => { + if (!canGenerateKey) { + return; + } + + setGenerating(true); + try { + const payload = {}; + if (authUser.user_level == USER_LEVELS.ADMIN && user?.id) { + payload.user_id = user.id; + } + + const resp = await API.generateApiKey(payload); + const newKey = resp && (resp.key || resp.raw_key); + if (newKey) { + setGeneratedKey(newKey); + setUserAPIKey(newKey); + } + } catch (e) { + // API shows notifications + } finally { + setGenerating(false); + } + }; + return (
@@ -269,6 +306,45 @@ const User = ({ user = null, isOpen, onClose }) => { )} + + {canGenerateKey && ( + + {userAPIKey && ( + + copyToClipboard(userAPIKey, { + successTitle: 'API Key Copied!', + successMessage: + 'The API Key has been copied to your clipboard.', + }) + } + > + + + } + /> + )} + + + + )}