3 Commits

Author SHA1 Message Date
8086f6880d feat: Update auth flow and add third party auth
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-07-17 10:21:26 -07:00
a55248774c fix: login bug when both anonymous and auth is enabled
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-06-16 13:46:55 +03:00
936590d822 feat:Integration tests
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-05-30 10:54:24 +03:00
48 changed files with 2855 additions and 16086 deletions

View File

@ -111,7 +111,19 @@ jobs:
regctl registry set --tls disabled $REGISTRY_HOST:$REGISTRY_PORT
make test-data REGISTRY_HOST=$REGISTRY_HOST REGISTRY_PORT=$REGISTRY_PORT
- name: Install playwright dependencies
run: |
cd $GITHUB_WORKSPACE
make playwright-browsers
- name: Run integration tests
run: |
cd $GITHUB_WORKSPACE
make integration-tests REGISTRY_HOST=$REGISTRY_HOST REGISTRY_PORT=$REGISTRY_PORT
- name: Upload playwright report
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
retention-days: 30

5
.gitignore vendored
View File

@ -129,3 +129,8 @@ dist
# TernJS port file
.tern-port
/test-results/
/playwright-report/
/playwright/.cache/
data.md

View File

@ -30,8 +30,13 @@ test-data:
--registry $(REGISTRY_HOST):$(REGISTRY_PORT) \
--data-dir tests/data \
--config-file tests/data/config.yaml \
--metadata-file tests/data/image_metadata.json
--metadata-file tests/data/image_metadata.json \
-d
.PHONY: playwright-browsers
playwright-browsers:
npx playwright install --with-deps
.PHONY: integration-tests
integration-tests: # Triggering the tests TBD
cat tests/data/image_metadata.json | jq
UI_HOST=$(REGISTRY_HOST):$(REGISTRY_PORT) API_HOST=$(REGISTRY_HOST):$(REGISTRY_PORT) npm run test:ui

