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:
Utkarsh Mehta 2022-12-30 22:32:16 +05:30 committed by GitHub
parent 5b9664433d
commit 0d7fac092b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 8378 additions and 487 deletions

View File

@ -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=

View File

@ -61,5 +61,3 @@ EXPOSE 5225
ENV PORT 5225
CMD ["node", "server.js"]

5
components/Error.tsx Normal file
View File

@ -0,0 +1,5 @@
const ErrorMessage = () => {
return <p>{`Unable to load this page. Maybe you don't have enough rights.`}</p>;
};
export default ErrorMessage;

View File

@ -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>

View File

@ -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 (

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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:

View File

@ -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 };

32
lib/retraced.ts Normal file
View File

@ -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;
};

51
lib/ui/retraced.ts Normal file
View File

@ -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,
};
};

View File

@ -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"
}

2
npm/package-lock.json generated
View File

@ -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",

View File

@ -339,6 +339,10 @@ export interface JacksonOption {
privateKey: string;
};
boxyhqLicenseKey?: string;
retraced?: {
host?: string;
adminToken?: string;
};
noAnalytics?: boolean;
}

7739
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
},

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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;
}

View File

@ -2,6 +2,7 @@
"compilerOptions": {
"sourceMap": true,
"target": "es5",
"sourceMap": true,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

10
types/base.ts Normal file
View File

@ -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;
};

2
types/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './retraced';
export * from './base';

34
types/retraced.ts Normal file
View File

@ -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;
};