Files
owncast/web/pages/admin/access-tokens.tsx
Copilot 383b80851b Add localization support to admin status and error messages (#4631)
* Initial plan

* Add localization support to admin form status and error messages

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Format updated files with prettier

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Replace t() with Translation component in admin page JSX

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* update package-lock.json

* Update web/i18n/en/translation.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: gabek <414923+gabek@users.noreply.github.com>
Co-authored-by: Gabe Kangas <gabek@real-ity.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-13 16:29:40 -07:00

285 lines
7.0 KiB
TypeScript

import React, { useState, useEffect, ReactElement } from 'react';
import {
Table,
Tag,
Space,
Button,
Modal,
Checkbox,
Input,
Typography,
Row,
Col,
Tooltip,
} from 'antd';
import { format } from 'date-fns';
import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import {
fetchData,
ACCESS_TOKENS,
DELETE_ACCESS_TOKEN,
CREATE_ACCESS_TOKEN,
} from '../../utils/apis';
import { Localization } from '../../types/localization';
import { Translation } from '../../components/ui/Translation/Translation';
import { AdminLayout } from '../../components/layouts/AdminLayout';
const { Title, Paragraph } = Typography;
// Lazy loaded components
const DeleteOutlined = dynamic(() => import('@ant-design/icons/DeleteOutlined'), {
ssr: false,
});
const availableScopes = {
CAN_SEND_SYSTEM_MESSAGES: {
name: 'System messages',
description: 'Can send official messages on behalf of the system.',
color: 'purple',
},
CAN_SEND_MESSAGES: {
name: 'User chat messages',
description: 'Can send chat messages on behalf of the owner of this token.',
color: 'green',
},
HAS_ADMIN_ACCESS: {
name: 'Has admin access',
description: 'Can perform administrative actions such as moderation, get server statuses, etc.',
color: 'red',
},
};
function convertScopeStringToTag(scopeString: string) {
if (!scopeString || !availableScopes[scopeString]) {
return null;
}
const scope = availableScopes[scopeString];
return (
<Tooltip key={scopeString} title={scope.description}>
<Tag color={scope.color}>{scope.name}</Tag>
</Tooltip>
);
}
interface Props {
onCancel: () => void;
onOk: any; // todo: make better type
open: boolean;
}
const NewTokenModal = (props: Props) => {
const { onOk, onCancel, open } = props;
const { t } = useTranslation();
const [selectedScopes, setSelectedScopes] = useState([]);
const [name, setName] = useState('');
const scopes = Object.keys(availableScopes).map(key => ({
value: key,
label: availableScopes[key].description,
}));
function onChange(checkedValues) {
setSelectedScopes(checkedValues);
}
function saveToken() {
onOk(name, selectedScopes);
// Clear the modal
setSelectedScopes([]);
setName('');
}
const okButtonProps = {
disabled: selectedScopes.length === 0 || name === '',
};
function selectAll() {
setSelectedScopes(Object.keys(availableScopes));
}
const checkboxes = scopes.map(singleEvent => (
<Col span={8} key={singleEvent.value}>
<Checkbox value={singleEvent.value}>{singleEvent.label}</Checkbox>
</Col>
));
return (
<Modal
title={t(Localization.Admin.AccessTokens.createNewAccessToken)}
open={open}
onOk={saveToken}
onCancel={onCancel}
okButtonProps={okButtonProps}
>
<p>
<p>
<Translation translationKey={Localization.Admin.AccessTokens.nameDescription} />
</p>
<Input
value={name}
placeholder={t(Localization.Admin.AccessTokens.namePlaceholder)}
onChange={input => setName(input.currentTarget.value)}
/>
</p>
<p>
<Translation translationKey={Localization.Admin.AccessTokens.selectPermissions} />
</p>
<Checkbox.Group style={{ width: '100%' }} value={selectedScopes} onChange={onChange}>
<Row>{checkboxes}</Row>
</Checkbox.Group>
<p>
<Button type="primary" onClick={selectAll}>
<Translation translationKey={Localization.Admin.AccessTokens.selectAll} />
</Button>
</p>
</Modal>
);
};
const AccessTokens = () => {
const [tokens, setTokens] = useState([]);
const [isTokenModalOpen, setIsTokenModalOpen] = useState(false);
function handleError(error) {
console.error('error', error);
}
async function getAccessTokens() {
try {
const result = await fetchData(ACCESS_TOKENS);
setTokens(result);
} catch (error) {
handleError(error);
}
}
useEffect(() => {
getAccessTokens();
}, []);
async function handleDeleteToken(token) {
try {
await fetchData(DELETE_ACCESS_TOKEN, {
method: 'POST',
data: { token },
});
getAccessTokens();
} catch (error) {
handleError(error);
}
}
async function handleSaveToken(name: string, scopes: string[]) {
try {
const newToken = await fetchData(CREATE_ACCESS_TOKEN, {
method: 'POST',
data: { name, scopes },
});
setTokens(tokens.concat(newToken));
} catch (error) {
handleError(error);
}
}
const columns = [
{
title: '',
key: 'delete',
render: (_, record) => (
<Space size="middle">
<Button onClick={() => handleDeleteToken(record.accessToken)} icon={<DeleteOutlined />} />
</Space>
),
},
{
title: 'Name',
dataIndex: 'displayName',
key: 'displayName',
},
{
title: 'Token',
dataIndex: 'accessToken',
key: 'accessToken',
render: text => <Input.Password size="small" bordered={false} value={text} />,
},
{
title: 'Scopes',
dataIndex: 'scopes',
key: 'scopes',
// eslint-disable-next-line react/destructuring-assignment
render: scopes => <>{scopes.map(scope => convertScopeStringToTag(scope))}</>,
},
{
title: 'Last Used',
dataIndex: 'lastUsed',
key: 'lastUsed',
render: lastUsed => {
if (!lastUsed) {
return 'Never';
}
const dateObject = new Date(lastUsed);
return format(dateObject, 'P p');
},
},
];
const showCreateTokenModal = () => {
setIsTokenModalOpen(true);
};
const handleTokenModalSaveButton = (name, scopes) => {
setIsTokenModalOpen(false);
handleSaveToken(name, scopes);
};
const handleTokenModalCancel = () => {
setIsTokenModalOpen(false);
};
return (
<div>
<Title>Access Tokens</Title>
<Paragraph>
Access tokens are used to allow external, 3rd party tools to perform specific actions on
your Owncast server. They should be kept secure and never included in client code, instead
they should be kept on a server that you control.
</Paragraph>
<Paragraph>
Read more about how to use these tokens, with examples, at{' '}
<a
href="https://owncast.online/docs/integrations/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
our documentation
</a>
.
</Paragraph>
<Table rowKey="token" columns={columns} dataSource={tokens} pagination={false} />
<br />
<Button type="primary" onClick={showCreateTokenModal}>
Create Access Token
</Button>
<NewTokenModal
open={isTokenModalOpen}
onOk={handleTokenModalSaveButton}
onCancel={handleTokenModalCancel}
/>
</div>
);
};
AccessTokens.getLayout = function getLayout(page: ReactElement) {
return <AdminLayout page={page} />;
};
export default AccessTokens;