17502
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,6 @@
"dependencies": {
"@emotion/react": "^11.9.3",
"@emotion/styled": "^11.9.3",
"@mui-treasury/styles": "^1.13.1",
"@mui/icons-material": "^5.2.5",
"@mui/lab": "^5.0.0-alpha.89",
"@mui/material": "^5.8.6",
@ -18,7 +17,6 @@
"lodash": "^4.17.21",
"luxon": "^2.5.2",
"markdown-to-jsx": "^7.1.7",
"nth-check": "^2.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^6.2.1",
@ -26,18 +24,24 @@
"web-vitals": "^2.1.3"
},
"devDependencies": {
"@playwright/test": "^1.28.1",
"eslint": "^8.23.1",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.8",
"prettier": "^2.7.1",
"react-scripts": "^5.0.1"
"react-scripts": "^5.0.1",
"@babel/plugin-proposal-private-property-in-object": "^7.16.7"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --detectOpenHandles",
"test:coverage": "react-scripts test --detectOpenHandles --coverage",
"test:ui": "playwright test",
"test:ui-headed": "playwright test --headed --trace on",
"test:ui-debug": "playwright test --trace on",
"test:release": "npm run test && npm run test:ui",
"lint": "eslint -c .eslintrc.json --ext .js,.jsx .",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write ./**/*.{js,jsx,ts,tsx,css,md,json} --config ./.prettierrc",

113
playwright.config.js Normal file
View File

@ -0,0 +1,113 @@
// @ts-check
const { devices } = require('@playwright/test');
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* @see https://playwright.dev/docs/test-configuration
* @type {import('@playwright/test').PlaywrightTestConfig}
*/
const config = {
testDir: './tests',
/* Maximum time one test can run for. */
timeout: 50 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 15000
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 2,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [['html', { open: 'never' }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
ignoreHTTPSErrors: true
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
ignoreHTTPSErrors: true
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
ignoreHTTPSErrors: true
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
ignoreHTTPSErrors: true
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// },
};
module.exports = config;

View File

@ -76,4 +76,10 @@
.hide-on-mobile {
display: none;
}
}
@media (max-width: 950px) {
.hide-on-small {
display:none
}
}

View File

@ -1,22 +1,19 @@
import React, { useState } from 'react';
import HomePage from './pages/HomePage.jsx';
import LoginPage from './pages/LoginPage.jsx';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { isAuthenticated } from 'utilities/authUtilities';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import { AuthWrapper } from 'utilities/AuthWrapper';
import RepoPage from 'pages/RepoPage';
import TagPage from 'pages/TagPage';
import ExplorePage from 'pages/ExplorePage';
import './App.css';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthWrapper } from 'utilities/AuthWrapper.jsx';
import RepoPage from 'pages/RepoPage.jsx';
import TagPage from 'pages/TagPage';
import ExplorePage from 'pages/ExplorePage.jsx';
function App() {
const isToken = () => {
const localStorageToken = localStorage.getItem('token');
return localStorageToken ? true : false;
};
const [isLoggedIn, setIsLoggedIn] = useState(isToken());
const [isLoggedIn, setIsLoggedIn] = useState(isAuthenticated());
return (
<div className="App" data-testid="app-container">

View File

@ -1,8 +1,13 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import App from './App';
import MockThemeProvider from './__mocks__/MockThemeProvider';
it('renders the app component', () => {
render(<App />);
render(
<MockThemeProvider>
<App />
</MockThemeProvider>
);
expect(screen.getByTestId('app-container')).toBeInTheDocument();
});

View File

@ -3,8 +3,8 @@ import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme();
function MockThemeProvier({ children }) {
function MockThemeProvider({ children }) {
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
}
export default MockThemeProvier;
export default MockThemeProvider;

View File

@ -6,7 +6,7 @@ import React from 'react';
import { createSearchParams, MemoryRouter } from 'react-router-dom';
import filterConstants from 'utilities/filterConstants.js';
import { sortByCriteria } from 'utilities/sortCriteria.js';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
// router mock
const mockedUsedNavigate = jest.fn();
@ -18,11 +18,11 @@ jest.mock('react-router-dom', () => ({
const StateExploreWrapper = (props) => {
const queryString = props.search || '';
return (
<MockThemeProvier>
<MockThemeProvider>
<MemoryRouter initialEntries={[`/explore?${queryString.toString()}`]}>
<Explore />
</MemoryRouter>
</MockThemeProvier>
</MockThemeProvider>
);
};
const mockImageList = {
@ -236,6 +236,10 @@ beforeEach(() => {
disconnect: () => null
});
window.IntersectionObserver = mockIntersectionObserver;
Object.defineProperty(window.document, 'cookie', {
writable: true,
value: 'user=test'
});
});
afterEach(() => {

View File

@ -2,19 +2,19 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import FilterCard from 'components/Shared/FilterCard';
import React, { useState } from 'react';
import filterConstants from 'utilities/filterConstants';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
const StateFilterCardWrapper = () => {
const [filters, setFilters] = useState([]);
return (
<MockThemeProvier>
<MockThemeProvider>
<FilterCard
title="Operating System"
filters={filterConstants.osFilters}
updateFilters={setFilters}
filterValue={filters}
/>
</MockThemeProvier>
</MockThemeProvider>
);
};

View File

@ -4,7 +4,7 @@ import Home from 'components/Home/Home';
import React from 'react';
import { createSearchParams } from 'react-router-dom';
import { sortByCriteria } from 'utilities/sortCriteria';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
// useNavigate mock
const mockedUsedNavigate = jest.fn();
@ -15,9 +15,9 @@ jest.mock('react-router-dom', () => ({
const HomeWrapper = () => {
return (
<MockThemeProvier>
<MockThemeProvider>
<Home />
</MockThemeProvier>
</MockThemeProvider>
);
};

View File

@ -2,19 +2,22 @@ import { render, screen } from '@testing-library/react';
import LoginPage from 'pages/LoginPage';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import MockThemeProvider from '__mocks__/MockThemeProvider';
it('renders the signin presentation component and signin components if auth enabled', () => {
render(
<BrowserRouter>
<Routes>
<Route
path="*"
element={
<LoginPage isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />
}
/>
</Routes>
</BrowserRouter>
<MockThemeProvider>
<BrowserRouter>
<Routes>
<Route
path="*"
element={
<LoginPage isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />
}
/>
</Routes>
</BrowserRouter>
</MockThemeProvider>
);
expect(screen.getByTestId('login-container')).toBeInTheDocument();
expect(screen.getByTestId('presentation-container')).toBeInTheDocument();

View File

@ -4,6 +4,12 @@ import SignIn from 'components/Login/SignIn';
import { api } from '../../api';
import userEvent from '@testing-library/user-event';
const mockMgmtResponse = {
distSpecVersion: '1.1.0-dev',
binaryType: '-apikey-lint-metrics-mgmt-scrub-search-sync-ui-userprefs',
http: { auth: { htpasswd: {} } }
};
// useNavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
@ -35,7 +41,10 @@ describe('Signin component automatic navigation', () => {
describe('Sign in form', () => {
beforeEach(() => {
// mock auth check request
jest.spyOn(api, 'get').mockRejectedValue({ status: 401, data: {} });
jest.spyOn(api, 'get').mockResolvedValue({
status: 401,
data: mockMgmtResponse
});
});
it('should change username and password values on user input', async () => {
@ -77,7 +86,7 @@ describe('Sign in form', () => {
it('should should display login error if login not successful', async () => {
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
const submitButton = await screen.findByText('Continue');
jest.spyOn(api, 'get').mockRejectedValue();
jest.spyOn(api, 'get').mockRejectedValue({ status: 401, data: {} });
fireEvent.click(submitButton);
const errorDisplay = await screen.findByText(/Authentication Failed/i);
await waitFor(() => {

View File

@ -3,14 +3,14 @@ import RepoDetails from 'components/Repo/RepoDetails';
import React from 'react';
import { api } from 'api';
import { createSearchParams } from 'react-router-dom';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
import userEvent from '@testing-library/user-event';
const RepoDetailsThemeWrapper = () => {
return (
<MockThemeProvier>
<MockThemeProvider>
<RepoDetails />
</MockThemeProvier>
</MockThemeProvider>
);
};
@ -234,6 +234,13 @@ const mockRepoDetailsHigh = {
}
};
beforeEach(() => {
Object.defineProperty(window.document, 'cookie', {
writable: true,
value: 'user=test'
});
});
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();

View File

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
import RepoPage from 'pages/RepoPage';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -27,18 +27,13 @@ afterEach(() => {
it('renders the repository page component', () => {
render(
<BrowserRouter>
<Routes>
<Route
path="*"
element={
<MockThemeProvier>
<RepoPage />
</MockThemeProvier>
}
/>
</Routes>
</BrowserRouter>
<MockThemeProvider>
<BrowserRouter>
<Routes>
<Route path="*" element={<RepoPage />} />
</Routes>
</BrowserRouter>
</MockThemeProvider>
);
expect(screen.getByTestId('repo-container')).toBeInTheDocument();
});

View File

@ -2,13 +2,13 @@ import { fireEvent, waitFor, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Tags from 'components/Repo/Tabs/Tags';
import React from 'react';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
const TagsThemeWrapper = () => {
return (
<MockThemeProvier>
<MockThemeProvider>
<Tags tags={mockedTagsData} />
</MockThemeProvier>
</MockThemeProvider>
);
};

View File

@ -3,7 +3,7 @@ import React from 'react';
import userEvent from '@testing-library/user-event';
import RepoCard from 'components/Shared/RepoCard';
import { createSearchParams } from 'react-router-dom';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
// usenavigate mock
const mockedUsedNavigate = jest.fn();
@ -28,7 +28,7 @@ const mockImage = {
const RepoCardWrapper = (props) => {
const { image } = props;
return (
<MockThemeProvier>
<MockThemeProvider>
<RepoCard
name={image.name}
version={image.latestVersion}
@ -38,7 +38,7 @@ const RepoCardWrapper = (props) => {
lastUpdated={image.lastUpdated}
platforms={image.platforms}
/>
</MockThemeProvier>
</MockThemeProvider>
);
};

View File

@ -3,7 +3,7 @@ import { api } from 'api';
import DependsOn from 'components/Tag/Tabs/DependsOn';
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
const mockDependenciesList = {
data: {
@ -53,13 +53,13 @@ const mockDependenciesList = {
const RouterDependsWrapper = () => {
return (
<MockThemeProvier>
<MockThemeProvider>
<BrowserRouter>
<Routes>
<Route path="*" element={<DependsOn name="alpine:latest" />} />
</Routes>
</BrowserRouter>
</MockThemeProvier>
</MockThemeProvider>
);
};

View File

@ -3,7 +3,7 @@ import { api } from 'api';
import IsDependentOn from 'components/Tag/Tabs/IsDependentOn';
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
const mockDependentsList = {
data: {
@ -53,13 +53,13 @@ const mockDependentsList = {
const RouterDependsWrapper = () => {
return (
<MockThemeProvier>
<MockThemeProvider>
<BrowserRouter>
<Routes>
<Route path="*" element={<IsDependentOn name="alpine:latest" />} />
</Routes>
</BrowserRouter>
</MockThemeProvier>
</MockThemeProvider>
);
};

View File

@ -3,18 +3,18 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { api } from 'api';
import TagDetails from 'components/Tag/TagDetails';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
const TagDetailsThemeWrapper = () => {
return (
<MockThemeProvier>
<MockThemeProvider>
<BrowserRouter>
<Routes>
<Route path="*" element={<TagDetails />} />
</Routes>
</BrowserRouter>
</MockThemeProvier>
</MockThemeProvider>
);
};

View File

@ -1,6 +1,6 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockThemeProvier from '__mocks__/MockThemeProvider';
import MockThemeProvider from '__mocks__/MockThemeProvider';
import { api } from 'api';
import VulnerabilitiesDetails from 'components/Tag/Tabs/VulnerabilitiesDetails';
import React from 'react';
@ -8,11 +8,11 @@ import { MemoryRouter } from 'react-router-dom';
const StateVulnerabilitiesWrapper = () => {
return (
<MockThemeProvier>
<MockThemeProvider>
<MemoryRouter>
<VulnerabilitiesDetails name="mongo" />
</MemoryRouter>
</MockThemeProvier>
</MockThemeProvider>
);
};

View File

@ -1,14 +1,25 @@
import axios from 'axios';
import { isEmpty } from 'lodash';
import { sortByCriteria } from 'utilities/sortCriteria';
import { logoutUser } from 'utilities/authUtilities';
import { host } from 'host';
axios.interceptors.request.use((config) => {
if (config.url.includes(endpoints.authConfig)) {
config.withCredentials = false;
} else {
config.headers['X-ZOT-API-CLIENT'] = 'zot-ui';
}
return config;
});
axios.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response.status === 401) {
localStorage.clear();
if (error?.response?.status === 401) {
logoutUser();
window.location.replace('/login');
return Promise.reject(error);
}
@ -19,24 +30,15 @@ const api = {
getAxiosInstance: () => axios,
getRequestCfg: () => {
const authConfig = JSON.parse(localStorage.getItem('authConfig'));
const genericHeaders = {
Accept: 'application/json',
'Content-Type': 'application/json'
};
const token = localStorage.getItem('token');
if (token && token !== '-') {
const authHeaders = {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Basic ${token}`
};
return {
headers: authHeaders
};
}
// withCredentials option must be enabled on cross-origin
return {
headers: genericHeaders
headers: genericHeaders,
withCredentials: host() !== window?.location?.origin && authConfig !== null
};
},
@ -74,7 +76,10 @@ const api = {
};
const endpoints = {
status: `/v2/`,
authConfig: `/v2/_zot/ext/mgmt`,
openidAuth: `/auth/login`,
logout: `/auth/logout`,
repoList: ({ pageNumber = 1, pageSize = 15 } = {}) =>
`/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize

3
src/assets/GhIcon.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0001 0.296387C5.3735 0.296387 0 5.66889 0 12.2965C0 17.5984 3.43839 22.0966 8.2064 23.6833C8.80613 23.7944 9.0263 23.423 9.0263 23.1061C9.0263 22.8199 9.01518 21.8746 9.01001 20.8719C5.67157 21.5978 4.96712 19.456 4.96712 19.456C4.42125 18.069 3.63473 17.7002 3.63473 17.7002C2.54596 16.9554 3.7168 16.9707 3.7168 16.9707C4.92181 17.0554 5.55632 18.2073 5.55632 18.2073C6.6266 20.0419 8.36359 19.5115 9.04836 19.2049C9.15607 18.4293 9.46706 17.8999 9.81024 17.6002C7.14486 17.2968 4.34295 16.2678 4.34295 11.6697C4.34295 10.3596 4.81172 9.28911 5.57937 8.44874C5.45477 8.14649 5.04402 6.92597 5.69562 5.27305C5.69562 5.27305 6.70331 4.95053 8.9965 6.5031C9.95372 6.23722 10.9803 6.10388 12.0001 6.09931C13.0199 6.10388 14.0473 6.23722 15.0063 6.5031C17.2967 4.95053 18.303 5.27305 18.303 5.27305C18.9562 6.92597 18.5452 8.14649 18.4206 8.44874C19.1901 9.28911 19.6557 10.3596 19.6557 11.6697C19.6557 16.2788 16.8484 17.2936 14.1762 17.5907C14.6067 17.9631 14.9902 18.6934 14.9902 19.8129C14.9902 21.4186 14.9763 22.7108 14.9763 23.1061C14.9763 23.4254 15.1923 23.7996 15.8006 23.6818C20.566 22.0932 24 17.5967 24 12.2965C24 5.66889 18.6273 0.296387 12.0001 0.296387ZM4.49443 17.3908C4.468 17.4504 4.3742 17.4683 4.28876 17.4273C4.20172 17.3882 4.15283 17.3069 4.18105 17.2471C4.20688 17.1857 4.30088 17.1686 4.38772 17.2098C4.47495 17.2489 4.52463 17.331 4.49443 17.3908ZM5.0847 17.9175C5.02747 17.9705 4.91559 17.9459 4.83968 17.862C4.76119 17.7784 4.74648 17.6665 4.80451 17.6126C4.86353 17.5596 4.97203 17.5844 5.05072 17.6681C5.12921 17.7527 5.14451 17.8638 5.0847 17.9175ZM5.48965 18.5914C5.41612 18.6424 5.2959 18.5945 5.22158 18.4878C5.14805 18.3811 5.14805 18.2531 5.22317 18.2019C5.29769 18.1506 5.41612 18.1967 5.49144 18.3026C5.56476 18.4111 5.56476 18.5391 5.48965 18.5914ZM6.1745 19.3718C6.10873 19.4443 5.96863 19.4249 5.86609 19.3259C5.76117 19.2291 5.73196 19.0918 5.79793 19.0193C5.8645 18.9466 6.00539 18.967 6.10873 19.0652C6.21285 19.1618 6.24465 19.3001 6.1745 19.3718ZM7.05961 19.6353C7.0306 19.7293 6.89567 19.772 6.75975 19.7321C6.62402 19.6909 6.5352 19.5808 6.56262 19.4858C6.59084 19.3913 6.72636 19.3467 6.86328 19.3895C6.9988 19.4304 7.08783 19.5397 7.05961 19.6353ZM8.0669 19.747C8.07028 19.846 7.95502 19.9281 7.81235 19.9299C7.66887 19.933 7.55282 19.853 7.55123 19.7556C7.55123 19.6556 7.6639 19.5744 7.80738 19.572C7.95006 19.5692 8.0669 19.6487 8.0669 19.747ZM9.05645 19.7091C9.07354 19.8057 8.97438 19.9048 8.8327 19.9313C8.6934 19.9567 8.56443 19.8971 8.54674 19.8013C8.52945 19.7024 8.6304 19.6032 8.7695 19.5776C8.91139 19.5529 9.03837 19.6109 9.05645 19.7091Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 155.8 73.7" style="enable-background:new 0 0 155.8 73.7;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g id="Layer_2_1_">
</g>
<path class="st0" d="M58,45.8c6,1.1,10.4,2.4,10.4,5.6c0,6.1-11.9,9-23.1,9s-23.1-3.2-23.1-9c0-5.2,11.9-6.6,23.1-6.6h0.1
c0,0-3.1,1.1-6,2.1c-2.5,0.9-7.9,1.9-9.2,2.3c-4.4,1.1-5.3,2.4-5.3,2.6c0,0.3,1,1.5,5.3,2.6c4,1,9.3,1.6,15,1.6s11-0.6,15-1.6
c4.4-1.1,5.3-2.4,5.3-2.6c0-0.3-1-1.5-5.3-2.6c-1.4-0.4-3-0.7-4.8-0.9L58,45.8z"/>
<path class="st0" d="M45.4,31.2c-11.1,0-23.1-2.7-23.1-7.7c0-5.4,11.9-8.1,23.1-8.1s23.1,2.8,23.1,8.1
C68.5,28.5,56.5,31.2,45.4,31.2z M45.4,19.5c-5.7,0-11,0.5-15,1.5c-4.4,1.1-5.3,2.2-5.3,2.5c0,0.2,1,1.4,5.3,2.5c4,1,9.3,1.5,15,1.5
s11-0.5,15-1.5c4.4-1.1,5.3-2.2,5.3-2.5c0-0.2-1-1.4-5.3-2.5C56.4,20.1,51.1,19.5,45.4,19.5z"/>
<path class="st0" d="M65,36.1C49.3,54.5,39.5,56.6,37.3,56.6c-0.1,0-11.9-2.6-11.9-2.6L25,51.7C34.4,50.7,51.6,48,65,36.1z"/>
<path class="st0" d="M48.3,43.7c3.3-1.5,11.8-10.2,10.9-15.4l9.3-4.8C68.5,35.9,51,42.2,48.3,43.7z"/>
<g>
<path class="st0" d="M97.2,33.4L84.1,48.1h13.1v3.5H79.3v-3.9L92.4,33H79.9v-3.4h17.2V33.4z"/>
<path class="st0" d="M100.1,40.6c0-4,1.2-7.1,3.6-9.1c1.9-1.7,4.3-2.5,7.3-2.5c3.4,0,6.1,1.1,8,3.3c1.9,2,2.8,4.8,2.8,8.4
c0,4-1.2,7-3.5,9.1c-1.9,1.7-4.4,2.5-7.4,2.5c-3.4,0-6.1-1.1-8.1-3.4C101.1,46.8,100.1,44,100.1,40.6z M104.4,40.6
c0,2.8,0.7,5,2.2,6.5c1.2,1.2,2.7,1.9,4.5,1.9c2.1,0,3.8-0.8,5-2.4c1.1-1.5,1.6-3.4,1.6-5.9c0-2.9-0.7-5.1-2.1-6.5
c-1.2-1.2-2.7-1.8-4.5-1.8c-2.1,0-3.8,0.8-5,2.4C104.9,36.1,104.4,38.1,104.4,40.6z"/>
<path class="st0" d="M136.3,48.3v3.5c-1,0.3-2,0.4-3,0.4c-2,0-3.6-0.6-4.6-1.9c-0.9-1-1.3-2.4-1.3-4.2V33h-3.3v-3.5h3.3l0.3-5.3
h3.8v5.3h4.7V33h-4.7v13.2c0,1.6,0.8,2.5,2.3,2.5C134.6,48.6,135.4,48.5,136.3,48.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,17 +1,18 @@
// react global
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
// components
import { AppBar, Toolbar, Grid } from '@mui/material';
import { isAuthenticated, isAuthenticationEnabled, logoutUser } from '../../utilities/authUtilities';
// components
import { AppBar, Toolbar, Grid, Button } from '@mui/material';
import SearchSuggestion from './SearchSuggestion';
import UserAccountMenu from './UserAccountMenu';
// styling
import makeStyles from '@mui/styles/makeStyles';
import logo from '../../assets/zotLogoWhite.svg';
import logoxs from '../../assets/zotLogoWhiteSmall.svg';
import githubLogo from '../../assets/Git.png';
import { useState, useEffect } from 'react';
import SearchSuggestion from './SearchSuggestion';
const useStyles = makeStyles((theme) => ({
barOpen: {
@ -130,6 +131,10 @@ function Header({ setSearchCurrentValue = () => {} }) {
const classes = useStyles();
const path = useLocation().pathname;
const handleSignInClick = () => {
logoutUser();
};
return (
<AppBar position={show ? 'fixed' : 'absolute'} sx={{ height: '5rem' }}>
<Toolbar className={classes.header}>
@ -168,6 +173,18 @@ function Header({ setSearchCurrentValue = () => {} }) {
<img alt="github repository" src={githubLogo} className={classes.logo} />
</a>
</Grid>
{isAuthenticated() && isAuthenticationEnabled() && (
<Grid item>
<UserAccountMenu />
</Grid>
)}
{!isAuthenticated() && isAuthenticationEnabled() && (
<Grid item>
<Button className={classes.signInBtn} onClick={handleSignInClick}>
Sign in
</Button>
</Grid>
)}
</Grid>
</Grid>
</Toolbar>

View File

@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { Menu, MenuItem, IconButton, Avatar, Divider } from '@mui/material';
import { getLoggedInUser, logoutUser } from '../../utilities/authUtilities';
function UserAccountMenu() {
const [anchorEl, setAnchorEl] = useState(null);
const openMenu = Boolean(anchorEl);
const handleUserClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleUserClose = () => {
setAnchorEl(null);
};
return (
<>
<IconButton
onClick={handleUserClick}
size="small"
aria-controls={open ? 'account-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
>
<Avatar sx={{ width: 32, height: 32 }} />
</IconButton>
<Menu
anchorEl={anchorEl}
open={openMenu}
onClose={handleUserClose}
onClick={handleUserClose}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem onClick={handleUserClose}>{getLoggedInUser()}</MenuItem>
<Divider />
<MenuItem onClick={logoutUser}>Log out</MenuItem>
</Menu>
</>
);
}
export default UserAccountMenu;

View File

@ -1,33 +1,35 @@
// react global
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { host } from '../../host';
// utility
import { api, endpoints } from '../../api';
import { isEmpty } from 'lodash';
import { host } from '../../host';
import { isEmpty, isObject } from 'lodash';
// components
import { Card, CardContent, CssBaseline } from '@mui/material';
import Button from '@mui/material/Button';
import CssBaseline from '@mui/material/CssBaseline';
import TextField from '@mui/material/TextField';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';
import CircularProgress from '@mui/material/CircularProgress';
import TermsOfService from './TermsOfService';
import Loading from '../Shared/Loading';
import { GoogleLoginButton, GithubLoginButton, DexLoginButton } from './ThirdPartyLoginComponents';
// styling
import { makeStyles } from '@mui/styles';
import { Card, CardContent } from '@mui/material';
import Loading from '../Shared/Loading';
const useStyles = makeStyles(() => ({
cardContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto',
padding: '0.625rem',
position: 'relative'
},
loginCard: {
@ -35,45 +37,82 @@ const useStyles = makeStyles(() => ({
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
marginTop: '20%',
width: '60%',
height: '60%',
background: '#FFFFFF',
gap: '0.625em',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
borderRadius: '1.5rem',
borderRadius: '0.75rem',
minWidth: '30rem'
},
loginCardContent: {
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
flexDirection: 'column',
border: '0.1875rem black',
maxWidth: '73%',
height: '90%'
width: '100%',
padding: '3rem'
},
text: {
color: '#14191F',
width: '100%',
fontSize: '1.5rem'
fontSize: '1.5rem',
lineHeight: '2.25rem',
letterSpacing: '-0.01rem',
marginBottom: '0.25rem'
},
subtext: {
color: '#52637A',
width: '100%',
fontSize: '1rem'
fontSize: '1rem',
marginBottom: '2.375rem'
},
textField: {
borderRadius: '0.25rem'
borderRadius: '0.25rem',
marginTop: 0,
marginBottom: '1.5rem'
},
button: {
textColor: {
color: '#8596AD'
},
labelColor: {
color: '#667C99',
'&:focused': {
color: '#667C99'
}
},
continueButton: {
textTransform: 'none',
color: '##FFFFFF',
fontSize: '1.4375rem',
fontWeight: '500',
background: '#F15527',
color: '#FFFFFF',
fontSize: '1.438rem',
fontWeight: '600',
height: '3.125rem',
borderRadius: '0.25rem',
letterSpacing: '0.01rem'
letterSpacing: '0.01rem',
marginBottom: '1rem',
padding: 0,
boxShadow: 'none',
'&:hover': {
backgroundColor: '#F15527',
boxShadow: 'none'
}
},
continueAsGuestButton: {
textTransform: 'none',
background: '#FFFFFF',
color: '#52637A',
fontSize: '1.438rem',
fontWeight: '600',
height: '3.125rem',
borderRadius: '0.25rem',
border: '1px solid #52637A',
letterSpacing: '0.01rem',
marginBottom: '1rem',
padding: 0,
boxShadow: 'none',
'&:hover': {
backgroundColor: '#FFFFFF',
boxShadow: 'none'
}
},
gitLogo: {
height: '24px',
@ -95,6 +134,15 @@ const useStyles = makeStyles(() => ({
fontWeight: '400',
paddingLeft: '1rem',
paddingRight: '1rem'
},
divider: {
color: '#C2CBD6',
marginBottom: '2rem',
width: '100%'
},
thirdPartyLoginContainer: {
width: '100%',
marginBottom: '2rem'
}
}));
@ -106,6 +154,8 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
const [requestProcessing, setRequestProcessing] = useState(false);
const [requestError, setRequestError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [authMethods, setAuthMethods] = useState({});
const [isGuestLoginEnabled, setIsGuestLoginEnabled] = useState(false);
const abortController = useMemo(() => new AbortController(), []);
const navigate = useNavigate();
const classes = useStyles();
@ -121,14 +171,28 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
.get(`${host()}${endpoints.authConfig}`, abortController.signal)
.then((response) => {
if (response.data?.http && isEmpty(response.data?.http?.auth)) {
localStorage.setItem('token', '-');
localStorage.setItem('authConfig', '{}');
setIsLoggedIn(true);
navigate('/home');
} else if (response.data?.http?.auth) {
setAuthMethods(response.data?.http?.auth);
localStorage.setItem('authConfig', JSON.stringify(response.data?.http?.auth));
setIsLoading(false);
wrapperSetLoading(false);
api
.get(`${host()}${endpoints.status}`)
.then((response) => {
if (response.status === 200) {
setIsGuestLoginEnabled(true);
}
})
.catch(() => console.log('could not obtain guest login status'));
}
setIsLoading(false);
wrapperSetLoading(false);
})
.catch(() => {
.catch((e) => {
console.error(e);
setIsLoading(false);
wrapperSetLoading(false);
});
@ -138,22 +202,20 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
};
}, []);
const handleClick = (event) => {
event.preventDefault();
const handleBasicAuth = () => {
setRequestProcessing(true);
let cfg = {};
const token = btoa(username + ':' + password);
cfg = {
headers: {
Authorization: `Basic ${token}`
}
},
withCredentials: host() !== window?.location?.origin
};
api
.get(`${host()}/v2/`, abortController.signal, cfg)
.then((response) => {
if (response.status === 200) {
const token = btoa(username + ':' + password);
localStorage.setItem('token', token);
setRequestProcessing(false);
setRequestError(false);
setIsLoggedIn(true);
@ -166,11 +228,34 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
});
};
const handleClick = (event) => {
event.preventDefault();
if (Object.keys(authMethods).includes('htpasswd')) {
handleBasicAuth();
}
};
const handleGuestClick = () => {
setRequestProcessing(false);
setRequestError(false);
setIsLoggedIn(true);
navigate('/home');
};
const handleClickExternalLogin = (event, provider) => {
event.preventDefault();
window.location.replace(
`${host()}${endpoints.openidAuth}?callback_ui=${encodeURIComponent(
window?.location?.origin
)}/home&provider=${provider}`
);
};
const handleChange = (event, type) => {
event.preventDefault();
setRequestError(false);
const val = event.target.value;
const val = event.target?.value;
const isEmpty = val === '';
switch (type) {
@ -195,8 +280,24 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
}
};
const renderThirdPartyLoginMethods = () => {
let isGoogle = isObject(authMethods.openid?.providers?.google);
// let isGitlab = isObject(authMethods.openid?.providers?.gitlab);
let isGithub = isObject(authMethods.openid?.providers?.github);
let isDex = isObject(authMethods.openid?.providers?.dex);
return (
<Stack direction="column" spacing="1rem" className={classes.thirdPartyLoginContainer}>
{isGithub && <GithubLoginButton handleClick={handleClickExternalLogin} />}
{isGoogle && <GoogleLoginButton handleClick={handleClickExternalLogin} />}
{/* {isGitlab && <GitlabLoginButton handleClick={handleClickExternalLogin} />} */}
{isDex && <DexLoginButton handleClick={handleClickExternalLogin} />}
</Stack>
);
};
return (
<Box className={classes.cardContainer} data-testid="signin-container">
<div className={classes.cardContainer} data-testid="signin-container">
{isLoading ? (
<Loading />
) : (
@ -204,68 +305,70 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
<CardContent className={classes.loginCardContent}>
<CssBaseline />
<Typography align="left" className={classes.text} component="h1" variant="h4">
Sign in
Sign In
</Typography>
<Typography align="left" className={classes.subtext} variant="body1" gutterBottom>
Welcome back! Please enter your details.
</Typography>
<Box component="form" onSubmit={null} noValidate autoComplete="off" sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="username"
label="Username"
name="username"
className={classes.textField}
onInput={(e) => handleChange(e, 'username')}
error={usernameError != null}
helperText={usernameError}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Enter password"
type="password"
id="password"
className={classes.textField}
onInput={(e) => handleChange(e, 'password')}
error={passwordError != null}
helperText={passwordError}
/>
{requestProcessing && <CircularProgress style={{ marginTop: 20 }} color="secondary" />}
{requestError && (
<Alert style={{ marginTop: 20 }} severity="error">
Authentication Failed. Please try again.
</Alert>
)}
<div>
<Button
{renderThirdPartyLoginMethods()}
{Object.keys(authMethods).length > 1 && <Divider className={classes.divider}>or</Divider>}
{Object.keys(authMethods).includes('htpasswd') && (
<Box component="form" onSubmit={null} noValidate autoComplete="off">
<TextField
margin="normal"
required
fullWidth
variant="contained"
className={classes.button}
sx={{
mt: 3,
mb: 1,
background: '#1479FF',
'&:hover': {
backgroundColor: '#1565C0'
}
}}
onClick={handleClick}
>
{' '}
Continue
</Button>
</div>
</Box>
<TermsOfService sx={{ mt: 2, mb: 4 }} />
id="username"
label="Username"
name="username"
className={classes.textField}
inputProps={{ className: classes.textColor }}
InputLabelProps={{ className: classes.labelColor }}
onInput={(e) => handleChange(e, 'username')}
error={usernameError != null}
helperText={usernameError}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Enter password"
type="password"
id="password"
className={classes.textField}
inputProps={{ className: classes.textColor }}
InputLabelProps={{ className: classes.labelColor }}
onInput={(e) => handleChange(e, 'password')}
error={passwordError != null}
helperText={passwordError}
/>
{requestProcessing && <CircularProgress style={{ marginTop: 20 }} color="secondary" />}
{requestError && (
<Alert style={{ marginTop: 20 }} severity="error">
Authentication Failed. Please try again.
</Alert>
)}
<div>
<Button fullWidth variant="contained" className={classes.continueButton} onClick={handleClick}>
Continue
</Button>
</div>
</Box>
)}
{isGuestLoginEnabled && (
<Button
fullWidth
variant="contained"
className={classes.continueAsGuestButton}
onClick={handleGuestClick}
>
Continue as guest
</Button>
)}
</CardContent>
</Card>
)}
</Box>
</div>
);
}

View File

@ -1,55 +1,52 @@
import React from 'react';
import { Stack, Typography } from '@mui/material';
import { makeStyles } from '@mui/styles';
import React from 'react';
import logoWhite from '../../assets/Zot-white.svg';
import loginDrawing from '../../assets/codeReviewSignIn.png';
import backgroundImage from '../../assets/backgroundSignIn.png';
const useStyles = makeStyles(() => ({
import logoWhite from '../../assets/zotLogoWhiteHorizontal.svg';
const useStyles = makeStyles((theme) => ({
container: {
backgroundImage: `url(${backgroundImage})`,
backgroundSize: 'cover',
backgroundColor: theme.palette.secondary.main,
minHeight: '100%',
alignItems: 'center'
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
},
contentContainer: {
width: '51%',
height: '22%'
},
logoContainer: {
width: '100%',
display: 'flex',
justifyContent: 'center'
},
logo: {
maxHeight: 96,
maxWidth: 320,
marginTop: '17%'
},
loginDrawing: {
maxHeight: 298,
maxWidth: 464,
marginTop: '4%'
width: '64%'
},
mainText: {
color: '#FFFFFF',
fontWeight: 700,
maxWidth: '45%',
marginTop: '4%',
fontSize: '2.5rem'
},
captionText: {
color: 'rgba(255, 255, 255, 0.7)',
maxWidth: '48%',
marginTop: '2%',
fontSize: '1.1875rem'
color: '#F6F7F9',
fontWeight: '700',
width: '100%',
fontSize: '2.5rem',
lineHeight: '3rem'
}
}));
export default function SigninPresentation() {
const classes = useStyles();
return (
<Stack spacing={0} className={classes.container} data-testid="presentation-container">
<img src={logoWhite} alt="zot logo" className={classes.logo}></img>
<Typography variant="h2" className={classes.mainText}>
Welcome to our repository
</Typography>
<Typography variant="body1" className={classes.captionText}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Amet, dis pellentesque posuere nulla tortor ac eu arcu
nunc.
</Typography>
<img src={loginDrawing} alt="drawing" className={classes.loginDrawing}></img>
</Stack>
<div className={classes.container}>
<Stack spacing={'3rem'} className={classes.contentContainer} data-testid="presentation-container">
<div className={classes.logoContainer}>
<img src={logoWhite} alt="zot logo" className={classes.logo}></img>
</div>
<Typography variant="h2" className={classes.mainText}>
OCI-native container image registry, simplified
</Typography>
</Stack>
</div>
);
}

View File

@ -26,8 +26,8 @@ export default function TermsOfService(props) {
return (
<Stack spacing={0}>
<Typography variant="caption" className={classes.subtext} align="justify" {...props} pb={6}>
By creating an account, you agree to the Terms of Service. For more information about our privacy practices, see
the ZOT&apos;s Privacy Policy.
By using zot UI, you agree to the Terms of Service. For more information about our privacy practices, see
zot&apos;s Privacy Policy.
</Typography>
<Typography variant="caption" className={classes.text} align="center" {...props}>
Privacy Policy | Terms of Service

View File

@ -0,0 +1,93 @@
import React from 'react';
import Button from '@mui/material/Button';
import SvgIcon from '@mui/material/SvgIcon';
import githubLogo from '../../assets/GhIcon.svg';
// styling
import { makeStyles } from '@mui/styles';
const useStyles = makeStyles(() => ({
githubButton: {
textTransform: 'none',
background: '#161614',
color: '#FFFFFF',
borderRadius: '0.25rem',
padding: 0,
height: '3.125rem',
boxShadow: 'none',
'&:hover': {
backgroundColor: '#161614',
boxShadow: 'none'
}
},
googleButton: {
textTransform: 'none',
background: '#FFFFFF',
color: '#52637A',
borderRadius: '0.25rem',
border: '1px solid #52637A',
padding: 0,
height: '3.125rem',
boxShadow: 'none',
'&:hover': {
backgroundColor: '#FFFFFF',
boxShadow: 'none'
}
},
buttonsText: {
lineHeight: '2.125rem',
height: '2.125rem',
fontSize: '1.438rem',
fontWeight: '600',
letterSpacing: '0.01rem'
}
}));
function GithubLoginButton({ handleClick }) {
const classes = useStyles();
return (
<Button
fullWidth
variant="contained"
className={classes.githubButton}
endIcon={<SvgIcon fontSize="medium">{githubLogo}</SvgIcon>}
onClick={(e) => handleClick(e, 'github')}
>
<span className={classes.buttonsText}>Continue with Github</span>
</Button>
);
}
function GoogleLoginButton({ handleClick }) {
const classes = useStyles();
return (
<Button fullWidth variant="contained" className={classes.googleButton} onClick={(e) => handleClick(e, 'google')}>
<span className={classes.buttonsText}>Continue with Google</span>
</Button>
);
}
function GitlabLoginButton({ handleClick }) {
const classes = useStyles();
return (
<Button fullWidth variant="contained" className={classes.button} onClick={(e) => handleClick(e, 'gitlab')}>
Sign in with Gitlab
</Button>
);
}
function DexLoginButton({ handleClick }) {
const classes = useStyles();
return (
<Button fullWidth variant="contained" className={classes.button} onClick={(e) => handleClick(e, 'dex')}>
Sign in with Dex
</Button>
);
}
export { GithubLoginButton, GoogleLoginButton, GitlabLoginButton, DexLoginButton };

View File

@ -15,6 +15,11 @@ const useStyles = makeStyles(() => ({
minHeight: '100vh',
backgroundColor: '#F6F7F9'
},
signinContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
},
loadingHidden: {
display: 'none'
}
@ -27,10 +32,16 @@ function LoginPage({ isLoggedIn, setIsLoggedIn }) {
return (
<Grid container spacing={0} className={classes.container} data-testid="login-container">
{isLoading && <Loading />}
<Grid item xs={6} className={isLoading ? classes.loadingHidden : ''}>
<Grid item xs={1} md={6} className={`${isLoading ? classes.loadingHidden : ''} hide-on-small`}>
<SigninPresentation />
</Grid>
<Grid item xs={6} className={isLoading ? classes.loadingHidden : ''}>
<Grid
item
xs={12}
sm={12}
md={6}
className={`${classes.signinContainer} ${isLoading ? classes.loadingHidden : ''}`}
>
<SignIn isLoggedIn={isLoggedIn} setIsLoggedIn={setIsLoggedIn} wrapperSetLoading={setIsLoading} />
</Grid>
</Grid>

View File

@ -1,5 +1,50 @@
const isAuthenticated = () => {
return localStorage.getItem('token') !== '-';
import { isNil } from 'lodash';
import { host } from '../host';
import { api, endpoints } from '../api';
const getCookie = (name) => document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))?.at(2);
const deleteCookie = (name, path, domain) => {
if (getCookie(name)) {
document.cookie =
name +
'=' +
(path ? ';path=' + path : '') +
(domain ? ';domain=' + domain : '') +
';expires=Thu, 01 Jan 1970 00:00:01 GMT';
}
};
export { isAuthenticated };
const logoutUser = () => {
localStorage.clear();
api
.post(`${host()}${endpoints.logout}`)
.then(() => {
deleteCookie('user');
window.location.replace('/login');
})
.catch((err) => {
console.error(err);
});
};
const isAuthenticated = () => {
const loggedIn = getCookie('user');
if (loggedIn) return true;
const authState = JSON.parse(localStorage.getItem('authConfig'));
if (isNil(authState)) return false;
if (Object.keys(authState).length === 0) return true;
};
const isAuthenticationEnabled = () => {
const authMethods = JSON.parse(localStorage.getItem('authConfig')) || {};
return Object.keys(authMethods).length > 0;
};
const getLoggedInUser = () => {
const userCookie = getCookie('user');
if (!userCookie) return null;
return userCookie;
};
export { isAuthenticated, isAuthenticationEnabled, getLoggedInUser, logoutUser };

View File

@ -1,4 +1,14 @@
images:
- name: ubuntu
tags:
- "18.04"
- "bionic-20230301"
- "bionic"
- "22.04"
- "jammy-20230301"
- "jammy"
- "latest"
multiarch: ""
- name: alpine
tags:
- "3.17"
@ -20,16 +30,6 @@ images:
- "3.14"
- "3.14.9"
multiarch: "all"
- name: ubuntu
tags:
- "18.04"
- "bionic-20230301"
- "bionic"
- "22.04"
- "jammy-20230301"
- "jammy"
- "latest"
multiarch: ""
- name: debian
tags:
- "bullseye-slim"

83
tests/explore.spec.js Normal file
View File

@ -0,0 +1,83 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { scroll } from './utils/scroll';
import { getRepoCardNameForLocator, getRepoListOrderedAlpha } from './utils/test-data-parser';
import { hosts, endpoints, sortCriteria } from './values/test-constants';
test.describe('explore page test', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('token', '-');
});
});
test('explore data', async ({ page }) => {
const expectedRequest = `${hosts.api}${endpoints.globalSearch('', sortCriteria.relevance, 1)}`;
const exploreDataRequest = page.waitForRequest(
(request) => request.url() === expectedRequest && request.method() === 'GET'
);
await page.goto(`${hosts.ui}/explore?search=`);
const expectDataResponse = await exploreDataRequest;
expect(expectDataResponse).toBeTruthy();
// if no search query provided and no filters selected, data should be alphabetical when sorted by relevance
const alphaOrderedData = getRepoListOrderedAlpha();
const exploreFirst = page.getByRole('button', {
name: getRepoCardNameForLocator(alphaOrderedData[0])
});
const exploreSecond = page.getByRole('button', {
name: getRepoCardNameForLocator(alphaOrderedData[1])
});
await expect(exploreFirst).toBeVisible({ timeout: 250000 });
await expect(exploreSecond).toBeVisible({ timeout: 250000 });
const exploreNextPageRequest = page.waitForRequest(
(request) =>
request.url() === `${hosts.api}${endpoints.globalSearch('', sortCriteria.relevance, 2)}` &&
request.method() === 'GET'
);
await page.evaluate(scroll, { direction: 'down', speed: 'fast' });
const exploreNextPageResponse = await exploreNextPageRequest;
expect(exploreNextPageResponse).toBeTruthy();
const postScrollExploreElementOne = page.getByRole('button', {
name: getRepoCardNameForLocator(alphaOrderedData[alphaOrderedData.length - 1])
});
const postScrollExploreElementTwo = page.getByRole('button', {
name: getRepoCardNameForLocator(alphaOrderedData[alphaOrderedData.length - 2])
});
await expect(postScrollExploreElementOne).toBeVisible({ timeout: 250000 });
await expect(postScrollExploreElementTwo).toBeVisible({ timeout: 250000 });
});
test('explore filtering', async ({ page }) => {
const alphaOrderedData = getRepoListOrderedAlpha();
await page.goto(`${hosts.ui}/explore?search=`);
const exploreFirst = page.getByRole('button', {
name: getRepoCardNameForLocator(alphaOrderedData[0])
});
const exploreSecond = page.getByRole('button', {
name: getRepoCardNameForLocator(alphaOrderedData[1])
});
await expect(exploreFirst).toBeVisible({ timeout: 250000 });
await expect(exploreSecond).toBeVisible({ timeout: 250000 });
const linuxFilter = page.getByRole('checkbox', { name: 'linux' });
await linuxFilter.check();
await expect(linuxFilter).toBeChecked();
await expect(exploreFirst).toBeVisible({ timeout: 250000 });
await linuxFilter.uncheck();
await page.getByRole('checkbox', { name: 'windows' }).check();
await expect(exploreFirst).not.toBeVisible({ timeout: 250000 });
});
});

25
tests/home.spec.js Normal file
View File

@ -0,0 +1,25 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { hosts, endpoints, sortCriteria } from './values/test-constants';
test.describe('homepage test', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('token', '-');
});
});
test('homepage viewall navigation', async ({ page }) => {
await page.goto(`${hosts.ui}/home`);
const popularRequest = page.waitForRequest(
(request) =>
request.url() === `${hosts.api}${endpoints.globalSearch('', sortCriteria.downloads)}` &&
request.method() === 'GET'
);
const viewAllButton = page.getByText('View all').first();
await viewAllButton.click();
const popularResponse = await popularRequest;
expect(popularResponse).toBeTruthy();
await expect(page).toHaveURL(`${hosts.ui}/explore?sortby=${sortCriteria.downloads}`);
});
});

38
tests/navbar.spec.js Normal file
View File

@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test';
import { hosts, endpoints, sortCriteria } from './values/test-constants';
import { getRepoListOrderedAlpha } from './utils/test-data-parser';
test.describe('navbar test', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('token', '-');
});
});
test('nav search', async ({ page }) => {
const alphaOrderedData = getRepoListOrderedAlpha();
await page.goto(`${hosts.ui}/home`);
// search results
const searchRequest = page.waitForRequest(
(request) =>
request.url() ===
`${hosts.api}${endpoints.globalSearch(
alphaOrderedData[0].repo.substring(0, 3),
sortCriteria.relevance,
1,
9
)}` && request.method() === 'GET'
);
await page.getByPlaceholder('Search for content...').click();
await page.getByPlaceholder('Search for content...').fill(alphaOrderedData[0].repo.substring(0, 3));
const searchResponse = await searchRequest;
expect(searchResponse).toBeTruthy();
const searchSuggestion = await page.getByRole('option', { name: alphaOrderedData[0].repo });
await expect(searchSuggestion).toBeVisible({ timeout: 100000 });
// clicking a search result
await searchSuggestion.click();
await expect(page).toHaveURL(/.*\/image.*/);
});
});

48
tests/repo.spec.js Normal file
View File

@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test';
import { hosts, endpoints } from './values/test-constants';
import { getMultiTagRepo } from './utils/test-data-parser';
import { head } from 'lodash';
const testRepo = getMultiTagRepo();
test.describe('Repository page test', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('token', '-');
});
await page.goto(`${hosts.ui}/image/${testRepo.repo}`);
});
test('Repository page data', async ({ page }) => {
// check metadata
const firstTag = head(testRepo.tags);
await expect(page.getByText(firstTag.description).first()).toBeVisible({ timeout: 100000 });
await expect(page.getByText(firstTag.source).first()).toBeVisible({ timeout: 100000 });
// check tags and tags search
for (let tag of testRepo.tags) {
await expect(page.getByText(tag.tag, { exact: true })).toBeVisible({ timeout: 100000 });
}
await page.getByText('Show more').first().click();
await expect(page.getByText('linux/amd64')).toBeVisible({ timeout: 100000 });
await page.getByPlaceholder('Search tags...').click();
await page.getByPlaceholder('Search tags...').fill(testRepo.tags[0].tag);
await expect(page.getByText(testRepo.tags[0].tag, { exact: true })).toBeVisible({ timeout: 100000 });
await expect(page.getByText(testRepo.tags[1].tag, { exact: true })).not.toBeVisible({ timeout: 100000 });
});
test('Repository page navigation', async ({ page }) => {
await expect(page.getByText(testRepo.tags[0].tag, { exact: true })).toBeVisible({ timeout: 100000 });
const tagPageRequest = page.waitForRequest(
(request) =>
request.url() === `${hosts.api}${endpoints.image(`${testRepo.repo}:${testRepo.tags[0].tag}`)}` &&
request.method() === 'GET'
);
await page.getByText(testRepo.tags[0].tag, { exact: true }).click();
await expect(tagPageRequest).toBeDefined();
const tagPageResponse = await tagPageRequest;
expect(tagPageResponse).toBeTruthy();
await expect(page).toHaveURL(/.*\/image\/.+\/tag\/.*/);
});
});

View File

@ -185,7 +185,7 @@ if [ $? -eq 0 ]; then
echo "Image ${local_image_ref_skopeo} found locally"
else
echo "Image ${local_image_ref_skopeo} will be copied"
skopeo --insecure-policy copy --format=oci ${multiarch_arg} ${remote_src_image_ref} ${local_image_ref_skopeo}
skopeo --insecure-policy --override-os="linux" --override-arch="amd64" copy --override-os="linux" --override-arch="amd64" --format=oci ${multiarch_arg} ${remote_src_image_ref} ${local_image_ref_skopeo}
if [ $? -ne 0 ]; then
exit 1
fi
@ -206,7 +206,7 @@ if [ ! -z "${username}" ]; then
fi
# Upload image to target registry
skopeo copy --dest-tls-verify=false ${multiarch_arg} ${credentials_args} ${local_image_ref_skopeo} docker://${remote_dest_image_ref}
skopeo --override-os="linux" --override-arch="amd64" copy --dest-tls-verify=false ${multiarch_arg} ${credentials_args} ${local_image_ref_skopeo} docker://${remote_dest_image_ref}
if [ $? -ne 0 ]; then
exit 1
fi

43
tests/tag.spec.js Normal file
View File

@ -0,0 +1,43 @@
import { test, expect } from '@playwright/test';
import { getTagWithDependencies, getTagWithDependents, getTagWithVulnerabilities } from './utils/test-data-parser';
import { hosts, pageSizes } from './values/test-constants';
test.describe('Tag page test', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('token', '-');
});
});
test('Tag page with dependents', async ({ page }) => {
const tagWithDependents = getTagWithDependents();
await page.goto(`${hosts.ui}/image/${tagWithDependents.title}/tag/${tagWithDependents.tag}`);
await expect(page.getByRole('tab', { name: 'Layers' })).toBeVisible({ timeout: 100000 });
await page.getByRole('tab', { name: 'Layers' }).click();
await expect(page.getByTestId('layer-card-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
await page.getByRole('tab', { name: 'Used by' }).click();
await expect(page.getByTestId('dependents-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
await expect(page.getByText('Tag').nth(1)).toBeVisible({ timeout: 100000 });
await expect(await page.getByText('Tag').count()).toBeGreaterThan(0);
});
test('Tag page with dependencies', async ({ page }) => {
const tagWithDependencies = getTagWithDependencies();
await page.goto(`${hosts.ui}/image/${tagWithDependencies.title}/tag/${tagWithDependencies.tag}`);
await expect(page.getByRole('tab', { name: 'Layers' })).toBeVisible({ timeout: 100000 });
await page.getByRole('tab', { name: 'Layers' }).click();
await expect(page.getByTestId('layer-card-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
await page.getByRole('tab', { name: 'Uses' }).click();
await expect(page.getByTestId('depends-on-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
await expect(page.getByText('Tag')).toHaveCount(1, { timeout: 100000 });
});
test('Tag page with vulnerabilities', async ({ page }) => {
const tagWithVulnerabilities = getTagWithVulnerabilities();
await page.goto(`${hosts.ui}/image/${tagWithVulnerabilities.title}/tag/${tagWithVulnerabilities.tag}`);
await page.getByRole('tab', { name: 'Vulnerabilities' }).click();
await expect(page.getByTestId('vulnerability-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
await expect(await page.getByText('CVE-').count()).toBeGreaterThan(0);
await expect(await page.getByText('CVE-').count()).toBeLessThanOrEqual(pageSizes.EXPLORE);
});
});

14
tests/utils/scroll.js Normal file
View File

@ -0,0 +1,14 @@
export const scroll = async (args) => {
const { direction, speed } = args;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const scrollHeight = () => document.body.scrollHeight;
const start = direction === 'down' ? 0 : scrollHeight();
const shouldStop = (position) => (direction === 'down' ? position > scrollHeight() : position < 0);
const increment = direction === 'down' ? 100 : -100;
const delayTime = speed === 'slow' ? 50 : 10;
console.error(start, shouldStop(start), increment);
for (let i = start; !shouldStop(i); i += increment) {
window.scrollTo(0, i);
await delay(delayTime);
}
};

View File

@ -0,0 +1,163 @@
// read raw test data and get expected result for different queries
import { isNil } from 'lodash';
import * as rawData from '../data/image_metadata.json';
const rawDataToRepo = ([rawDataRepoKey, rawDataRepoValue]) => {
if (rawDataRepoKey === 'default') return;
return {
repo: rawDataRepoKey,
tags: Object.entries(rawDataRepoValue).map(([key, value]) => ({
tag: key,
title: value['org.opencontainers.image.title'],
description: value['org.opencontainers.image.description'],
url: value['org.opencontainers.image.url'],
source: value['org.opencontainers.image.source'],
license: value['org.opencontainers.image.licenses'],
vendor: value['org.opencontainers.image.vendor'],
documentation: value['org.opencontainers.image.documentation'],
manifests: value['manifests'],
cves: value['cves']
}))
};
};
const getManifestDependents = (manifestValue, repoName) => {
const dependents = [];
Object.entries(rawData)
.map((repo) => rawDataToRepo(repo))
.forEach((repo) => {
// if different repo
repo?.tags.forEach((tag) => {
if (tag.title !== repoName) {
Object.values(tag?.manifests).forEach((value) => {
if (value.layers?.length > manifestValue.layers?.length) {
if (manifestValue.layers?.every((i) => value.layers?.includes(i))) {
dependents.push(value);
}
}
});
}
});
});
return dependents;
};
const getManifestDependencies = (manifestValue, repoName) => {
const dependencies = [];
Object.entries(rawData)
.map((repo) => rawDataToRepo(repo))
.forEach((repo) => {
repo?.tags.forEach((tag) => {
// if different repo
if (tag.title !== repoName) {
Object.values(tag?.manifests).forEach((value) => {
if (value.layers?.length < manifestValue.layers?.length) {
if (value.layers?.every((i) => manifestValue.layers?.includes(i))) {
dependencies.push(value);
}
}
});
}
});
});
return dependencies;
};
const getMultiTagRepo = () => {
const multiTagImage = Object.entries(rawData)
.find(([, value]) => Object.keys(value).length > 1)
.filter((e) => !isNil(e));
return rawDataToRepo(multiTagImage);
};
const getTagWithDependents = (minSize = 0) => {
const parsedRepoList = Object.entries(rawData)
.map((repo) => rawDataToRepo(repo))
.filter((e) => !isNil(e));
let tagsArray = [];
parsedRepoList.forEach((el) => (tagsArray = tagsArray.concat(el?.tags)));
for (let tag of tagsArray) {
if (!isNil(tag)) {
const tagManifests = Object.values(tag?.manifests);
const manifestWithDependent = tagManifests.findIndex(
(manifest) => getManifestDependents(manifest, tag.title).length > minSize
);
if (manifestWithDependent !== -1) return tag;
}
}
return null;
};
const getTagWithDependencies = (minSize = 0) => {
const parsedRepoList = Object.entries(rawData)
.map((repo) => rawDataToRepo(repo))
.filter((e) => !isNil(e));
let tagsArray = [];
parsedRepoList.forEach((el) => (tagsArray = tagsArray.concat(el?.tags)));
for (let tag of tagsArray) {
if (!isNil(tag)) {
const tagManifests = Object.values(tag?.manifests);
const manifestWithDependencies = tagManifests.findIndex(
(manifest) => getManifestDependencies(manifest, tag.title).length > minSize
);
if (manifestWithDependencies !== -1) return tag;
}
}
return null;
};
const getTagWithVulnerabilities = () => {
const parsedRepoList = Object.entries(rawData)
.map((repo) => rawDataToRepo(repo))
.filter((e) => !isNil(e));
let tagsArray = [];
parsedRepoList.forEach((el) => (tagsArray = tagsArray.concat(el?.tags)));
const tagWithCves = tagsArray.find((tag) => Object.keys(tag.cves).length > 0);
return tagWithCves;
};
const getTagWithMultiarch = () => {
const parsedRepoList = Object.entries(rawData)
.map((repo) => rawDataToRepo(repo))
.filter((e) => !isNil(e));
let tagsArray = [];
const tagsList = parsedRepoList.forEach((el) => tagsArray.concat(el?.tags));
const tagWithMultiarch = tagsList.find((tag) => tag.multiarch === 'all');
return tagWithMultiarch;
};
const getRepoListOrderedAlpha = () => {
const parsedRepoList = Object.entries(rawData)
.map((repo) => rawDataToRepo(repo))
.filter((e) => !isNil(e));
parsedRepoList.sort((a, b) => a?.repo.localeCompare(b?.repo));
return parsedRepoList;
};
// Currently image metadata does not contain last update time for tags
// const getLastUpdatedForRepo = (repo) => {
// const debug = DateTime.max(...repo.tags.map((tag) => DateTime.fromISO(tag.lastUpdated)));
// return debug;
// };
// const getRepoListOrderedRecent = () => {
// const parsedRepoList = Object.entries(rawData)
// .map((repo) => rawDataToRepo(repo))
// .filter((e) => !isNil(e));
// parsedRepoList.sort((a, b) => getLastUpdatedForRepo(b).diff(getLastUpdatedForRepo(a)));
// return parsedRepoList;
// };
const getRepoCardNameForLocator = (repo) => {
return `${repo?.repo} ${repo?.tags[0]?.description?.slice(0, 10)}`;
};
export {
getMultiTagRepo,
getRepoListOrderedAlpha,
getTagWithDependents,
getTagWithDependencies,
getTagWithVulnerabilities,
getTagWithMultiarch,
getRepoCardNameForLocator
};

View File

@ -0,0 +1,31 @@
const hosts = {
ui: process.env.UI_HOST ? `http://${process.env.UI_HOST}` : 'http://localhost:5000',
api: process.env.API_HOST ? `http://${process.env.API_HOST}` : 'http://localhost:5000'
};
const sortCriteria = {
relevance: 'RELEVANCE',
updateTime: 'UPDATE_TIME',
alphabetic: 'ALPHABETIC_ASC',
alphabeticDesc: 'ALPHABETIC_DSC',
downloads: 'DOWNLOADS'
};
const pageSizes = {
EXPLORE: 10,
HOME: 10
};
const endpoints = {
repoList: `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage:%20{limit:15%20offset:0}){Results%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20%20Licenses%20Logo%20Title%20Source%20IsSigned%20Documentation%20Vendor%20Labels}%20DownloadCount}}}`,
detailedRepoInfo: (name) =>
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`,
globalSearch: (searchTerm, sortCriteria, pageNumber = 1, pageSize = 10) =>
`/v2/_zot/ext/search?query={GlobalSearch(query:%22${searchTerm}%22,%20requestedPage:%20{limit:${pageSize}%20offset:${
10 * (pageNumber - 1)
}%20sortBy:%20${sortCriteria}}%20)%20{Page%20{TotalCount%20ItemCount}%20Repos%20{Name%20LastUpdated%20Size%20Platforms%20{%20Os%20Arch%20}%20IsStarred%20IsBookmarked%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20IsSigned%20Licenses%20Vendor%20Labels%20}%20DownloadCount}}}`,
image: (name) =>
`/v2/_zot/ext/search?query={Image(image:%20%22${name}%22){RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20%20Referrers%20{MediaType%20ArtifactType%20Size%20Digest%20Annotations{Key%20Value}}%20Tag%20Manifests%20{History%20{Layer%20{Size%20Digest}%20HistoryDescription%20{CreatedBy%20EmptyLayer}}%20Digest%20ConfigDigest%20LastUpdated%20Size%20Platform%20{Os%20Arch}}%20Vendor%20Licenses%20}}`
};
export { hosts, endpoints, sortCriteria, pageSizes };