frontend components for API keys

This commit is contained in:
dekzter 2026-02-21 13:45:21 -05:00
parent aece3367ce
commit 6cf808a4df
2 changed files with 170 additions and 22 deletions

View file

@ -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}/`, {

View file

@ -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 (
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
@ -269,6 +306,45 @@ const User = ({ user = null, isOpen, onClose }) => {
</Tooltip>
</Box>
)}
{canGenerateKey && (
<Stack>
{userAPIKey && (
<TextInput
label="API Key"
disabled={true}
value={userAPIKey}
rightSection={
<ActionIcon
variant="transparent"
size="sm"
color="white"
onClick={() =>
copyToClipboard(userAPIKey, {
successTitle: 'API Key Copied!',
successMessage:
'The API Key has been copied to your clipboard.',
})
}
>
<Copy />
</ActionIcon>
}
/>
)}
<Button
leftSection={<Key size={14} />}
size="xs"
onClick={onGenerateKey}
loading={generating}
color="blue"
variant="light"
>
{userAPIKey ? 'Regenerate API Key' : 'Generate API Key'}
</Button>
</Stack>
)}
</Stack>
</Group>