mirror of https://github.com/boxyhq/jackson.git
Retraced Admin UI (#452)
* Merged
* Revert the changes
* changes
* dropdown working
* fixes
* added custom classes for log viewer
* Create Project & styling fixes
* Update package-lock.json
* fixed react datepicker css issues
* Showing apis keys after project is created
* View tokens page
* minor changes
* masking for tokens
* warning fixes
* Fix the sidebar active state
* wip
* wip
* wip
* Showing publisher api url
* Fixed create new projects and list projects
* Improved the ProjectInfo page
* Fix the copy to clipboard button
* Add the codesnippet
* wip
* wip UI
* Improve the code snippet
* Fixes and improve the UIs
* Replace the product logos
* Set the group null
* Fix the conflicts
* Fix the heroicons
* Remove the unused method
* Make the ProjectDetails 2 columns
* Fix the logs-viewer not displaying
* read event log from admin-ui
* Jackson docker compose file & retraced integration related changes
* minor fix
* fixes for created key of audit log
* fixed the expiry for self signed certificate
* using node forge for self signed certs
* Revert "using node forge for self signed certs"
This reverts commit c027b5b7ce
.
* fix
* package lock changes
* installed missing dependancies and added new packages
* minor fixes
* fixes
* added missing translations for retraced pages
* - pin deps
- removed react-copy-to-clipboard, react-host-toast
* fixed typo
* cleanup
* tweak
* switched to ButtonIcon
* switch to button components and added back buttons where needed
* checking npm ci
* simplified env vars for Retraced
* tweaks
* If Retraced host is not specified then show a message
* added audit logs logo
* - added admin_token to bypass user and project specific queries
- fixed project details view to read any length for environments
* switched to daisyui Select
* fixed auth check for api routes, get email for claims from the jwt
* updated package-lock
* switched to clipboard component
* tweaks to CodeSnippet
* padding tweaks
* updated package-lock
* updated package-lock
* fixed z-index for modal in logs-viewer
* select -> Select
Co-authored-by: Kiran <kiran@boxyhq.com>
Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
parent
5b9664433d
commit
0d7fac092b
|
@ -62,3 +62,7 @@ BOXYHQ_LICENSE_KEY=
|
|||
|
||||
# To turn off our anonymous analytics uncomment the line below
|
||||
#BOXYHQ_NO_ANALYTICS=1
|
||||
|
||||
# Retraced
|
||||
NEXT_PUBLIC_RETRACED_HOST=
|
||||
RETRACED_ADMIN_ROOT_TOKEN=
|
||||
|
|
|
@ -61,5 +61,3 @@ EXPOSE 5225
|
|||
ENV PORT 5225
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
const ErrorMessage = () => {
|
||||
return <p>{`Unable to load this page. Maybe you don't have enough rights.`}</p>;
|
||||
};
|
||||
|
||||
export default ErrorMessage;
|
|
@ -4,7 +4,7 @@ export const IconButton = ({ Icon, tooltip, onClick, className }) => {
|
|||
return (
|
||||
<div className='tooltip' data-tip={tooltip}>
|
||||
<Icon
|
||||
className={classNames('h-5 w-5 cursor-pointer text-secondary hover:scale-125', className)}
|
||||
className={classNames('hover:scale-115 h-5 w-5 cursor-pointer text-secondary', className)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -8,8 +8,7 @@ import Logo from '../public/logo.png';
|
|||
import { useTranslation } from 'next-i18next';
|
||||
import SSOLogo from '@components/logo/SSO';
|
||||
import DSyncLogo from '@components/logo/DSync';
|
||||
// import VaultLogo from '@components/logo/Vault';
|
||||
// import AuditLogsLogo from '@components/logo/AuditLogs';
|
||||
import AuditLogsLogo from '@components/logo/AuditLogs';
|
||||
|
||||
type SidebarProps = {
|
||||
isOpen: boolean;
|
||||
|
@ -78,6 +77,20 @@ export const Sidebar = ({ isOpen, setIsOpen }: SidebarProps) => {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/admin/retraced',
|
||||
text: 'Audit Logs',
|
||||
icon: AuditLogsLogo,
|
||||
current: asPath.includes('retraced'),
|
||||
active: asPath.includes('/admin/retraced'),
|
||||
items: [
|
||||
{
|
||||
href: '/admin/retraced',
|
||||
text: t('projects'),
|
||||
active: asPath.includes('/admin/retraced'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
import { useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
import { successToast, errorToast } from '@components/Toaster';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
|
||||
const AddProject = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [project, setProject] = useState({
|
||||
name: '',
|
||||
});
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
|
||||
setProject({
|
||||
...project,
|
||||
[target.id]: target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const createProject = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch('/api/admin/retraced/projects', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(project),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!response.ok) {
|
||||
errorToast('ERROR');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await response.json();
|
||||
|
||||
if (error) {
|
||||
errorToast('ERROR');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && data.project) {
|
||||
successToast('Project created successfully.');
|
||||
router.replace(`/admin/retraced/projects/${data.project.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkBack href='/admin/retraced/projects' />
|
||||
<div className='mt-5'>
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>Create Project</h2>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
|
||||
<form onSubmit={createProject}>
|
||||
<div className='flex flex-col space-y-3'>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>Project name</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='name'
|
||||
className='input-bordered input w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button className={classNames('btn-primary btn', loading ? 'loading' : '')}>
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddProject;
|
|
@ -0,0 +1,64 @@
|
|||
import { CopyToClipboardButton } from '@components/ClipboardButton';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
|
||||
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
|
||||
const CodeSnippet = ({ token, baseUrl }: { token: string; baseUrl: string }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const eventURL = `${baseUrl}/event`;
|
||||
const curlRequest = `curl -X POST -H "Content-Type: application/json" -H "Authorization: token=${token}" -d '{
|
||||
"action": "some.record.created",
|
||||
"teamId": "boxyhq",
|
||||
"group": {
|
||||
"id": "boxyhq",
|
||||
"name": "BoxyHQ"
|
||||
},
|
||||
"crud": "c",
|
||||
"created": "${new Date().toISOString()}",
|
||||
"source_ip": "127.0.0.1",
|
||||
"actor": {
|
||||
"id": "jackson@boxyhq.com",
|
||||
"name": "Jackson"
|
||||
},
|
||||
"target": {
|
||||
"id": "100",
|
||||
"name": "tasks",
|
||||
"type": "Tasks"
|
||||
}
|
||||
}' ${eventURL}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='text-sm'>
|
||||
<div className='mb-5'>
|
||||
<div className='flex justify-between'>
|
||||
<label className='mb-2 block text-sm font-bold text-gray-900 dark:text-gray-300'>
|
||||
{t('send_event_to_url')}
|
||||
</label>
|
||||
<CopyToClipboardButton text={eventURL} />
|
||||
</div>
|
||||
|
||||
<SyntaxHighlighter language='bash' style={materialOceanic} customStyle={{ borderRadius: '0.5em' }}>
|
||||
{eventURL}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='flex justify-between'>
|
||||
<label className='mb-2 block text-sm font-bold text-gray-900 dark:text-gray-300'>
|
||||
{t('curl_request')}
|
||||
</label>
|
||||
<CopyToClipboardButton text={curlRequest} />
|
||||
</div>
|
||||
|
||||
<SyntaxHighlighter language='bash' style={materialOceanic} customStyle={{ borderRadius: '0.5em' }}>
|
||||
{curlRequest}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeSnippet;
|
|
@ -0,0 +1,48 @@
|
|||
import RetracedEventsBrowser from '@retraced-hq/logs-viewer';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import type { ApiError, ApiSuccess } from 'types';
|
||||
import type { Project } from 'types/retraced';
|
||||
import ErrorMessage from '@components/Error';
|
||||
import Loading from '@components/Loading';
|
||||
import { fetcher } from '@lib/ui/utils';
|
||||
import { retracedOptions } from '@lib/env';
|
||||
|
||||
const LogsViewer = (props: { project: Project; environmentId: string; groupId: string }) => {
|
||||
const { project, environmentId, groupId } = props;
|
||||
|
||||
const token = project.tokens.filter((token) => token.environment_id === environmentId)[0];
|
||||
|
||||
const { data, error } = useSWR<ApiSuccess<{ viewerToken: string }>, ApiError>(
|
||||
[`/api/admin/retraced/projects/${project.id}/viewer-token`, `?groupId=${groupId}&token=${token.token}`],
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage />;
|
||||
}
|
||||
|
||||
const viewerToken = data?.data?.viewerToken;
|
||||
|
||||
return (
|
||||
<>
|
||||
{viewerToken && (
|
||||
<RetracedEventsBrowser
|
||||
host={`${retracedOptions?.host}/viewer/v1`}
|
||||
auditLogToken={viewerToken}
|
||||
header='Audit Logs'
|
||||
customClass={'text-primary dark:text-white'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsViewer;
|
|
@ -0,0 +1,59 @@
|
|||
import type { Project } from 'types/retraced';
|
||||
import { retracedOptions } from '@lib/env';
|
||||
import CodeSnippet from '@components/retraced/CodeSnippet';
|
||||
import { useState } from 'react';
|
||||
import { Select } from 'react-daisyui';
|
||||
import { InputWithCopyButton } from '@components/ClipboardButton';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
const ProjectDetails = (props: { project: Project }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { project } = props;
|
||||
const { environments, tokens } = project;
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const baseUrl = `${retracedOptions?.host}/publisher/v1/project/${project.id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='form-control mb-5 max-w-xs'>
|
||||
<label className='label pl-0'>
|
||||
<span className='label-text'>Environment</span>
|
||||
</label>
|
||||
<Select
|
||||
value={selectedIndex}
|
||||
onChange={(idx) => {
|
||||
setSelectedIndex(idx);
|
||||
}}>
|
||||
{environments.map((env, i) => (
|
||||
<option key={env.id} value={i}>
|
||||
{env.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 border p-3 md:grid-cols-2'>
|
||||
<div className='form-control w-full'>
|
||||
<InputWithCopyButton text={project.id} label={t('project_id')} />
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<InputWithCopyButton text={baseUrl} label={t('publisher_api_base_url')} />
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<InputWithCopyButton
|
||||
text={tokens[selectedIndex].token}
|
||||
label={environments[selectedIndex].name + ' Token'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-5 border p-3'>
|
||||
<CodeSnippet token={tokens[selectedIndex].token} baseUrl={baseUrl} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetails;
|
|
@ -1,4 +1,3 @@
|
|||
# this file is a helper to run all the supported dbs locally, the data is ephemeral since no host volumes will be mounted
|
||||
version: "3.6"
|
||||
services:
|
||||
postgres:
|
||||
|
@ -36,6 +35,15 @@ services:
|
|||
environment:
|
||||
MARIADB_DATABASE: mysql
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes"
|
||||
jackson:
|
||||
build: .
|
||||
ports:
|
||||
- "5225:5225"
|
||||
environment:
|
||||
- SAML_AUDIENCE=https://saml.boxyhq.com
|
||||
- JACKSON_API_KEYS="secret"
|
||||
- DB_TYPE=mysql
|
||||
- DB_URL=mysql://root:mysql@mysql:3306/mysql
|
||||
mssql:
|
||||
image: mcr.microsoft.com/azure-sql-edge:1.0.6
|
||||
ports:
|
|
@ -16,6 +16,12 @@ if (process.env.DB_SSL === 'true') {
|
|||
};
|
||||
}
|
||||
|
||||
// Retraced
|
||||
const retraced = {
|
||||
host: process.env.NEXT_PUBLIC_RETRACED_HOST,
|
||||
adminToken: process.env.RETRACED_ADMIN_ROOT_TOKEN,
|
||||
};
|
||||
|
||||
const db: DatabaseOption = {
|
||||
engine: process.env.DB_ENGINE ? <DatabaseEngine>process.env.DB_ENGINE : undefined,
|
||||
url: process.env.DB_URL || process.env.DATABASE_URL,
|
||||
|
@ -49,6 +55,7 @@ const jacksonOptions: JacksonOption = {
|
|||
privateKey: process.env.PRIVATE_KEY || '',
|
||||
},
|
||||
boxyhqLicenseKey: process.env.BOXYHQ_LICENSE_KEY,
|
||||
retraced,
|
||||
noAnalytics:
|
||||
process.env.DO_NOT_TRACK === '1' ||
|
||||
process.env.DO_NOT_TRACK === 'true' ||
|
||||
|
@ -56,5 +63,6 @@ const jacksonOptions: JacksonOption = {
|
|||
process.env.BOXYHQ_NO_ANALYTICS === 'true',
|
||||
};
|
||||
|
||||
export { retraced as retracedOptions };
|
||||
export { apiKeys };
|
||||
export { jacksonOptions };
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import axios from 'axios';
|
||||
|
||||
import type { AdminToken } from 'types/retraced';
|
||||
import { retracedOptions } from './env';
|
||||
import { getToken as getNextAuthToken } from 'next-auth/jwt';
|
||||
import type { NextApiRequest } from 'next';
|
||||
import { sessionName } from './constants';
|
||||
|
||||
export const getToken = async (req: NextApiRequest): Promise<AdminToken> => {
|
||||
const token = await getNextAuthToken({
|
||||
req,
|
||||
cookieName: sessionName,
|
||||
});
|
||||
|
||||
const { data } = await axios.post<{ adminToken: AdminToken }>(
|
||||
`${retracedOptions?.host}/admin/v1/user/_login`,
|
||||
{
|
||||
claims: {
|
||||
upstreamToken: 'ADMIN_ROOT_TOKEN',
|
||||
email: token!.email,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token=${retracedOptions?.adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data.adminToken;
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
import axios from 'axios';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import type { ApiError, ApiSuccess } from 'types';
|
||||
import type { Project, Group } from 'types/retraced';
|
||||
import { fetcher } from '@lib/ui/utils';
|
||||
|
||||
export const useProject = (projectId: string) => {
|
||||
const { data, error } = useSWR<ApiSuccess<{ project: Project }>, ApiError>(
|
||||
`/api/admin/retraced/projects/${projectId}`,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
project: data?.data.project,
|
||||
isLoading: !error && !data,
|
||||
isError: error,
|
||||
};
|
||||
};
|
||||
|
||||
export const useProjects = () => {
|
||||
const { data, error } = useSWR<ApiSuccess<{ projects: Project[] }>, ApiError>(
|
||||
'/api/admin/retraced/projects',
|
||||
fetcher
|
||||
);
|
||||
|
||||
return {
|
||||
projects: data?.data?.projects,
|
||||
isLoading: !error && !data,
|
||||
isError: error,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGroups = (projectId: string, environmentId: string) => {
|
||||
const { data, error } = useSWR<ApiSuccess<{ groups: Group[] }>, ApiError>(
|
||||
environmentId ? `/api/admin/retraced/projects/${projectId}/groups?environmentId=${environmentId}` : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
groups: data?.data?.groups,
|
||||
isLoading: !error && !data,
|
||||
isError: error,
|
||||
};
|
||||
};
|
|
@ -125,5 +125,13 @@
|
|||
"regenerate_setup_link_description": "This action cannot be undone. This will permanently delete the old setup link.",
|
||||
"delete_setup_link": "Delete this setup link?",
|
||||
"delete_setup_link_description": "This action cannot be undone. This will permanently delete the setup link.",
|
||||
"close": "Close"
|
||||
}
|
||||
"close": "Close",
|
||||
"configuration": "Configuration",
|
||||
"view_events": "View Events",
|
||||
"new_project": "New Project",
|
||||
"projects": "Projects",
|
||||
"project_id": "Project ID",
|
||||
"publisher_api_base_url": "Publisher API Base URL",
|
||||
"send_event_to_url": "Send your event to the following URL",
|
||||
"curl_request": "cURL Request"
|
||||
}
|
|
@ -2274,6 +2274,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.47.1.tgz",
|
||||
"integrity": "sha512-rF3pmut2JCCjh6BLRhNKdYjULMb1brvoaiWDlHfLNVgmnZ0sBVJrs3SyaKE1XoDDnJuAx/hDQryHYmPUuNq0ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "5.47.1",
|
||||
"eslint-visitor-keys": "^3.3.0"
|
||||
|
@ -3164,6 +3165,7 @@
|
|||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.30.0.tgz",
|
||||
"integrity": "sha512-MGADB39QqYuzEGov+F/qb18r4i7DohCDOfatHaxI2iGlPuC65bwG2gxgO+7DkyL38dRFaRH7RaRAgU6JKL9rMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint/eslintrc": "^1.4.0",
|
||||
"@humanwhocodes/config-array": "^0.11.8",
|
||||
|
|
|
@ -339,6 +339,10 @@ export interface JacksonOption {
|
|||
privateKey: string;
|
||||
};
|
||||
boxyhqLicenseKey?: string;
|
||||
retraced?: {
|
||||
host?: string;
|
||||
adminToken?: string;
|
||||
};
|
||||
noAnalytics?: boolean;
|
||||
}
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -52,7 +52,11 @@
|
|||
"@opentelemetry/resources": "1.8.0",
|
||||
"@opentelemetry/sdk-metrics": "1.8.0",
|
||||
"@opentelemetry/semantic-conventions": "1.8.0",
|
||||
"@retraced-hq/logs-viewer": "2.3.0",
|
||||
"@retraced-hq/retraced": "0.4.6",
|
||||
"@tailwindcss/typography": "0.5.8",
|
||||
"axios": "1.1.3",
|
||||
"chance": "1.1.9",
|
||||
"classnames": "2.3.2",
|
||||
"cors": "2.8.5",
|
||||
"daisyui": "2.46.0",
|
||||
|
@ -67,6 +71,7 @@
|
|||
"react-daisyui": "2.5.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-syntax-highlighter": "15.5.0",
|
||||
"request-ip": "3.3.0",
|
||||
"sharp": "0.31.3",
|
||||
"swr": "1.3.0"
|
||||
},
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import type { NextPage } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { retracedOptions } from '@lib/env';
|
||||
import Loading from '@components/Loading';
|
||||
import EmptyState from '@components/EmptyState';
|
||||
|
||||
const Retraced: NextPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!retracedOptions?.host) {
|
||||
return;
|
||||
}
|
||||
router.push('/admin/retraced/projects');
|
||||
}, [router]);
|
||||
|
||||
if (!retracedOptions?.host) {
|
||||
return (
|
||||
<EmptyState
|
||||
title='This feature has not been enabled.'
|
||||
description='Please add the host for our Audit Logs service to enable this feature.'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Loading />;
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ['common'])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default Retraced;
|
|
@ -0,0 +1,111 @@
|
|||
import type { NextPage } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import { useProject, useGroups } from '@lib/ui/retraced';
|
||||
import Loading from '@components/Loading';
|
||||
import ErrorMessage from '@components/Error';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
import { Select } from 'react-daisyui';
|
||||
|
||||
const LogsViewer = dynamic(() => import('@components/retraced/LogsViewer'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const Events: NextPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [environment, setEnvironment] = useState('');
|
||||
const [group, setGroup] = useState('');
|
||||
|
||||
const projectId = router.query.id as string;
|
||||
|
||||
const { project, isLoading, isError } = useProject(projectId);
|
||||
const { groups } = useGroups(projectId, environment);
|
||||
|
||||
// Set the environment
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setEnvironment(project.environments[0].id);
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
// Set the group
|
||||
useEffect(() => {
|
||||
if (groups && groups.length > 0) {
|
||||
setGroup(groups[0].group_id);
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ErrorMessage />;
|
||||
}
|
||||
|
||||
const displayLogsViewer = project && environment && group;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LinkBack href='/admin/retraced/projects' />
|
||||
<div className='mb-2 mt-5 flex items-center justify-between'>
|
||||
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>{project?.name}</h2>
|
||||
</div>
|
||||
<div className='flex space-x-2'>
|
||||
<div className='form-control max-w-xs'>
|
||||
<label className='label pl-0'>
|
||||
<span className='label-text'>Environment</span>
|
||||
</label>
|
||||
{project ? (
|
||||
<Select
|
||||
value={environment}
|
||||
onChange={(env) => {
|
||||
setEnvironment(env);
|
||||
setGroup('');
|
||||
}}>
|
||||
{project!.environments.map((environment) => (
|
||||
<option key={environment.id} value={environment.id}>
|
||||
{environment.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='form-control max-w-xs'>
|
||||
<label className='label pl-0'>
|
||||
<span className='label-text'>Tenants</span>
|
||||
</label>
|
||||
{groups ? (
|
||||
<Select
|
||||
value={group}
|
||||
onChange={(group) => {
|
||||
setGroup(group);
|
||||
}}>
|
||||
{groups!.map((group) => (
|
||||
<option key={group.group_id} value={group.group_id}>
|
||||
{group.name ? group.name : group.group_id}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
{displayLogsViewer && <LogsViewer project={project} environmentId={environment} groupId={group} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ['common'])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default Events;
|
|
@ -0,0 +1,44 @@
|
|||
import type { NextPage } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import ProjectDetails from '@components/retraced/ProjectDetails';
|
||||
import { useProject } from '@lib/ui/retraced';
|
||||
import Loading from '@components/Loading';
|
||||
import ErrorMessage from '@components/Error';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
|
||||
const ProjectInfo: NextPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { id: projectId } = router.query;
|
||||
|
||||
const { project, isError, isLoading } = useProject(projectId as string);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ErrorMessage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LinkBack href='/admin/retraced/projects' />
|
||||
<div className='mb-2 mt-5 flex items-center justify-between'>
|
||||
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>{project?.name}</h2>
|
||||
</div>
|
||||
{project && <ProjectDetails project={project} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ['common'])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default ProjectInfo;
|
|
@ -0,0 +1,107 @@
|
|||
import type { NextPage } from 'next';
|
||||
import { DocumentMagnifyingGlassIcon, PlusIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
import EmptyState from '@components/EmptyState';
|
||||
import { useProjects } from '@lib/ui/retraced';
|
||||
import Loading from '@components/Loading';
|
||||
import ErrorMessage from '@components/Error';
|
||||
import { IconButton } from '@components/IconButton';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import router from 'next/router';
|
||||
import { LinkPrimary } from '@components/LinkPrimary';
|
||||
|
||||
const ProjectList: NextPage = () => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { projects, isError, isLoading } = useProjects();
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ErrorMessage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-5 flex items-center justify-between'>
|
||||
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>Projects</h2>
|
||||
<LinkPrimary Icon={PlusIcon} href={'/admin/retraced/projects/new'}>
|
||||
{t('new_project')}
|
||||
</LinkPrimary>
|
||||
</div>
|
||||
{projects?.length === 0 ? (
|
||||
<EmptyState title='No projects found.' href='/admin/retraced/projects/new' />
|
||||
) : (
|
||||
<>
|
||||
<div className='rounder border'>
|
||||
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
|
||||
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
|
||||
<tr>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Name
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Id
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Created At
|
||||
</th>
|
||||
<th scope='col' className='px-6 py-3'>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects?.map((project) => (
|
||||
<tr key={project.id} className='border-b bg-white dark:border-gray-700 dark:bg-gray-800'>
|
||||
<td className='whitespace-nowrap px-6 py-3 text-sm text-gray-500 dark:text-gray-400'>
|
||||
{project.name}
|
||||
</td>
|
||||
<td className='whitespace-nowrap px-6 py-3 text-sm text-gray-500 dark:text-gray-400'>
|
||||
{project.id}
|
||||
</td>
|
||||
<td className='whitespace-nowrap px-6 py-3 text-sm text-gray-500 dark:text-gray-400'>
|
||||
{project.created}
|
||||
</td>
|
||||
<td className='px-6 py-3'>
|
||||
<span className='inline-flex items-baseline'>
|
||||
<IconButton
|
||||
tooltip={t('configuration')}
|
||||
Icon={WrenchScrewdriverIcon}
|
||||
className='mr-3 hover:text-green-400'
|
||||
onClick={() => {
|
||||
router.push(`/admin/retraced/projects/${project.id}`);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
tooltip={t('view_events')}
|
||||
Icon={DocumentMagnifyingGlassIcon}
|
||||
className='mr-3 hover:text-green-400'
|
||||
onClick={() => {
|
||||
router.push(`/admin/retraced/projects/${project.id}/events`);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ['common'])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default ProjectList;
|
|
@ -0,0 +1,17 @@
|
|||
import type { NextPage } from 'next';
|
||||
import AddProject from '@components/retraced/AddProject';
|
||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||
|
||||
const NewProject: NextPage = () => {
|
||||
return <AddProject />;
|
||||
};
|
||||
|
||||
export async function getServerSideProps({ locale }) {
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ['common'])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default NewProject;
|
|
@ -0,0 +1,69 @@
|
|||
import Chance from 'chance';
|
||||
import * as Retraced from '@retraced-hq/retraced';
|
||||
import { retracedOptions } from '@lib/env';
|
||||
import { checkSession } from '@lib/middleware';
|
||||
|
||||
const chance = new Chance();
|
||||
|
||||
const actions = [
|
||||
'license.update',
|
||||
'spline.reticulate',
|
||||
'user.login',
|
||||
'release.promote',
|
||||
'wozniak.bore',
|
||||
'page.load',
|
||||
];
|
||||
|
||||
const ips = ['192.168.1.1', '200.168.1.10', '12.18.12.13', '92.68.51.21'];
|
||||
|
||||
async function handler(req, res) {
|
||||
const actor_id = chance.guid();
|
||||
const actor_name = chance.name();
|
||||
|
||||
// use a random action
|
||||
const randomAction = actions[chance.integer({ min: 0, max: actions.length - 1 })];
|
||||
|
||||
// use random ips
|
||||
const randomIPs = ips[chance.integer({ min: 0, max: ips.length - 1 })];
|
||||
const { token, project } = req.query;
|
||||
|
||||
const retraced = new Retraced.Client({
|
||||
apiKey: (token as string) || 'dev',
|
||||
projectId: (project as string) || 'dev',
|
||||
endpoint: retracedOptions?.host,
|
||||
viewLogAction: 'audit.log.view',
|
||||
});
|
||||
|
||||
const team_id = req.query.group_id || 'dev';
|
||||
|
||||
// Report an event on every page load
|
||||
retraced.reportEvent({
|
||||
crud: 'u',
|
||||
action: randomAction,
|
||||
description: 'user <anonymous> reticulated the splines',
|
||||
created: new Date(),
|
||||
actor: {
|
||||
id: actor_id,
|
||||
name: actor_name,
|
||||
},
|
||||
group: {
|
||||
id: team_id,
|
||||
name: team_id,
|
||||
},
|
||||
sourceIp: randomIPs,
|
||||
});
|
||||
|
||||
// Get A viewer token and send it to the client
|
||||
// the client will use this token to initialize the viewer
|
||||
console.log('Requesting viewer token for team', team_id);
|
||||
|
||||
retraced
|
||||
.getViewerToken(team_id, '', true)
|
||||
.then((t) => res.send(JSON.stringify({ token: t, host: `${retracedOptions?.host}/viewer/v1` })))
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
res.status(500).send({ error: 'An Unexpected Error Occured' });
|
||||
});
|
||||
}
|
||||
|
||||
export default checkSession(handler);
|
|
@ -0,0 +1,49 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import axios from 'axios';
|
||||
|
||||
import { getToken } from '@lib/retraced';
|
||||
import { retracedOptions } from '@lib/env';
|
||||
import { checkSession } from '@lib/middleware';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return getGroups(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({
|
||||
data: null,
|
||||
error: { message: `Method ${method} Not Allowed` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const getGroups = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const token = await getToken(req);
|
||||
|
||||
const { id: projectId, environmentId } = req.query;
|
||||
|
||||
const { data } = await axios.get(
|
||||
`${retracedOptions?.host}/admin/v1/project/${projectId}/groups?environment_id=${environmentId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `id=${token.id} token=${token.token} admin_token=${retracedOptions.adminToken}`,
|
||||
},
|
||||
data: {
|
||||
query: {
|
||||
length: 10,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
data,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
export default checkSession(handler);
|
|
@ -0,0 +1,41 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import axios from 'axios';
|
||||
|
||||
import type { Project } from 'types/retraced';
|
||||
import { getToken } from '@lib/retraced';
|
||||
import { retracedOptions } from '@lib/env';
|
||||
import { checkSession } from '@lib/middleware';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return getProject(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({
|
||||
data: null,
|
||||
error: { message: `Method ${method} Not Allowed` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const getProject = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const token = await getToken(req);
|
||||
|
||||
const { id } = req.query;
|
||||
|
||||
const { data } = await axios.get<{ project: Project }>(`${retracedOptions?.host}/admin/v1/project/${id}`, {
|
||||
headers: {
|
||||
Authorization: `id=${token.id} token=${token.token} admin_token=${retracedOptions.adminToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
data,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
export default checkSession(handler);
|
|
@ -0,0 +1,62 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import * as Retraced from '@retraced-hq/retraced';
|
||||
import requestIp from 'request-ip';
|
||||
|
||||
import { retracedOptions } from '@lib/env';
|
||||
import { checkSession } from '@lib/middleware';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return getViewerToken(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).json({
|
||||
data: null,
|
||||
error: { message: `Method ${method} Not Allowed` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get A viewer token and send it to the client, the client will use this token to initialize the logs-viewer
|
||||
const getViewerToken = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { id: projectId, groupId, token } = req.query;
|
||||
|
||||
const retraced = new Retraced.Client({
|
||||
apiKey: token as string,
|
||||
projectId: projectId as string,
|
||||
endpoint: retracedOptions?.host,
|
||||
viewLogAction: 'audit.log.view',
|
||||
});
|
||||
const reqIp = requestIp.getClientIp(req);
|
||||
const ip = reqIp == '::1' ? '127.0.0.1' : reqIp;
|
||||
|
||||
retraced.reportEvent({
|
||||
crud: 'r',
|
||||
action: 'audit.log.view',
|
||||
description: 'Admin UI accessed the audit logs.',
|
||||
created: new Date(),
|
||||
actor: {
|
||||
id: 'Admin-UI',
|
||||
name: 'Admin-UI',
|
||||
},
|
||||
group: {
|
||||
id: groupId as string,
|
||||
name: groupId as string,
|
||||
},
|
||||
sourceIp: ip,
|
||||
});
|
||||
|
||||
const viewerToken = await retraced.getViewerToken(groupId as string, 'Admin-UI', true);
|
||||
|
||||
return res.status(200).json({
|
||||
data: {
|
||||
viewerToken,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
export default checkSession(handler);
|
|
@ -0,0 +1,64 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import axios from 'axios';
|
||||
|
||||
import type { Project } from 'types/retraced';
|
||||
import { getToken } from '@lib/retraced';
|
||||
import { retracedOptions } from '@lib/env';
|
||||
import { checkSession } from '@lib/middleware';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req;
|
||||
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return getProjects(req, res);
|
||||
case 'POST':
|
||||
return createProject(req, res);
|
||||
default:
|
||||
res.setHeader('Allow', ['GET', 'POST']);
|
||||
res.status(405).json({
|
||||
data: null,
|
||||
error: { message: `Method ${method} Not Allowed` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const createProject = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const token = await getToken(req);
|
||||
|
||||
const { name } = req.body;
|
||||
|
||||
const { data } = await axios.post<{ project: Project }>(
|
||||
`${retracedOptions?.host}/admin/v1/project`,
|
||||
{
|
||||
name,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `id=${token.id} token=${token.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
data,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
const getProjects = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const token = await getToken(req);
|
||||
|
||||
const { data } = await axios.get<{ projects: Project[] }>(`${retracedOptions?.host}/admin/v1/projects`, {
|
||||
headers: {
|
||||
Authorization: `id=${token.id} token=${token.token} admin_token=${retracedOptions.adminToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
data,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
export default checkSession(handler);
|
|
@ -50,3 +50,64 @@ a {
|
|||
@apply rounded;
|
||||
}
|
||||
}
|
||||
|
||||
.react-datepicker {
|
||||
background-color: #fff !important;
|
||||
color: #000 !important;
|
||||
border: 1px solid #aeaeae !important;
|
||||
border-radius: 0.3rem !important;
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
z-index: 100;
|
||||
border: 1px solid #aeaeae;
|
||||
}
|
||||
|
||||
.react-datepicker__day-name {
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day {
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
.react-datepicker__current-month {
|
||||
font-weight: bold !important;
|
||||
font-size: 0.944rem !important;
|
||||
}
|
||||
|
||||
.SearchEvents {
|
||||
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.CustomCheckbox {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.Input {
|
||||
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.react-datepicker__navigation {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
span.react-datepicker__navigation-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.react-datepicker__header {
|
||||
border-bottom: 1px solid #aeaeae !important;
|
||||
border-top-left-radius: 0.3rem !important;
|
||||
border-top-right-radius: 0.3rem !important;
|
||||
padding-top: 8px !important;
|
||||
}
|
||||
|
||||
.react-datepicker__month {
|
||||
margin: 0.4rem !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.ReactModalPortal {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"target": "es5",
|
||||
"sourceMap": true,
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
export type ApiError = {
|
||||
code?: string;
|
||||
message: string;
|
||||
values: { [key: string]: string };
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
data: T | null;
|
||||
error: ApiError | null;
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export * from './retraced';
|
||||
export * from './base';
|
|
@ -0,0 +1,34 @@
|
|||
export type AdminToken = {
|
||||
id: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export type APIKey = {
|
||||
name: string;
|
||||
created: string;
|
||||
disabled: boolean;
|
||||
environment_id: string;
|
||||
project_id: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type Environment = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
created: string;
|
||||
environments: Environment[];
|
||||
tokens: APIKey[];
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type Group = {
|
||||
group_id: string;
|
||||
name: string;
|
||||
};
|
Loading…
Reference in New Issue