Apply new eslint rules; default to prettier for styling

This commit is contained in:
DaneEveritt 2022-06-26 15:13:52 -04:00
parent f22cce8881
commit dc84af9937
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
218 changed files with 3876 additions and 3564 deletions

57
.eslintrc.js Normal file
View file

@ -0,0 +1,57 @@
const prettier = {
singleQuote: true,
jsxSingleQuote: true,
printWidth: 120,
};
/** @type {import('eslint').Linter.Config} */
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 6,
ecmaFeatures: {
jsx: true,
},
project: './tsconfig.json',
tsconfigRootDir: './',
},
settings: {
react: {
pragma: 'React',
version: 'detect',
},
linkComponents: [
{name: 'Link', linkAttribute: 'to'},
{name: 'NavLink', linkAttribute: 'to'},
],
},
env: {
browser: true,
es6: true,
},
plugins: [
'react',
'react-hooks',
'prettier',
'@typescript-eslint',
],
extends: [
// 'standard',
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:jest-dom/recommended',
],
rules: {
eqeqeq: 'error',
'prettier/prettier': ['error', prettier],
// This setup is required to avoid a spam of errors when running eslint about React being
// used before it is defined.
//
// @see https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use
'no-use-before-define': 0,
'@typescript-eslint/no-use-before-define': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', {argsIgnorePattern: '^_', varsIgnorePattern: '^_'}],
'@typescript-eslint/ban-ts-comment': ['error', {'ts-expect-error': 'allow-with-description'}],
}
};

View file

@ -53,8 +53,6 @@ rules:
multiline-ternary: 0 multiline-ternary: 0
"react-hooks/rules-of-hooks": "react-hooks/rules-of-hooks":
- error - error
"react-hooks/exhaustive-deps": 0
"@typescript-eslint/explicit-function-return-type": 0
"@typescript-eslint/explicit-member-accessibility": 0 "@typescript-eslint/explicit-member-accessibility": 0
"@typescript-eslint/ban-ts-ignore": 0 "@typescript-eslint/ban-ts-ignore": 0
"@typescript-eslint/no-explicit-any": 0 "@typescript-eslint/no-explicit-any": 0

View file

@ -99,14 +99,17 @@
"babel-plugin-styled-components": "^2.0.7", "babel-plugin-styled-components": "^2.0.7",
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"css-loader": "^5.2.7", "css-loader": "^5.2.7",
"eslint": "^7.27.0", "eslint": "^8.18.0",
"eslint-config-standard": "^16.0.3", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.23.3", "eslint-config-standard": "^17.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest-dom": "^4.0.2", "eslint-plugin-jest-dom": "^4.0.2",
"eslint-plugin-n": "^15.2.3",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.23.2", "eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"fork-ts-checker-webpack-plugin": "^6.2.10", "fork-ts-checker-webpack-plugin": "^6.2.10",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^28.1.1", "jest": "^28.1.1",
@ -115,6 +118,7 @@
"postcss-loader": "^4.0.0", "postcss-loader": "^4.0.0",
"postcss-nesting": "^10.1.8", "postcss-nesting": "^10.1.8",
"postcss-preset-env": "^7.7.1", "postcss-preset-env": "^7.7.1",
"prettier": "^2.7.1",
"redux-devtools-extension": "^2.13.8", "redux-devtools-extension": "^2.13.8",
"source-map-loader": "^1.1.3", "source-map-loader": "^1.1.3",
"style-loader": "^2.0.0", "style-loader": "^2.0.0",

View file

@ -19,9 +19,7 @@ const TransitionRouter: React.FC = ({ children }) => {
render={({ location }) => ( render={({ location }) => (
<StyledSwitchTransition> <StyledSwitchTransition>
<Fade timeout={150} key={location.pathname + location.search} in appear unmountOnExit> <Fade timeout={150} key={location.pathname + location.search} in appear unmountOnExit>
<section> <section>{children}</section>
{children}
</section>
</Fade> </Fade>
</StyledSwitchTransition> </StyledSwitchTransition>
)} )}

View file

@ -8,10 +8,15 @@ import useFilteredObject from '@/plugins/useFilteredObject';
export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>; export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>;
const useActivityLogs = (filters?: ActivityLogFilters, config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => { const useActivityLogs = (
filters?: ActivityLogFilters,
config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>
): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => {
const key = useUserSWRContentKey(['account', 'activity', JSON.stringify(useFilteredObject(filters || {}))]); const key = useUserSWRContentKey(['account', 'activity', JSON.stringify(useFilteredObject(filters || {}))]);
return useSWR<PaginatedResult<ActivityLog>>(key, async () => { return useSWR<PaginatedResult<ActivityLog>>(
key,
async () => {
const { data } = await http.get('/api/client/account/activity', { const { data } = await http.get('/api/client/account/activity', {
params: { params: {
...withQueryBuilderParams(filters), ...withQueryBuilderParams(filters),
@ -20,7 +25,9 @@ const useActivityLogs = (filters?: ActivityLogFilters, config?: ConfigInterface<
}); });
return toPaginatedSet(data, Transformers.toActivityLog); return toPaginatedSet(data, Transformers.toActivityLog);
}, { revalidateOnMount: false, ...(config || {}) }); },
{ revalidateOnMount: false, ...(config || {}) }
);
}; };
export { useActivityLogs }; export { useActivityLogs };

View file

@ -7,11 +7,13 @@ export default (description: string, allowedIps: string): Promise<ApiKey & { sec
description, description,
allowed_ips: allowedIps.length > 0 ? allowedIps.split('\n') : [], allowed_ips: allowedIps.length > 0 ? allowedIps.split('\n') : [],
}) })
.then(({ data }) => resolve({ .then(({ data }) =>
resolve({
...rawDataToApiKey(data.attributes), ...rawDataToApiKey(data.attributes),
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
secretToken: data.meta?.secret_token ?? '', secretToken: data.meta?.secret_token ?? '',
})) })
)
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -7,13 +7,17 @@ import { AxiosError } from 'axios';
const useSSHKeys = (config?: ConfigInterface<SSHKey[], AxiosError>) => { const useSSHKeys = (config?: ConfigInterface<SSHKey[], AxiosError>) => {
const key = useUserSWRContentKey(['account', 'ssh-keys']); const key = useUserSWRContentKey(['account', 'ssh-keys']);
return useSWR(key, async () => { return useSWR(
key,
async () => {
const { data } = await http.get('/api/client/account/ssh-keys'); const { data } = await http.get('/api/client/account/ssh-keys');
return (data as FractalResponseList).data.map((datum: any) => { return (data as FractalResponseList).data.map((datum: any) => {
return Transformers.toSSHKey(datum.attributes); return Transformers.toSSHKey(datum.attributes);
}); });
}, { revalidateOnMount: false, ...(config || {}) }); },
{ revalidateOnMount: false, ...(config || {}) }
);
}; };
const createSSHKey = async (name: string, publicKey: string): Promise<SSHKey> => { const createSSHKey = async (name: string, publicKey: string): Promise<SSHKey> => {

View file

@ -15,12 +15,14 @@ export interface LoginData {
export default ({ username, password, recaptchaData }: LoginData): Promise<LoginResponse> => { export default ({ username, password, recaptchaData }: LoginData): Promise<LoginResponse> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get('/sanctum/csrf-cookie') http.get('/sanctum/csrf-cookie')
.then(() => http.post('/auth/login', { .then(() =>
http.post('/auth/login', {
user: username, user: username,
password, password,
'g-recaptcha-response': recaptchaData, 'g-recaptcha-response': recaptchaData,
})) })
.then(response => { )
.then((response) => {
if (!(response.data instanceof Object)) { if (!(response.data instanceof Object)) {
return reject(new Error('An error occurred while processing the login request.')); return reject(new Error('An error occurred while processing the login request.'));
} }

View file

@ -6,12 +6,14 @@ export default (token: string, code: string, recoveryToken?: string): Promise<Lo
http.post('/auth/login/checkpoint', { http.post('/auth/login/checkpoint', {
confirmation_token: token, confirmation_token: token,
authentication_code: code, authentication_code: code,
recovery_token: (recoveryToken && recoveryToken.length > 0) ? recoveryToken : undefined, recovery_token: recoveryToken && recoveryToken.length > 0 ? recoveryToken : undefined,
}) })
.then(response => resolve({ .then((response) =>
resolve({
complete: response.data.data.complete, complete: response.data.data.complete,
intended: response.data.data.intended || undefined, intended: response.data.data.intended || undefined,
})) })
)
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -19,10 +19,12 @@ export default (email: string, data: Data): Promise<PasswordResetResponse> => {
password: data.password, password: data.password,
password_confirmation: data.passwordConfirmation, password_confirmation: data.passwordConfirmation,
}) })
.then(response => resolve({ .then((response) =>
resolve({
redirectTo: response.data.redirect_to, redirectTo: response.data.redirect_to,
sendToLogin: response.data.send_to_login, sendToLogin: response.data.send_to_login,
})) })
)
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -3,7 +3,7 @@ import http from '@/api/http';
export default (email: string, recaptchaData?: string): Promise<string> => { export default (email: string, recaptchaData?: string): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.post('/auth/password', { email, 'g-recaptcha-response': recaptchaData }) http.post('/auth/password', { email, 'g-recaptcha-response': recaptchaData })
.then(response => resolve(response.data.status || '')) .then((response) => resolve(response.data.status || ''))
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -12,9 +12,21 @@ type TransformerFunc<T> = (callback: FractalResponseData) => T;
const isList = (data: FractalResponseList | FractalResponseData): data is FractalResponseList => data.object === 'list'; const isList = (data: FractalResponseList | FractalResponseData): data is FractalResponseList => data.object === 'list';
function transform<T, M>(data: null | undefined, transformer: TransformerFunc<T>, missing?: M): M; function transform<T, M>(data: null | undefined, transformer: TransformerFunc<T>, missing?: M): M;
function transform<T, M>(data: FractalResponseData | null | undefined, transformer: TransformerFunc<T>, missing?: M): T | M; function transform<T, M>(
function transform<T, M>(data: FractalResponseList | FractalPaginatedResponse | null | undefined, transformer: TransformerFunc<T>, missing?: M): T[] | M; data: FractalResponseData | null | undefined,
function transform<T> (data: FractalResponseData | FractalResponseList | FractalPaginatedResponse | null | undefined, transformer: TransformerFunc<T>, missing = undefined) { transformer: TransformerFunc<T>,
missing?: M
): T | M;
function transform<T, M>(
data: FractalResponseList | FractalPaginatedResponse | null | undefined,
transformer: TransformerFunc<T>,
missing?: M
): T[] | M;
function transform<T>(
data: FractalResponseData | FractalResponseList | FractalPaginatedResponse | null | undefined,
transformer: TransformerFunc<T>,
missing = undefined
) {
if (data === undefined || data === null) { if (data === undefined || data === null) {
return missing; return missing;
} }
@ -32,7 +44,7 @@ function transform<T> (data: FractalResponseData | FractalResponseList | Fractal
function toPaginatedSet<T extends TransformerFunc<Model>>( function toPaginatedSet<T extends TransformerFunc<Model>>(
response: FractalPaginatedResponse, response: FractalPaginatedResponse,
transformer: T, transformer: T
): PaginatedResult<ReturnType<T>> { ): PaginatedResult<ReturnType<T>> {
return { return {
items: transform(response, transformer) as ReturnType<T>[], items: transform(response, transformer) as ReturnType<T>[],

View file

@ -22,7 +22,7 @@ interface ModelWithRelationships extends Model {
*/ */
type WithLoaded<M extends ModelWithRelationships, R extends keyof M['relationships']> = M & { type WithLoaded<M extends ModelWithRelationships, R extends keyof M['relationships']> = M & {
relationships: MarkRequired<M['relationships'], R>; relationships: MarkRequired<M['relationships'], R>;
} };
/** /**
* Helper type that allows you to infer the type of an object by giving * Helper type that allows you to infer the type of an object by giving

View file

@ -30,5 +30,5 @@ interface ActivityLog extends Model<'actor'> {
timestamp: Date; timestamp: Date;
relationships: { relationships: {
actor: User | null; actor: User | null;
} };
} }

View file

@ -10,7 +10,7 @@ export default class Transformers {
fingerprint: data.fingerprint, fingerprint: data.fingerprint,
createdAt: new Date(data.created_at), createdAt: new Date(data.created_at),
}; };
} };
static toUser = ({ attributes }: FractalResponseData): Models.User => { static toUser = ({ attributes }: FractalResponseData): Models.User => {
return { return {
@ -25,7 +25,7 @@ export default class Transformers {
return this.permissions.includes(permission); return this.permissions.includes(permission);
}, },
}; };
} };
static toActivityLog = ({ attributes }: FractalResponseData): Models.ActivityLog => { static toActivityLog = ({ attributes }: FractalResponseData): Models.ActivityLog => {
const { actor } = attributes.relationships || {}; const { actor } = attributes.relationships || {};
@ -43,8 +43,7 @@ export default class Transformers {
actor: transform(actor as FractalResponseData, this.toUser, null), actor: transform(actor as FractalResponseData, this.toUser, null),
}, },
}; };
} };
} }
export class MetaTransformers { export class MetaTransformers {}
}

View file

@ -15,10 +15,12 @@ export default ({ query, ...params }: QueryParams): Promise<PaginatedResult<Serv
...params, ...params,
}, },
}) })
.then(({ data }) => resolve({ .then(({ data }) =>
resolve({
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum)), items: (data.data || []).map((datum: any) => rawDataToServerObject(datum)),
pagination: getPaginationSet(data.meta.pagination), pagination: getPaginationSet(data.meta.pagination),
})) })
)
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -11,7 +11,7 @@ const http: AxiosInstance = axios.create({
}, },
}); });
http.interceptors.request.use(req => { http.interceptors.request.use((req) => {
if (!req.url?.endsWith('/resources')) { if (!req.url?.endsWith('/resources')) {
store.getActions().progress.startContinuous(); store.getActions().progress.startContinuous();
} }
@ -19,17 +19,20 @@ http.interceptors.request.use(req => {
return req; return req;
}); });
http.interceptors.response.use(resp => { http.interceptors.response.use(
(resp) => {
if (!resp.request?.url?.endsWith('/resources')) { if (!resp.request?.url?.endsWith('/resources')) {
store.getActions().progress.setComplete(); store.getActions().progress.setComplete();
} }
return resp; return resp;
}, error => { },
(error) => {
store.getActions().progress.setComplete(); store.getActions().progress.setComplete();
throw error; throw error;
}); }
);
export default http; export default http;

View file

@ -3,7 +3,9 @@ import { AxiosError } from 'axios';
import { History } from 'history'; import { History } from 'history';
export const setupInterceptors = (history: History) => { export const setupInterceptors = (history: History) => {
http.interceptors.response.use(resp => resp, (error: AxiosError) => { http.interceptors.response.use(
(resp) => resp,
(error: AxiosError) => {
if (error.response?.status === 400) { if (error.response?.status === 400) {
if (error.response?.data.errors?.[0].code === 'TwoFactorAuthRequiredException') { if (error.response?.data.errors?.[0].code === 'TwoFactorAuthRequiredException') {
if (!window.location.pathname.startsWith('/account')) { if (!window.location.pathname.startsWith('/account')) {
@ -12,5 +14,6 @@ export const setupInterceptors = (history: History) => {
} }
} }
throw error; throw error;
}); }
);
}; };

View file

@ -9,11 +9,16 @@ import { ServerContext } from '@/state/server';
export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>; export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>;
const useActivityLogs = (filters?: ActivityLogFilters, config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => { const useActivityLogs = (
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid); filters?: ActivityLogFilters,
config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>
): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => {
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
const key = useUserSWRContentKey(['server', 'activity', useFilteredObject(filters || {})]); const key = useUserSWRContentKey(['server', 'activity', useFilteredObject(filters || {})]);
return useSWR<PaginatedResult<ActivityLog>>(key, async () => { return useSWR<PaginatedResult<ActivityLog>>(
key,
async () => {
const { data } = await http.get(`/api/client/servers/${uuid}/activity`, { const { data } = await http.get(`/api/client/servers/${uuid}/activity`, {
params: { params: {
...withQueryBuilderParams(filters), ...withQueryBuilderParams(filters),
@ -22,7 +27,9 @@ const useActivityLogs = (filters?: ActivityLogFilters, config?: ConfigInterface<
}); });
return toPaginatedSet(data, Transformers.toActivityLog); return toPaginatedSet(data, Transformers.toActivityLog);
}, { revalidateOnMount: false, ...(config || {}) }); },
{ revalidateOnMount: false, ...(config || {}) }
);
}; };
export { useActivityLogs }; export { useActivityLogs };

View file

@ -3,13 +3,17 @@ import http from '@/api/http';
export default (uuid: string, data: { connectionsFrom: string; databaseName: string }): Promise<ServerDatabase> => { export default (uuid: string, data: { connectionsFrom: string; databaseName: string }): Promise<ServerDatabase> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/databases`, { http.post(
`/api/client/servers/${uuid}/databases`,
{
database: data.databaseName, database: data.databaseName,
remote: data.connectionsFrom, remote: data.connectionsFrom,
}, { },
{
params: { include: 'password' }, params: { include: 'password' },
}) }
.then(response => resolve(rawDataToServerDatabase(response.data.attributes))) )
.then((response) => resolve(rawDataToServerDatabase(response.data.attributes)))
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -15,7 +15,8 @@ export const rawDataToServerDatabase = (data: any): ServerDatabase => ({
username: data.username, username: data.username,
connectionString: `${data.host.address}:${data.host.port}`, connectionString: `${data.host.address}:${data.host.port}`,
allowConnectionsFrom: data.connections_from, allowConnectionsFrom: data.connections_from,
password: data.relationships && data.relationships.password ? data.relationships.password.attributes.password : undefined, password:
data.relationships && data.relationships.password ? data.relationships.password.attributes.password : undefined,
}); });
export default (uuid: string, includePassword = true): Promise<ServerDatabase[]> => { export default (uuid: string, includePassword = true): Promise<ServerDatabase[]> => {
@ -23,9 +24,9 @@ export default (uuid: string, includePassword = true): Promise<ServerDatabase[]>
http.get(`/api/client/servers/${uuid}/databases`, { http.get(`/api/client/servers/${uuid}/databases`, {
params: includePassword ? { include: 'password' } : undefined, params: includePassword ? { include: 'password' } : undefined,
}) })
.then(response => resolve( .then((response) =>
(response.data.data || []).map((item: any) => rawDataToServerDatabase(item.attributes)) resolve((response.data.data || []).map((item: any) => rawDataToServerDatabase(item.attributes)))
)) )
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -3,10 +3,15 @@ import http from '@/api/http';
import { rawDataToFileObject } from '@/api/transformers'; import { rawDataToFileObject } from '@/api/transformers';
export default async (uuid: string, directory: string, files: string[]): Promise<FileObject> => { export default async (uuid: string, directory: string, files: string[]): Promise<FileObject> => {
const { data } = await http.post(`/api/client/servers/${uuid}/files/compress`, { root: directory, files }, { const { data } = await http.post(
`/api/client/servers/${uuid}/files/compress`,
{ root: directory, files },
{
timeout: 60000, timeout: 60000,
timeoutErrorMessage: 'It looks like this archive is taking a long time to generate. It will appear once completed.', timeoutErrorMessage:
}); 'It looks like this archive is taking a long time to generate. It will appear once completed.',
}
);
return rawDataToFileObject(data); return rawDataToFileObject(data);
}; };

View file

@ -1,8 +1,13 @@
import http from '@/api/http'; import http from '@/api/http';
export default async (uuid: string, directory: string, file: string): Promise<void> => { export default async (uuid: string, directory: string, file: string): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/files/decompress`, { root: directory, file }, { await http.post(
`/api/client/servers/${uuid}/files/decompress`,
{ root: directory, file },
{
timeout: 300000, timeout: 300000,
timeoutErrorMessage: 'It looks like this archive is taking a long time to be unarchived. Once completed the unarchived files will appear.', timeoutErrorMessage:
}); 'It looks like this archive is taking a long time to be unarchived. Once completed the unarchived files will appear.',
}
);
}; };

View file

@ -4,7 +4,7 @@ export default (server: string, file: string): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${server}/files/contents`, { http.get(`/api/client/servers/${server}/files/contents`, {
params: { file }, params: { file },
transformResponse: res => res, transformResponse: (res) => res,
responseType: 'text', responseType: 'text',
}) })
.then(({ data }) => resolve(data)) .then(({ data }) => resolve(data))

View file

@ -5,7 +5,7 @@ export interface FileObject {
key: string; key: string;
name: string; name: string;
mode: string; mode: string;
modeBits: string, modeBits: string;
size: number; size: number;
isFile: boolean; isFile: boolean;
isSymlink: boolean; isSymlink: boolean;

View file

@ -58,24 +58,30 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
ip: data.sftp_details.ip, ip: data.sftp_details.ip,
port: data.sftp_details.port, port: data.sftp_details.port,
}, },
description: data.description ? ((data.description.length > 0) ? data.description : null) : null, description: data.description ? (data.description.length > 0 ? data.description : null) : null,
limits: { ...data.limits }, limits: { ...data.limits },
eggFeatures: data.egg_features || [], eggFeatures: data.egg_features || [],
featureLimits: { ...data.feature_limits }, featureLimits: { ...data.feature_limits },
isInstalling: data.status === 'installing' || data.status === 'install_failed', isInstalling: data.status === 'installing' || data.status === 'install_failed',
isTransferring: data.is_transferring, isTransferring: data.is_transferring,
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable), variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation), rawDataToServerEggVariable
),
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(
rawDataToServerAllocation
),
}); });
export default (uuid: string): Promise<[Server, string[]]> => { export default (uuid: string): Promise<[Server, string[]]> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}`) http.get(`/api/client/servers/${uuid}`)
.then(({ data }) => resolve([ .then(({ data }) =>
resolve([
rawDataToServerObject(data), rawDataToServerObject(data),
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
data.meta?.is_server_owner ? [ '*' ] : (data.meta?.user_permissions || []), data.meta?.is_server_owner ? ['*'] : data.meta?.user_permissions || [],
])) ])
)
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -16,7 +16,8 @@ export interface ServerStats {
export default (server: string): Promise<ServerStats> => { export default (server: string): Promise<ServerStats> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${server}/resources`) http.get(`/api/client/servers/${server}/resources`)
.then(({ data: { attributes } }) => resolve({ .then(({ data: { attributes } }) =>
resolve({
status: attributes.current_state, status: attributes.current_state,
isSuspended: attributes.is_suspended, isSuspended: attributes.is_suspended,
memoryUsageInBytes: attributes.resources.memory_bytes, memoryUsageInBytes: attributes.resources.memory_bytes,
@ -25,7 +26,8 @@ export default (server: string): Promise<ServerStats> => {
networkRxInBytes: attributes.resources.network_rx_bytes, networkRxInBytes: attributes.resources.network_rx_bytes,
networkTxInBytes: attributes.resources.network_tx_bytes, networkTxInBytes: attributes.resources.network_tx_bytes,
uptime: attributes.resources.uptime, uptime: attributes.resources.uptime,
})) })
)
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -8,10 +8,12 @@ interface Response {
export default (server: string): Promise<Response> => { export default (server: string): Promise<Response> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${server}/websocket`) http.get(`/api/client/servers/${server}/websocket`)
.then(({ data }) => resolve({ .then(({ data }) =>
resolve({
token: data.data.token, token: data.data.token,
socket: data.data.socket, socket: data.data.socket,
})) })
)
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -1,4 +1,5 @@
import { Allocation } from '@/api/server/getServer'; import { Allocation } from '@/api/server/getServer';
import http from '@/api/http'; import http from '@/api/http';
export default async (uuid: string, id: number): Promise<Allocation> => await http.delete(`/api/client/servers/${uuid}/network/allocations/${id}`); export default async (uuid: string, id: number): Promise<Allocation> =>
await http.delete(`/api/client/servers/${uuid}/network/allocations/${id}`);

View file

@ -1,7 +1,7 @@
import { rawDataToServerSchedule, Schedule } from '@/api/server/schedules/getServerSchedules'; import { rawDataToServerSchedule, Schedule } from '@/api/server/schedules/getServerSchedules';
import http from '@/api/http'; import http from '@/api/http';
type Data = Pick<Schedule, 'cron' | 'name' | 'onlyWhenOnline' | 'isActive'> & { id?: number } type Data = Pick<Schedule, 'cron' | 'name' | 'onlyWhenOnline' | 'isActive'> & { id?: number };
export default async (uuid: string, schedule: Data): Promise<Schedule> => { export default async (uuid: string, schedule: Data): Promise<Schedule> => {
const { data } = await http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, { const { data } = await http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, {

View file

@ -9,12 +9,15 @@ interface Data {
} }
export default async (uuid: string, schedule: number, task: number | undefined, data: Data): Promise<Task> => { export default async (uuid: string, schedule: number, task: number | undefined, data: Data): Promise<Task> => {
const { data: response } = await http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, { const { data: response } = await http.post(
`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`,
{
action: data.action, action: data.action,
payload: data.payload, payload: data.payload,
continue_on_failure: data.continueOnFailure, continue_on_failure: data.continueOnFailure,
time_offset: data.timeOffset, time_offset: data.timeOffset,
}); }
);
return rawDataToServerTask(response.attributes); return rawDataToServerTask(response.attributes);
}; };

View file

@ -12,7 +12,7 @@ export default (uuid: string, params: Params, subuser?: Subuser): Promise<Subuse
http.post(`/api/client/servers/${uuid}/users${subuser ? `/${subuser.uuid}` : ''}`, { http.post(`/api/client/servers/${uuid}/users${subuser ? `/${subuser.uuid}` : ''}`, {
...params, ...params,
}) })
.then(data => resolve(rawDataToServerSubuser(data.data))) .then((data) => resolve(rawDataToServerSubuser(data.data)))
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -9,7 +9,7 @@ export const rawDataToServerSubuser = (data: FractalResponseData): Subuser => ({
twoFactorEnabled: data.attributes['2fa_enabled'], twoFactorEnabled: data.attributes['2fa_enabled'],
createdAt: new Date(data.attributes.created_at), createdAt: new Date(data.attributes.created_at),
permissions: data.attributes.permissions || [], permissions: data.attributes.permissions || [],
can: permission => (data.attributes.permissions || []).indexOf(permission) >= 0, can: (permission) => (data.attributes.permissions || []).indexOf(permission) >= 0,
}); });
export default (uuid: string): Promise<Subuser[]> => { export default (uuid: string): Promise<Subuser[]> => {

View file

@ -5,11 +5,15 @@ import { rawDataToServerAllocation } from '@/api/transformers';
import { Allocation } from '@/api/server/getServer'; import { Allocation } from '@/api/server/getServer';
export default () => { export default () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
return useSWR<Allocation[]>([ 'server:allocations', uuid ], async () => { return useSWR<Allocation[]>(
['server:allocations', uuid],
async () => {
const { data } = await http.get(`/api/client/servers/${uuid}/network/allocations`); const { data } = await http.get(`/api/client/servers/${uuid}/network/allocations`);
return (data.data || []).map(rawDataToServerAllocation); return (data.data || []).map(rawDataToServerAllocation);
}, { revalidateOnFocus: false, revalidateOnMount: false }); },
{ revalidateOnFocus: false, revalidateOnMount: false }
);
}; };

View file

@ -16,15 +16,15 @@ type BackupResponse = PaginatedResult<ServerBackup> & { backupCount: number };
export default () => { export default () => {
const { page } = useContext(Context); const { page } = useContext(Context);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
return useSWR<BackupResponse>(['server:backups', uuid, page], async () => { return useSWR<BackupResponse>(['server:backups', uuid, page], async () => {
const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } }); const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } });
return ({ return {
items: (data.data || []).map(rawDataToServerBackup), items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination), pagination: getPaginationSet(data.meta.pagination),
backupCount: data.meta.backup_count, backupCount: data.meta.backup_count,
}); };
}); });
}; };

View file

@ -9,7 +9,10 @@ interface Response {
dockerImages: Record<string, string>; dockerImages: Record<string, string>;
} }
export default (uuid: string, initialData?: Response | null, config?: ConfigInterface<Response>) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => { export default (uuid: string, initialData?: Response | null, config?: ConfigInterface<Response>) =>
useSWR(
[uuid, '/startup'],
async (): Promise<Response> => {
const { data } = await http.get(`/api/client/servers/${uuid}/startup`); const { data } = await http.get(`/api/client/servers/${uuid}/startup`);
const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable); const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable);
@ -19,4 +22,6 @@ export default (uuid: string, initialData?: Response | null, config?: ConfigInte
invocation: data.meta.startup_command, invocation: data.meta.startup_command,
dockerImages: data.meta.docker_images || {}, dockerImages: data.meta.docker_images || {},
}; };
}, { initialData: initialData || undefined, errorRetryCount: 3, ...(config || {}) }); },
{ initialData: initialData || undefined, errorRetryCount: 3, ...(config || {}) }
);

View file

@ -25,7 +25,9 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
modifiedAt: new Date(data.attributes.modified_at), modifiedAt: new Date(data.attributes.modified_at),
isArchiveType: function () { isArchiveType: function () {
return this.isFile && [ return (
this.isFile &&
[
'application/vnd.rar', // .rar 'application/vnd.rar', // .rar
'application/x-rar-compressed', // .rar (2) 'application/x-rar-compressed', // .rar (2)
'application/x-tar', // .tar 'application/x-tar', // .tar
@ -38,20 +40,16 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
'application/x-xz', // .tar.xz, .xz 'application/x-xz', // .tar.xz, .xz
'application/zstd', // .tar.zst, .zst 'application/zstd', // .tar.zst, .zst
'application/zip', // .zip 'application/zip', // .zip
].indexOf(this.mimetype) >= 0; ].indexOf(this.mimetype) >= 0
);
}, },
isEditable: function () { isEditable: function () {
if (this.isArchiveType() || !this.isFile) return false; if (this.isArchiveType() || !this.isFile) return false;
const matches = [ const matches = ['application/jar', 'application/octet-stream', 'inode/directory', /^image\//];
'application/jar',
'application/octet-stream',
'inode/directory',
/^image\//,
];
return matches.every(m => !this.mimetype.match(m)); return matches.every((m) => !this.mimetype.match(m));
}, },
}); });

View file

@ -38,7 +38,7 @@ interface ExtendedWindow extends Window {
setupInterceptors(history); setupInterceptors(history);
const App = () => { const App = () => {
const { PterodactylUser, SiteConfiguration } = (window as ExtendedWindow); const { PterodactylUser, SiteConfiguration } = window as ExtendedWindow;
if (PterodactylUser && !store.getState().user.data) { if (PterodactylUser && !store.getState().user.data) {
store.getActions().user.setUserData({ store.getActions().user.setUserData({
uuid: PterodactylUser.uuid, uuid: PterodactylUser.uuid,

View file

@ -11,11 +11,9 @@ const _Avatar = ({ variant = 'beam', ...props }: AvatarProps) => (
); );
const _UserAvatar = ({ variant = 'beam', ...props }: Omit<Props, 'name'>) => { const _UserAvatar = ({ variant = 'beam', ...props }: Omit<Props, 'name'>) => {
const uuid = useStoreState(state => state.user.data?.uuid); const uuid = useStoreState((state) => state.user.data?.uuid);
return ( return <BoringAvatar colors={palette} name={uuid || 'system'} variant={variant} {...props} />;
<BoringAvatar colors={palette} name={uuid || 'system'} variant={variant} {...props} />
);
}; };
_Avatar.displayName = 'Avatar'; _Avatar.displayName = 'Avatar';

View file

@ -9,27 +9,22 @@ type Props = Readonly<{
}>; }>;
const FlashMessageRender = ({ byKey, className }: Props) => { const FlashMessageRender = ({ byKey, className }: Props) => {
const flashes = useStoreState(state => state.flashes.items.filter( const flashes = useStoreState((state) =>
flash => byKey ? flash.key === byKey : true, state.flashes.items.filter((flash) => (byKey ? flash.key === byKey : true))
)); );
return ( return flashes.length ? (
flashes.length ?
<div className={className}> <div className={className}>
{ {flashes.map((flash, index) => (
flashes.map((flash, index) => (
<React.Fragment key={flash.id || flash.type + flash.message}> <React.Fragment key={flash.id || flash.type + flash.message}>
{index > 0 && <div css={tw`mt-2`}></div>} {index > 0 && <div css={tw`mt-2`}></div>}
<MessageBox type={flash.type} title={flash.title}> <MessageBox type={flash.type} title={flash.title}>
{flash.message} {flash.message}
</MessageBox> </MessageBox>
</React.Fragment> </React.Fragment>
)) ))}
}
</div> </div>
: ) : null;
null
);
}; };
export default FlashMessageRender; export default FlashMessageRender;

View file

@ -42,13 +42,13 @@ const getBackground = (type?: FlashMessageType): TwStyle | string => {
const Container = styled.div<{ $type?: FlashMessageType }>` const Container = styled.div<{ $type?: FlashMessageType }>`
${tw`p-2 border items-center leading-normal rounded flex w-full text-sm text-white`}; ${tw`p-2 border items-center leading-normal rounded flex w-full text-sm text-white`};
${props => styling(props.$type)}; ${(props) => styling(props.$type)};
`; `;
Container.displayName = 'MessageBox.Container'; Container.displayName = 'MessageBox.Container';
const MessageBox = ({ title, children, type }: Props) => ( const MessageBox = ({ title, children, type }: Props) => (
<Container css={tw`lg:inline-flex`} $type={type} role={'alert'}> <Container css={tw`lg:inline-flex`} $type={type} role={'alert'}>
{title && {title && (
<span <span
className={'title'} className={'title'}
css={[ css={[
@ -58,10 +58,8 @@ const MessageBox = ({ title, children, type }: Props) => (
> >
{title} {title}
</span> </span>
} )}
<span css={tw`mr-2 text-left flex-auto`}> <span css={tw`mr-2 text-left flex-auto`}>{children}</span>
{children}
</span>
</Container> </Container>
); );
MessageBox.displayName = 'MessageBox'; MessageBox.displayName = 'MessageBox';

View file

@ -14,14 +14,19 @@ import Tooltip from '@/components/elements/tooltip/Tooltip';
import Avatar from '@/components/Avatar'; import Avatar from '@/components/Avatar';
const RightNavigation = styled.div` const RightNavigation = styled.div`
& > a, & > button, & > .navigation-link { & > a,
& > button,
& > .navigation-link {
${tw`flex items-center h-full no-underline text-neutral-300 px-6 cursor-pointer transition-all duration-150`}; ${tw`flex items-center h-full no-underline text-neutral-300 px-6 cursor-pointer transition-all duration-150`};
&:active, &:hover { &:active,
&:hover {
${tw`text-neutral-100 bg-black`}; ${tw`text-neutral-100 bg-black`};
} }
&:active, &:hover, &.active { &:active,
&:hover,
&.active {
box-shadow: inset 0 -2px ${theme`colors.cyan.600`.toString()}; box-shadow: inset 0 -2px ${theme`colors.cyan.600`.toString()};
} }
} }
@ -47,7 +52,9 @@ export default () => {
<div id={'logo'} className={'flex-1'}> <div id={'logo'} className={'flex-1'}>
<Link <Link
to={'/'} to={'/'}
className={'text-2xl font-header px-4 no-underline text-neutral-200 hover:text-neutral-100 transition-colors duration-150'} className={
'text-2xl font-header px-4 no-underline text-neutral-200 hover:text-neutral-100 transition-colors duration-150'
}
> >
{name} {name}
</Link> </Link>
@ -59,13 +66,13 @@ export default () => {
<FontAwesomeIcon icon={faLayerGroup} /> <FontAwesomeIcon icon={faLayerGroup} />
</NavLink> </NavLink>
</Tooltip> </Tooltip>
{rootAdmin && {rootAdmin && (
<Tooltip placement={'bottom'} content={'Admin'}> <Tooltip placement={'bottom'} content={'Admin'}>
<a href={'/admin'} rel={'noreferrer'}> <a href={'/admin'} rel={'noreferrer'}>
<FontAwesomeIcon icon={faCogs} /> <FontAwesomeIcon icon={faCogs} />
</a> </a>
</Tooltip> </Tooltip>
} )}
<Tooltip placement={'bottom'} content={'Account Settings'}> <Tooltip placement={'bottom'} content={'Account Settings'}>
<NavLink to={'/account'}> <NavLink to={'/account'}>
<span className={'flex items-center w-5 h-5'}> <span className={'flex items-center w-5 h-5'}>

View file

@ -22,7 +22,7 @@ export default () => {
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const { clearFlashes, addFlash } = useFlash(); const { clearFlashes, addFlash } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha); const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha);
useEffect(() => { useEffect(() => {
clearFlashes(); clearFlashes();
@ -34,7 +34,7 @@ export default () => {
// If there is no token in the state yet, request the token and then abort this submit request // If there is no token in the state yet, request the token and then abort this submit request
// since it will be re-submitted when the recaptcha data is returned by the component. // since it will be re-submitted when the recaptcha data is returned by the component.
if (recaptchaEnabled && !token) { if (recaptchaEnabled && !token) {
ref.current!.execute().catch(error => { ref.current!.execute().catch((error) => {
console.error(error); console.error(error);
setSubmitting(false); setSubmitting(false);
@ -45,11 +45,11 @@ export default () => {
} }
requestPasswordResetEmail(email, token) requestPasswordResetEmail(email, token)
.then(response => { .then((response) => {
resetForm(); resetForm();
addFlash({ type: 'success', title: 'Success', message: response }); addFlash({ type: 'success', title: 'Success', message: response });
}) })
.catch(error => { .catch((error) => {
console.error(error); console.error(error);
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) }); addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
}) })
@ -66,38 +66,33 @@ export default () => {
onSubmit={handleSubmission} onSubmit={handleSubmission}
initialValues={{ email: '' }} initialValues={{ email: '' }}
validationSchema={object().shape({ validationSchema={object().shape({
email: string().email('A valid email address must be provided to continue.') email: string()
.email('A valid email address must be provided to continue.')
.required('A valid email address must be provided to continue.'), .required('A valid email address must be provided to continue.'),
})} })}
> >
{({ isSubmitting, setSubmitting, submitForm }) => ( {({ isSubmitting, setSubmitting, submitForm }) => (
<LoginFormContainer <LoginFormContainer title={'Request Password Reset'} css={tw`w-full flex`}>
title={'Request Password Reset'}
css={tw`w-full flex`}
>
<Field <Field
light light
label={'Email'} label={'Email'}
description={'Enter your account email address to receive instructions on resetting your password.'} description={
'Enter your account email address to receive instructions on resetting your password.'
}
name={'email'} name={'email'}
type={'email'} type={'email'}
/> />
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
<Button <Button type={'submit'} size={'xlarge'} disabled={isSubmitting} isLoading={isSubmitting}>
type={'submit'}
size={'xlarge'}
disabled={isSubmitting}
isLoading={isSubmitting}
>
Send Email Send Email
</Button> </Button>
</div> </div>
{recaptchaEnabled && {recaptchaEnabled && (
<Reaptcha <Reaptcha
ref={ref} ref={ref}
size={'invisible'} size={'invisible'}
sitekey={siteKey || '_invalid_key'} sitekey={siteKey || '_invalid_key'}
onVerify={response => { onVerify={(response) => {
setToken(response); setToken(response);
submitForm(); submitForm();
}} }}
@ -106,7 +101,7 @@ export default () => {
setToken(''); setToken('');
}} }}
/> />
} )}
<div css={tw`mt-6 text-center`}> <div css={tw`mt-6 text-center`}>
<Link <Link
to={'/auth/login'} to={'/auth/login'}

View file

@ -13,14 +13,14 @@ import Button from '@/components/elements/Button';
interface Values { interface Values {
code: string; code: string;
recoveryCode: '', recoveryCode: '';
} }
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }> type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }>;
type Props = OwnProps & { type Props = OwnProps & {
clearAndAddHttpError: ActionCreator<FlashStore['clearAndAddHttpError']['payload']>; clearAndAddHttpError: ActionCreator<FlashStore['clearAndAddHttpError']['payload']>;
} };
const LoginCheckpointContainer = () => { const LoginCheckpointContainer = () => {
const { isSubmitting, setFieldValue } = useFormikContext<Values>(); const { isSubmitting, setFieldValue } = useFormikContext<Values>();
@ -44,12 +44,7 @@ const LoginCheckpointContainer = () => {
/> />
</div> </div>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
<Button <Button size={'xlarge'} type={'submit'} disabled={isSubmitting} isLoading={isSubmitting}>
size={'xlarge'}
type={'submit'}
disabled={isSubmitting}
isLoading={isSubmitting}
>
Continue Continue
</Button> </Button>
</div> </div>
@ -58,11 +53,11 @@ const LoginCheckpointContainer = () => {
onClick={() => { onClick={() => {
setFieldValue('code', ''); setFieldValue('code', '');
setFieldValue('recoveryCode', ''); setFieldValue('recoveryCode', '');
setIsMissingDevice(s => !s); setIsMissingDevice((s) => !s);
}} }}
css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`} css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
> >
{!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'} {!isMissingDevice ? "I've Lost My Device" : 'I Have My Device'}
</span> </span>
</div> </div>
<div css={tw`mt-6 text-center`}> <div css={tw`mt-6 text-center`}>
@ -80,7 +75,7 @@ const LoginCheckpointContainer = () => {
const EnhancedForm = withFormik<Props, Values>({ const EnhancedForm = withFormik<Props, Values>({
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { clearAndAddHttpError, location } }) => { handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { clearAndAddHttpError, location } }) => {
loginCheckpoint(location.state?.token || '', code, recoveryCode) loginCheckpoint(location.state?.token || '', code, recoveryCode)
.then(response => { .then((response) => {
if (response.complete) { if (response.complete) {
// @ts-ignore // @ts-ignore
window.location = response.intended || '/'; window.location = response.intended || '/';
@ -89,7 +84,7 @@ const EnhancedForm = withFormik<Props, Values>({
setSubmitting(false); setSubmitting(false);
}) })
.catch(error => { .catch((error) => {
console.error(error); console.error(error);
setSubmitting(false); setSubmitting(false);
clearAndAddHttpError({ error }); clearAndAddHttpError({ error });
@ -111,10 +106,7 @@ export default ({ history, location, ...props }: OwnProps) => {
return null; return null;
} }
return <EnhancedForm return (
clearAndAddHttpError={clearAndAddHttpError} <EnhancedForm clearAndAddHttpError={clearAndAddHttpError} history={history} location={location} {...props} />
history={history} );
location={location}
{...props}
/>;
}; };

View file

@ -21,7 +21,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha); const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha);
useEffect(() => { useEffect(() => {
clearFlashes(); clearFlashes();
@ -33,7 +33,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
// If there is no token in the state yet, request the token and then abort this submit request // If there is no token in the state yet, request the token and then abort this submit request
// since it will be re-submitted when the recaptcha data is returned by the component. // since it will be re-submitted when the recaptcha data is returned by the component.
if (recaptchaEnabled && !token) { if (recaptchaEnabled && !token) {
ref.current!.execute().catch(error => { ref.current!.execute().catch((error) => {
console.error(error); console.error(error);
setSubmitting(false); setSubmitting(false);
@ -44,7 +44,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
} }
login({ ...values, recaptchaData: token }) login({ ...values, recaptchaData: token })
.then(response => { .then((response) => {
if (response.complete) { if (response.complete) {
// @ts-ignore // @ts-ignore
window.location = response.intended || '/'; window.location = response.intended || '/';
@ -53,7 +53,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
history.replace('/auth/login/checkpoint', { token: response.confirmationToken }); history.replace('/auth/login/checkpoint', { token: response.confirmationToken });
}) })
.catch(error => { .catch((error) => {
console.error(error); console.error(error);
setToken(''); setToken('');
@ -75,33 +75,21 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
> >
{({ isSubmitting, setSubmitting, submitForm }) => ( {({ isSubmitting, setSubmitting, submitForm }) => (
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`}> <LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`}>
<Field <Field light type={'text'} label={'Username or Email'} name={'username'} disabled={isSubmitting} />
light
type={'text'}
label={'Username or Email'}
name={'username'}
disabled={isSubmitting}
/>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
<Field <Field light type={'password'} label={'Password'} name={'password'} disabled={isSubmitting} />
light
type={'password'}
label={'Password'}
name={'password'}
disabled={isSubmitting}
/>
</div> </div>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting} disabled={isSubmitting}> <Button type={'submit'} size={'xlarge'} isLoading={isSubmitting} disabled={isSubmitting}>
Login Login
</Button> </Button>
</div> </div>
{recaptchaEnabled && {recaptchaEnabled && (
<Reaptcha <Reaptcha
ref={ref} ref={ref}
size={'invisible'} size={'invisible'}
sitekey={siteKey || '_invalid_key'} sitekey={siteKey || '_invalid_key'}
onVerify={response => { onVerify={(response) => {
setToken(response); setToken(response);
submitForm(); submitForm();
}} }}
@ -110,7 +98,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
setToken(''); setToken('');
}} }}
/> />
} )}
<div css={tw`mt-6 text-center`}> <div css={tw`mt-6 text-center`}>
<Link <Link
to={'/auth/password'} to={'/auth/password'}

View file

@ -7,7 +7,7 @@ import tw from 'twin.macro';
type Props = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & { type Props = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
title?: string; title?: string;
} };
const Container = styled.div` const Container = styled.div`
${breakpoint('sm')` ${breakpoint('sm')`
@ -30,24 +30,18 @@ const Container = styled.div`
export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) => ( export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) => (
<Container> <Container>
{title && {title && <h2 css={tw`text-3xl text-center text-neutral-100 font-medium py-4`}>{title}</h2>}
<h2 css={tw`text-3xl text-center text-neutral-100 font-medium py-4`}>
{title}
</h2>
}
<FlashMessageRender css={tw`mb-2 px-1`} /> <FlashMessageRender css={tw`mb-2 px-1`} />
<Form {...props} ref={ref}> <Form {...props} ref={ref}>
<div css={tw`md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1`}> <div css={tw`md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1`}>
<div css={tw`flex-none select-none mb-6 md:mb-0 self-center`}> <div css={tw`flex-none select-none mb-6 md:mb-0 self-center`}>
<img src={'/assets/svgs/pterodactyl.svg'} css={tw`block w-48 md:w-64 mx-auto`} /> <img src={'/assets/svgs/pterodactyl.svg'} css={tw`block w-48 md:w-64 mx-auto`} />
</div> </div>
<div css={tw`flex-1`}> <div css={tw`flex-1`}>{props.children}</div>
{props.children}
</div>
</div> </div>
</Form> </Form>
<p css={tw`text-center text-neutral-500 text-xs mt-4`}> <p css={tw`text-center text-neutral-500 text-xs mt-4`}>
&copy; 2015 - {(new Date()).getFullYear()}&nbsp; &copy; 2015 - {new Date().getFullYear()}&nbsp;
<a <a
rel={'noopener nofollow noreferrer'} rel={'noopener nofollow noreferrer'}
href={'https://pterodactyl.io'} href={'https://pterodactyl.io'}

View file

@ -36,7 +36,7 @@ export default ({ match, location }: RouteComponentProps<{ token: string }>) =>
// @ts-ignore // @ts-ignore
window.location = '/'; window.location = '/';
}) })
.catch(error => { .catch((error) => {
console.error(error); console.error(error);
setSubmitting(false); setSubmitting(false);
@ -52,7 +52,8 @@ export default ({ match, location }: RouteComponentProps<{ token: string }>) =>
passwordConfirmation: '', passwordConfirmation: '',
}} }}
validationSchema={object().shape({ validationSchema={object().shape({
password: string().required('A new password is required.') password: string()
.required('A new password is required.')
.min(8, 'Your new password should be at least 8 characters in length.'), .min(8, 'Your new password should be at least 8 characters in length.'),
passwordConfirmation: string() passwordConfirmation: string()
.required('Your new password does not match.') .required('Your new password does not match.')
@ -61,10 +62,7 @@ export default ({ match, location }: RouteComponentProps<{ token: string }>) =>
})} })}
> >
{({ isSubmitting }) => ( {({ isSubmitting }) => (
<LoginFormContainer <LoginFormContainer title={'Reset Password'} css={tw`w-full flex`}>
title={'Reset Password'}
css={tw`w-full flex`}
>
<div> <div>
<label>Email</label> <label>Email</label>
<Input value={email} isLight disabled /> <Input value={email} isLight disabled />
@ -79,20 +77,10 @@ export default ({ match, location }: RouteComponentProps<{ token: string }>) =>
/> />
</div> </div>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
<Field <Field light label={'Confirm New Password'} name={'passwordConfirmation'} type={'password'} />
light
label={'Confirm New Password'}
name={'passwordConfirmation'}
type={'password'}
/>
</div> </div>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
<Button <Button size={'xlarge'} type={'submit'} disabled={isSubmitting} isLoading={isSubmitting}>
size={'xlarge'}
type={'submit'}
disabled={isSubmitting}
isLoading={isSubmitting}
>
Reset Password Reset Password
</Button> </Button>
</div> </div>

View file

@ -23,9 +23,9 @@ export default () => {
useEffect(() => { useEffect(() => {
getApiKeys() getApiKeys()
.then(keys => setKeys(keys)) .then((keys) => setKeys(keys))
.then(() => setLoading(false)) .then(() => setLoading(false))
.catch(error => clearAndAddHttpError(error)); .catch((error) => clearAndAddHttpError(error));
}, []); }, []);
const doDeletion = (identifier: string) => { const doDeletion = (identifier: string) => {
@ -33,10 +33,8 @@ export default () => {
clearAndAddHttpError(); clearAndAddHttpError();
deleteApiKey(identifier) deleteApiKey(identifier)
.then(() => setKeys(s => ([ .then(() => setKeys((s) => [...(s || []).filter((key) => key.identifier !== identifier)]))
...(s || []).filter(key => key.identifier !== identifier), .catch((error) => clearAndAddHttpError(error))
])))
.catch(error => clearAndAddHttpError(error))
.then(() => { .then(() => {
setLoading(false); setLoading(false);
setDeleteIdentifier(''); setDeleteIdentifier('');
@ -48,7 +46,7 @@ export default () => {
<FlashMessageRender byKey={'account'} /> <FlashMessageRender byKey={'account'} />
<div css={tw`md:flex flex-nowrap my-10`}> <div css={tw`md:flex flex-nowrap my-10`}>
<ContentBox title={'Create API Key'} css={tw`flex-none w-full md:w-1/2`}> <ContentBox title={'Create API Key'} css={tw`flex-none w-full md:w-1/2`}>
<CreateApiKeyForm onKeyCreated={key => setKeys(s => ([ ...s!, key ]))}/> <CreateApiKeyForm onKeyCreated={(key) => setKeys((s) => [...s!, key])} />
</ContentBox> </ContentBox>
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}> <ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
<SpinnerOverlay visible={loading} /> <SpinnerOverlay visible={loading} />
@ -61,12 +59,11 @@ export default () => {
> >
All requests using the <Code>{deleteIdentifier}</Code> key will be invalidated. All requests using the <Code>{deleteIdentifier}</Code> key will be invalidated.
</Dialog.Confirm> </Dialog.Confirm>
{ {keys.length === 0 ? (
keys.length === 0 ?
<p css={tw`text-center text-sm`}> <p css={tw`text-center text-sm`}>
{loading ? 'Loading...' : 'No API keys exist for this account.'} {loading ? 'Loading...' : 'No API keys exist for this account.'}
</p> </p>
: ) : (
keys.map((key, index) => ( keys.map((key, index) => (
<GreyRowBox <GreyRowBox
key={key.identifier} key={key.identifier}
@ -81,14 +78,9 @@ export default () => {
</p> </p>
</div> </div>
<p css={tw`text-sm ml-4 hidden md:block`}> <p css={tw`text-sm ml-4 hidden md:block`}>
<code css={tw`font-mono py-1 px-2 bg-neutral-900 rounded`}> <code css={tw`font-mono py-1 px-2 bg-neutral-900 rounded`}>{key.identifier}</code>
{key.identifier}
</code>
</p> </p>
<button <button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteIdentifier(key.identifier)}>
css={tw`ml-4 p-2 text-sm`}
onClick={() => setDeleteIdentifier(key.identifier)}
>
<FontAwesomeIcon <FontAwesomeIcon
icon={faTrashAlt} icon={faTrashAlt}
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`} css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
@ -96,7 +88,7 @@ export default () => {
</button> </button>
</GreyRowBox> </GreyRowBox>
)) ))
} )}
</ContentBox> </ContentBox>
</div> </div>
</PageContentBlock> </PageContentBlock>

View file

@ -31,28 +31,23 @@ export default () => {
return ( return (
<PageContentBlock title={'Account Overview'}> <PageContentBlock title={'Account Overview'}>
{state?.twoFactorRedirect && {state?.twoFactorRedirect && (
<MessageBox title={'2-Factor Required'} type={'error'}> <MessageBox title={'2-Factor Required'} type={'error'}>
Your account must have two-factor authentication enabled in order to continue. Your account must have two-factor authentication enabled in order to continue.
</MessageBox> </MessageBox>
} )}
<Container css={[tw`lg:grid lg:grid-cols-3 mb-10`, state?.twoFactorRedirect ? tw`mt-4` : tw`mt-10`]}> <Container css={[tw`lg:grid lg:grid-cols-3 mb-10`, state?.twoFactorRedirect ? tw`mt-4` : tw`mt-10`]}>
<ContentBox title={'Update Password'} showFlashes={'account:password'}> <ContentBox title={'Update Password'} showFlashes={'account:password'}>
<UpdatePasswordForm /> <UpdatePasswordForm />
</ContentBox> </ContentBox>
<ContentBox <ContentBox css={tw`mt-8 sm:mt-0 sm:ml-8`} title={'Update Email Address'} showFlashes={'account:email'}>
css={tw`mt-8 sm:mt-0 sm:ml-8`}
title={'Update Email Address'}
showFlashes={'account:email'}
>
<UpdateEmailAddressForm /> <UpdateEmailAddressForm />
</ContentBox> </ContentBox>
<ContentBox css={tw`md:ml-8 mt-8 md:mt-0`} title={'Configure Two Factor'}> <ContentBox css={tw`md:ml-8 mt-8 md:mt-0`} title={'Configure Two Factor'}>
<ConfigureTwoFactorForm /> <ConfigureTwoFactorForm />
</ContentBox> </ContentBox>
</Container> </Container>
</PageContentBlock> </PageContentBlock>
); );
}; };

View file

@ -20,7 +20,9 @@ const ApiKeyModal = ({ apiKey }: Props) => {
shown again. shown again.
</p> </p>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}> <pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<CopyOnClick text={apiKey}><code css={tw`font-mono`}>{apiKey}</code></CopyOnClick> <CopyOnClick text={apiKey}>
<code css={tw`font-mono`}>{apiKey}</code>
</CopyOnClick>
</pre> </pre>
<div css={tw`flex justify-end mt-6`}> <div css={tw`flex justify-end mt-6`}>
<Button type={'button'} onClick={() => dismiss()}> <Button type={'button'} onClick={() => dismiss()}>

View file

@ -18,15 +18,15 @@ export default () => {
const { search } = useLocation(); const { search } = useLocation();
const defaultPage = Number(new URLSearchParams(search).get('page') || '1'); const defaultPage = Number(new URLSearchParams(search).get('page') || '1');
const [ page, setPage ] = useState((!isNaN(defaultPage) && defaultPage > 0) ? defaultPage : 1); const [page, setPage] = useState(!isNaN(defaultPage) && defaultPage > 0 ? defaultPage : 1);
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const uuid = useStoreState(state => state.user.data!.uuid); const uuid = useStoreState((state) => state.user.data!.uuid);
const rootAdmin = useStoreState(state => state.user.data!.rootAdmin); const rootAdmin = useStoreState((state) => state.user.data!.rootAdmin);
const [showOnlyAdmin, setShowOnlyAdmin] = usePersistedState(`${uuid}:show_all_servers`, false); const [showOnlyAdmin, setShowOnlyAdmin] = usePersistedState(`${uuid}:show_all_servers`, false);
const { data: servers, error } = useSWR<PaginatedResult<Server>>( const { data: servers, error } = useSWR<PaginatedResult<Server>>(
[ '/api/client/servers', (showOnlyAdmin && rootAdmin), page ], ['/api/client/servers', showOnlyAdmin && rootAdmin, page],
() => getServers({ page, type: (showOnlyAdmin && rootAdmin) ? 'admin' : undefined }), () => getServers({ page, type: showOnlyAdmin && rootAdmin ? 'admin' : undefined })
); );
useEffect(() => { useEffect(() => {
@ -50,42 +50,37 @@ export default () => {
return ( return (
<PageContentBlock title={'Dashboard'} showFlashKey={'dashboard'}> <PageContentBlock title={'Dashboard'} showFlashKey={'dashboard'}>
{rootAdmin && {rootAdmin && (
<div css={tw`mb-2 flex justify-end items-center`}> <div css={tw`mb-2 flex justify-end items-center`}>
<p css={tw`uppercase text-xs text-neutral-400 mr-2`}> <p css={tw`uppercase text-xs text-neutral-400 mr-2`}>
{showOnlyAdmin ? 'Showing others\' servers' : 'Showing your servers'} {showOnlyAdmin ? "Showing others' servers" : 'Showing your servers'}
</p> </p>
<Switch <Switch
name={'show_all_servers'} name={'show_all_servers'}
defaultChecked={showOnlyAdmin} defaultChecked={showOnlyAdmin}
onChange={() => setShowOnlyAdmin(s => !s)} onChange={() => setShowOnlyAdmin((s) => !s)}
/> />
</div> </div>
}
{!servers ?
<Spinner centered size={'large'}/>
:
<Pagination data={servers} onPageSelect={setPage}>
{({ items }) => (
items.length > 0 ?
items.map((server, index) => (
<ServerRow
key={server.uuid}
server={server}
css={index > 0 ? tw`mt-2` : undefined}
/>
))
:
<p css={tw`text-center text-sm text-neutral-400`}>
{showOnlyAdmin ?
'There are no other servers to display.'
:
'There are no servers associated with your account.'
}
</p>
)} )}
</Pagination> {!servers ? (
<Spinner centered size={'large'} />
) : (
<Pagination data={servers} onPageSelect={setPage}>
{({ items }) =>
items.length > 0 ? (
items.map((server, index) => (
<ServerRow key={server.uuid} server={server} css={index > 0 ? tw`mt-2` : undefined} />
))
) : (
<p css={tw`text-center text-sm text-neutral-400`}>
{showOnlyAdmin
? 'There are no other servers to display.'
: 'There are no servers associated with your account.'}
</p>
)
} }
</Pagination>
)}
</PageContentBlock> </PageContentBlock>
); );
}; };

View file

@ -13,15 +13,18 @@ import isEqual from 'react-fast-compare';
// Determines if the current value is in an alarm threshold so we can show it in red rather // Determines if the current value is in an alarm threshold so we can show it in red rather
// than the more faded default style. // than the more faded default style.
const isAlarmState = (current: number, limit: number): boolean => limit > 0 && (current / (limit * 1024 * 1024) >= 0.90); const isAlarmState = (current: number, limit: number): boolean => limit > 0 && current / (limit * 1024 * 1024) >= 0.9;
const Icon = memo(styled(FontAwesomeIcon)<{ $alarm: boolean }>` const Icon = memo(
${props => props.$alarm ? tw`text-red-400` : tw`text-neutral-500`}; styled(FontAwesomeIcon)<{ $alarm: boolean }>`
`, isEqual); ${(props) => (props.$alarm ? tw`text-red-400` : tw`text-neutral-500`)};
`,
isEqual
);
const IconDescription = styled.p<{ $alarm: boolean }>` const IconDescription = styled.p<{ $alarm: boolean }>`
${tw`text-sm ml-2`}; ${tw`text-sm ml-2`};
${props => props.$alarm ? tw`text-white` : tw`text-neutral-400`}; ${(props) => (props.$alarm ? tw`text-white` : tw`text-neutral-400`)};
`; `;
const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>` const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>`
@ -31,7 +34,12 @@ const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | unde
${tw`w-2 bg-red-500 absolute right-0 z-20 rounded-full m-1 opacity-50 transition-all duration-150`}; ${tw`w-2 bg-red-500 absolute right-0 z-20 rounded-full m-1 opacity-50 transition-all duration-150`};
height: calc(100% - 0.5rem); height: calc(100% - 0.5rem);
${({ $status }) => (!$status || $status === 'offline') ? tw`bg-red-500` : ($status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`)}; ${({ $status }) =>
!$status || $status === 'offline'
? tw`bg-red-500`
: $status === 'running'
? tw`bg-green-500`
: tw`bg-yellow-500`};
} }
&:hover .status-bar { &:hover .status-bar {
@ -44,9 +52,10 @@ export default ({ server, className }: { server: Server; className?: string }) =
const [isSuspended, setIsSuspended] = useState(server.status === 'suspended'); const [isSuspended, setIsSuspended] = useState(server.status === 'suspended');
const [stats, setStats] = useState<ServerStats | null>(null); const [stats, setStats] = useState<ServerStats | null>(null);
const getStats = () => getServerResourceUsage(server.uuid) const getStats = () =>
.then(data => setStats(data)) getServerResourceUsage(server.uuid)
.catch(error => console.error(error)); .then((data) => setStats(data))
.catch((error) => console.error(error));
useEffect(() => { useEffect(() => {
setIsSuspended(stats?.isSuspended || server.status === 'suspended'); setIsSuspended(stats?.isSuspended || server.status === 'suspended');
@ -69,7 +78,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
const alarms = { cpu: false, memory: false, disk: false }; const alarms = { cpu: false, memory: false, disk: false };
if (stats) { if (stats) {
alarms.cpu = server.limits.cpu === 0 ? false : (stats.cpuUsagePercent >= (server.limits.cpu * 0.9)); alarms.cpu = server.limits.cpu === 0 ? false : stats.cpuUsagePercent >= server.limits.cpu * 0.9;
alarms.memory = isAlarmState(stats.memoryUsageInBytes, server.limits.memory); alarms.memory = isAlarmState(stats.memoryUsageInBytes, server.limits.memory);
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk); alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
} }
@ -86,52 +95,49 @@ export default ({ server, className }: { server: Server; className?: string }) =
</div> </div>
<div> <div>
<p css={tw`text-lg break-words`}>{server.name}</p> <p css={tw`text-lg break-words`}>{server.name}</p>
{!!server.description && {!!server.description && (
<p css={tw`text-sm text-neutral-300 break-words line-clamp-2`}>{server.description}</p> <p css={tw`text-sm text-neutral-300 break-words line-clamp-2`}>{server.description}</p>
} )}
</div> </div>
</div> </div>
<div css={tw`flex-1 ml-4 lg:block lg:col-span-2 hidden`}> <div css={tw`flex-1 ml-4 lg:block lg:col-span-2 hidden`}>
<div css={tw`flex justify-center`}> <div css={tw`flex justify-center`}>
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`} /> <FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`} />
<p css={tw`text-sm text-neutral-400 ml-2`}> <p css={tw`text-sm text-neutral-400 ml-2`}>
{ {server.allocations
server.allocations.filter(alloc => alloc.isDefault).map(allocation => ( .filter((alloc) => alloc.isDefault)
.map((allocation) => (
<React.Fragment key={allocation.ip + allocation.port.toString()}> <React.Fragment key={allocation.ip + allocation.port.toString()}>
{allocation.alias || ip(allocation.ip)}:{allocation.port} {allocation.alias || ip(allocation.ip)}:{allocation.port}
</React.Fragment> </React.Fragment>
)) ))}
}
</p> </p>
</div> </div>
</div> </div>
<div css={tw`hidden col-span-7 lg:col-span-4 sm:flex items-baseline justify-center`}> <div css={tw`hidden col-span-7 lg:col-span-4 sm:flex items-baseline justify-center`}>
{(!stats || isSuspended) ? {!stats || isSuspended ? (
isSuspended ? isSuspended ? (
<div css={tw`flex-1 text-center`}> <div css={tw`flex-1 text-center`}>
<span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}> <span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}>
{server.status === 'suspended' ? 'Suspended' : 'Connection Error'} {server.status === 'suspended' ? 'Suspended' : 'Connection Error'}
</span> </span>
</div> </div>
: ) : server.isTransferring || server.status ? (
(server.isTransferring || server.status) ?
<div css={tw`flex-1 text-center`}> <div css={tw`flex-1 text-center`}>
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}> <span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
{server.isTransferring ? {server.isTransferring
'Transferring' ? 'Transferring'
: : server.status === 'installing'
server.status === 'installing' ? 'Installing' : ( ? 'Installing'
server.status === 'restoring_backup' ? : server.status === 'restoring_backup'
'Restoring Backup' ? 'Restoring Backup'
: : 'Unavailable'}
'Unavailable'
)
}
</span> </span>
</div> </div>
: ) : (
<Spinner size={'small'} /> <Spinner size={'small'} />
: )
) : (
<React.Fragment> <React.Fragment>
<div css={tw`flex-1 ml-4 sm:block hidden`}> <div css={tw`flex-1 ml-4 sm:block hidden`}>
<div css={tw`flex justify-center`}> <div css={tw`flex justify-center`}>
@ -161,7 +167,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {diskLimit}</p> <p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {diskLimit}</p>
</div> </div>
</React.Fragment> </React.Fragment>
} )}
</div> </div>
<div className={'status-bar'} /> <div className={'status-bar'} />
</StatusIndicatorBox> </StatusIndicatorBox>

View file

@ -23,7 +23,7 @@ export default () => {
}); });
useEffect(() => { useEffect(() => {
setFilters(value => ({ ...value, filters: { ip: hash.ip, event: hash.event } })); setFilters((value) => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
}, [hash]); }, [hash]);
useEffect(() => { useEffect(() => {
@ -33,36 +33,40 @@ export default () => {
return ( return (
<PageContentBlock title={'Account Activity Log'}> <PageContentBlock title={'Account Activity Log'}>
<FlashMessageRender byKey={'account'} /> <FlashMessageRender byKey={'account'} />
{(filters.filters?.event || filters.filters?.ip) && {(filters.filters?.event || filters.filters?.ip) && (
<div className={'flex justify-end mb-2'}> <div className={'flex justify-end mb-2'}>
<Link <Link
to={'#'} to={'#'}
className={classNames(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')} className={classNames(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')}
onClick={() => setFilters(value => ({ ...value, filters: {} }))} onClick={() => setFilters((value) => ({ ...value, filters: {} }))}
> >
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'} /> Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'} />
</Link> </Link>
</div> </div>
} )}
{!data && isValidating ? {!data && isValidating ? (
<Spinner centered /> <Spinner centered />
: ) : (
<div className={'bg-gray-700'}> <div className={'bg-gray-700'}>
{data?.items.map((activity) => ( {data?.items.map((activity) => (
<ActivityLogEntry key={activity.timestamp.toString() + activity.event} activity={activity}> <ActivityLogEntry key={activity.timestamp.toString() + activity.event} activity={activity}>
{typeof activity.properties.useragent === 'string' && {typeof activity.properties.useragent === 'string' && (
<Tooltip content={activity.properties.useragent} placement={'top'}> <Tooltip content={activity.properties.useragent} placement={'top'}>
<span><DesktopComputerIcon/></span> <span>
<DesktopComputerIcon />
</span>
</Tooltip> </Tooltip>
} )}
</ActivityLogEntry> </ActivityLogEntry>
))} ))}
</div> </div>
} )}
{data && <PaginationFooter {data && (
<PaginationFooter
pagination={data.pagination} pagination={data.pagination}
onPageSelect={page => setFilters(value => ({ ...value, page }))} onPageSelect={(page) => setFilters((value) => ({ ...value, page }))}
/>} />
)}
</PageContentBlock> </PageContentBlock>
); );
}; };

View file

@ -12,18 +12,16 @@ export default () => {
return ( return (
<div> <div>
{visible && ( {visible &&
isEnabled ? (isEnabled ? (
<DisableTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)} /> <DisableTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)} />
: ) : (
<SetupTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)} /> <SetupTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)} />
)} ))}
<p css={tw`text-sm`}> <p css={tw`text-sm`}>
{isEnabled ? {isEnabled
'Two-factor authentication is currently enabled on your account.' ? 'Two-factor authentication is currently enabled on your account.'
: : 'You do not currently have two-factor authentication enabled on your account. Click the button below to begin configuring it.'}
'You do not currently have two-factor authentication enabled on your account. Click the button below to begin configuring it.'
}
</p> </p>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
<Button color={'red'} isSecondary onClick={() => setVisible(true)}> <Button color={'red'} isSecondary onClick={() => setVisible(true)}>

View file

@ -19,7 +19,9 @@ interface Values {
allowedIps: string; allowedIps: string;
} }
const CustomTextarea = styled(Textarea)`${tw`h-32`}`; const CustomTextarea = styled(Textarea)`
${tw`h-32`}
`;
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => { export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
@ -34,7 +36,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
setApiKey(`${key.identifier}${secretToken}`); setApiKey(`${key.identifier}${secretToken}`);
onKeyCreated(key); onKeyCreated(key);
}) })
.catch(error => { .catch((error) => {
console.error(error); console.error(error);
addError({ key: 'account', message: httpErrorToHuman(error) }); addError({ key: 'account', message: httpErrorToHuman(error) });
@ -44,11 +46,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
return ( return (
<> <>
<ApiKeyModal <ApiKeyModal visible={apiKey.length > 0} onModalDismissed={() => setApiKey('')} apiKey={apiKey} />
visible={apiKey.length > 0}
onModalDismissed={() => setApiKey('')}
apiKey={apiKey}
/>
<Formik <Formik
onSubmit={submit} onSubmit={submit}
initialValues={{ description: '', allowedIps: '' }} initialValues={{ description: '', allowedIps: '' }}
@ -71,7 +69,9 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
<FormikFieldWrapper <FormikFieldWrapper
label={'Allowed IPs'} label={'Allowed IPs'}
name={'allowedIps'} name={'allowedIps'}
description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'} description={
'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'
}
> >
<Field name={'allowedIps'} as={CustomTextarea} /> <Field name={'allowedIps'} as={CustomTextarea} />
</FormikFieldWrapper> </FormikFieldWrapper>

View file

@ -27,7 +27,7 @@ const DisableTwoFactorModal = () => {
updateUserData({ useTotp: false }); updateUserData({ useTotp: false });
dismiss(); dismiss();
}) })
.catch(error => { .catch((error) => {
console.error(error); console.error(error);
clearAndAddHttpError({ error, key: 'account:two-factor' }); clearAndAddHttpError({ error, key: 'account:two-factor' });
@ -54,7 +54,9 @@ const DisableTwoFactorModal = () => {
name={'password'} name={'password'}
type={'password'} type={'password'}
label={'Current Password'} label={'Current Password'}
description={'In order to disable two-factor authentication you will need to provide your account password.'} description={
'In order to disable two-factor authentication you will need to provide your account password.'
}
autoFocus autoFocus
/> />
<div css={tw`mt-6 text-right`}> <div css={tw`mt-6 text-right`}>

View file

@ -29,31 +29,31 @@ const SetupTwoFactorModal = () => {
useEffect(() => { useEffect(() => {
getTwoFactorTokenData() getTwoFactorTokenData()
.then(setToken) .then(setToken)
.catch(error => { .catch((error) => {
console.error(error); console.error(error);
clearAndAddHttpError({ error, key: 'account:two-factor' }); clearAndAddHttpError({ error, key: 'account:two-factor' });
}); });
}, []); }, []);
const submit = ({ code }: Values, { setSubmitting }: FormikHelpers<Values>) => { const submit = ({ code }: Values, { setSubmitting }: FormikHelpers<Values>) => {
setPropOverrides(state => ({ ...state, showSpinnerOverlay: true })); setPropOverrides((state) => ({ ...state, showSpinnerOverlay: true }));
enableAccountTwoFactor(code) enableAccountTwoFactor(code)
.then(tokens => { .then((tokens) => {
setRecoveryTokens(tokens); setRecoveryTokens(tokens);
}) })
.catch(error => { .catch((error) => {
console.error(error); console.error(error);
clearAndAddHttpError({ error, key: 'account:two-factor' }); clearAndAddHttpError({ error, key: 'account:two-factor' });
}) })
.then(() => { .then(() => {
setSubmitting(false); setSubmitting(false);
setPropOverrides(state => ({ ...state, showSpinnerOverlay: false })); setPropOverrides((state) => ({ ...state, showSpinnerOverlay: false }));
}); });
}; };
useEffect(() => { useEffect(() => {
setPropOverrides(state => ({ setPropOverrides((state) => ({
...state, ...state,
closeOnEscape: !recoveryTokens.length, closeOnEscape: !recoveryTokens.length,
closeOnBackground: !recoveryTokens.length, closeOnBackground: !recoveryTokens.length,
@ -76,20 +76,24 @@ const SetupTwoFactorModal = () => {
.matches(/^(\d){6}$/, 'Authenticator code must be 6 digits.'), .matches(/^(\d){6}$/, 'Authenticator code must be 6 digits.'),
})} })}
> >
{recoveryTokens.length > 0 ? {recoveryTokens.length > 0 ? (
<> <>
<h2 css={tw`text-2xl mb-4`}>Two-factor authentication enabled</h2> <h2 css={tw`text-2xl mb-4`}>Two-factor authentication enabled</h2>
<p css={tw`text-neutral-300`}> <p css={tw`text-neutral-300`}>
Two-factor authentication has been enabled on your account. Should you lose access to Two-factor authentication has been enabled on your account. Should you lose access to your
your authenticator device, you&apos;ll need to use one of the codes displayed below in order to access your authenticator device, you&apos;ll need to use one of the codes displayed below in order to
account. access your account.
</p> </p>
<p css={tw`text-neutral-300 mt-4`}> <p css={tw`text-neutral-300 mt-4`}>
<strong>These codes will not be displayed again.</strong> Please take note of them now <strong>These codes will not be displayed again.</strong> Please take note of them now by
by storing them in a secure repository such as a password manager. storing them in a secure repository such as a password manager.
</p> </p>
<pre css={tw`text-sm mt-4 rounded font-mono bg-neutral-900 p-4`}> <pre css={tw`text-sm mt-4 rounded font-mono bg-neutral-900 p-4`}>
{recoveryTokens.map(token => <code key={token} css={tw`block mb-1`}>{token}</code>)} {recoveryTokens.map((token) => (
<code key={token} css={tw`block mb-1`}>
{token}
</code>
))}
</pre> </pre>
<div css={tw`text-right`}> <div css={tw`text-right`}>
<Button css={tw`mt-6`} onClick={dismiss}> <Button css={tw`mt-6`} onClick={dismiss}>
@ -97,24 +101,26 @@ const SetupTwoFactorModal = () => {
</Button> </Button>
</div> </div>
</> </>
: ) : (
<Form css={tw`mb-0`}> <Form css={tw`mb-0`}>
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'} /> <FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'} />
<div css={tw`flex flex-wrap`}> <div css={tw`flex flex-wrap`}>
<div css={tw`w-full md:flex-1`}> <div css={tw`w-full md:flex-1`}>
<div css={tw`w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto`}> <div css={tw`w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto`}>
{!token ? {!token ? (
<img <img
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='} src={
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
}
css={tw`w-64 h-64 rounded`} css={tw`w-64 h-64 rounded`}
/> />
: ) : (
<QRCode <QRCode
renderAs={'svg'} renderAs={'svg'}
value={token.image_url_data} value={token.image_url_data}
css={tw`w-full h-full shadow-none rounded-none`} css={tw`w-full h-full shadow-none rounded-none`}
/> />
} )}
</div> </div>
</div> </div>
<div css={tw`w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col`}> <div css={tw`w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col`}>
@ -124,20 +130,20 @@ const SetupTwoFactorModal = () => {
name={'code'} name={'code'}
type={'text'} type={'text'}
title={'Code From Authenticator'} title={'Code From Authenticator'}
description={'Enter the code from your authenticator device after scanning the QR image.'} description={
'Enter the code from your authenticator device after scanning the QR image.'
}
/> />
{token && {token && (
<div css={tw`mt-4 pt-4 border-t border-neutral-500 text-neutral-200`}> <div css={tw`mt-4 pt-4 border-t border-neutral-500 text-neutral-200`}>
Alternatively, enter the following token into your authenticator application: Alternatively, enter the following token into your authenticator application:
<CopyOnClick text={token.secret}> <CopyOnClick text={token.secret}>
<div css={tw`text-sm bg-neutral-900 rounded mt-2 py-2 px-4 font-mono`}> <div css={tw`text-sm bg-neutral-900 rounded mt-2 py-2 px-4 font-mono`}>
<code css={tw`font-mono`}> <code css={tw`font-mono`}>{token.secret}</code>
{token.secret}
</code>
</div> </div>
</CopyOnClick> </CopyOnClick>
</div> </div>
} )}
</div> </div>
<div css={tw`mt-6 md:mt-0 text-right`}> <div css={tw`mt-6 md:mt-0 text-right`}>
<Button>Setup</Button> <Button>Setup</Button>
@ -145,7 +151,7 @@ const SetupTwoFactorModal = () => {
</div> </div>
</div> </div>
</Form> </Form>
} )}
</Formik> </Formik>
); );
}; };

View file

@ -29,17 +29,21 @@ export default () => {
clearFlashes('account:email'); clearFlashes('account:email');
updateEmail({ ...values }) updateEmail({ ...values })
.then(() => addFlash({ .then(() =>
addFlash({
type: 'success', type: 'success',
key: 'account:email', key: 'account:email',
message: 'Your primary email has been updated.', message: 'Your primary email has been updated.',
})) })
.catch(error => addFlash({ )
.catch((error) =>
addFlash({
type: 'error', type: 'error',
key: 'account:email', key: 'account:email',
title: 'Error', title: 'Error',
message: httpErrorToHuman(error), message: httpErrorToHuman(error),
})) })
)
.then(() => { .then(() => {
resetForm(); resetForm();
setSubmitting(false); setSubmitting(false);
@ -47,22 +51,12 @@ export default () => {
}; };
return ( return (
<Formik <Formik onSubmit={submit} validationSchema={schema} initialValues={{ email: user!.email, password: '' }}>
onSubmit={submit} {({ isSubmitting, isValid }) => (
validationSchema={schema}
initialValues={{ email: user!.email, password: '' }}
>
{
({ isSubmitting, isValid }) => (
<React.Fragment> <React.Fragment>
<SpinnerOverlay size={'large'} visible={isSubmitting} /> <SpinnerOverlay size={'large'} visible={isSubmitting} />
<Form css={tw`m-0`}> <Form css={tw`m-0`}>
<Field <Field id={'current_email'} type={'email'} name={'email'} label={'Email'} />
id={'current_email'}
type={'email'}
name={'email'}
label={'Email'}
/>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
<Field <Field
id={'confirm_password'} id={'confirm_password'}
@ -78,8 +72,7 @@ export default () => {
</div> </div>
</Form> </Form>
</React.Fragment> </React.Fragment>
) )}
}
</Formik> </Formik>
); );
}; };

View file

@ -19,9 +19,13 @@ interface Values {
const schema = Yup.object().shape({ const schema = Yup.object().shape({
current: Yup.string().min(1).required('You must provide your current password.'), current: Yup.string().min(1).required('You must provide your current password.'),
password: Yup.string().min(8).required(), password: Yup.string().min(8).required(),
confirmPassword: Yup.string().test('password', 'Password confirmation does not match the password you entered.', function (value) { confirmPassword: Yup.string().test(
'password',
'Password confirmation does not match the password you entered.',
function (value) {
return value === this.parent.password; return value === this.parent.password;
}), }
),
}); });
export default () => { export default () => {
@ -39,12 +43,14 @@ export default () => {
// @ts-ignore // @ts-ignore
window.location = '/auth/login'; window.location = '/auth/login';
}) })
.catch(error => addFlash({ .catch((error) =>
addFlash({
key: 'account:password', key: 'account:password',
type: 'error', type: 'error',
title: 'Error', title: 'Error',
message: httpErrorToHuman(error), message: httpErrorToHuman(error),
})) })
)
.then(() => setSubmitting(false)); .then(() => setSubmitting(false));
}; };
@ -55,8 +61,7 @@ export default () => {
validationSchema={schema} validationSchema={schema}
initialValues={{ current: '', password: '', confirmPassword: '' }} initialValues={{ current: '', password: '', confirmPassword: '' }}
> >
{ {({ isSubmitting, isValid }) => (
({ isSubmitting, isValid }) => (
<React.Fragment> <React.Fragment>
<SpinnerOverlay size={'large'} visible={isSubmitting} /> <SpinnerOverlay size={'large'} visible={isSubmitting} />
<Form css={tw`m-0`}> <Form css={tw`m-0`}>
@ -72,7 +77,9 @@ export default () => {
type={'password'} type={'password'}
name={'password'} name={'password'}
label={'New Password'} label={'New Password'}
description={'Your new password should be at least 8 characters in length and unique to this website.'} description={
'Your new password should be at least 8 characters in length and unique to this website.'
}
/> />
</div> </div>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
@ -90,8 +97,7 @@ export default () => {
</div> </div>
</Form> </Form>
</React.Fragment> </React.Fragment>
) )}
}
</Formik> </Formik>
</React.Fragment> </React.Fragment>
); );

View file

@ -18,13 +18,7 @@ export default () => {
return ( return (
<> <>
{visible && {visible && <SearchModal appear visible={visible} onDismissed={() => setVisible(false)} />}
<SearchModal
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
}
<Tooltip placement={'bottom'} content={'Search'}> <Tooltip placement={'bottom'} content={'Search'}>
<div className={'navigation-link'} onClick={() => setVisible(true)}> <div className={'navigation-link'} onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faSearch} /> <FontAwesomeIcon icon={faSearch} />

View file

@ -47,17 +47,19 @@ const SearchWatcher = () => {
export default ({ ...props }: Props) => { export default ({ ...props }: Props) => {
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const isAdmin = useStoreState(state => state.user.data!.rootAdmin); const isAdmin = useStoreState((state) => state.user.data!.rootAdmin);
const [servers, setServers] = useState<Server[]>([]); const [servers, setServers] = useState<Server[]>([]);
const { clearAndAddHttpError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const { clearAndAddHttpError, clearFlashes } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes
);
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => { const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('search'); clearFlashes('search');
// if (ref.current) ref.current.focus(); // if (ref.current) ref.current.focus();
getServers({ query: term, type: isAdmin ? 'admin-all' : undefined }) getServers({ query: term, type: isAdmin ? 'admin-all' : undefined })
.then(servers => setServers(servers.items.filter((_, index) => index < 5))) .then((servers) => setServers(servers.items.filter((_, index) => index < 5)))
.catch(error => { .catch((error) => {
console.error(error); console.error(error);
clearAndAddHttpError({ key: 'search', error }); clearAndAddHttpError({ key: 'search', error });
}) })
@ -96,10 +98,9 @@ export default ({ ...props }: Props) => {
</InputSpinner> </InputSpinner>
</FormikFieldWrapper> </FormikFieldWrapper>
</Form> </Form>
{servers.length > 0 && {servers.length > 0 && (
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
{ {servers.map((server) => (
servers.map(server => (
<ServerResult <ServerResult
key={server.uuid} key={server.uuid}
to={`/server/${server.id}`} to={`/server/${server.id}`}
@ -108,11 +109,13 @@ export default ({ ...props }: Props) => {
<div css={tw`flex-1 mr-4`}> <div css={tw`flex-1 mr-4`}>
<p css={tw`text-sm`}>{server.name}</p> <p css={tw`text-sm`}>{server.name}</p>
<p css={tw`mt-1 text-xs text-neutral-400`}> <p css={tw`mt-1 text-xs text-neutral-400`}>
{ {server.allocations
server.allocations.filter(alloc => alloc.isDefault).map(allocation => ( .filter((alloc) => alloc.isDefault)
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || ip(allocation.ip)}:{allocation.port}</span> .map((allocation) => (
)) <span key={allocation.ip + allocation.port.toString()}>
} {allocation.alias || ip(allocation.ip)}:{allocation.port}
</span>
))}
</p> </p>
</div> </div>
<div css={tw`flex-none text-right`}> <div css={tw`flex-none text-right`}>
@ -121,10 +124,9 @@ export default ({ ...props }: Props) => {
</span> </span>
</div> </div>
</ServerResult> </ServerResult>
)) ))}
}
</div> </div>
} )}
</Modal> </Modal>
)} )}
</Formik> </Formik>

View file

@ -33,12 +33,11 @@ export default () => {
</ContentBox> </ContentBox>
<ContentBox title={'SSH Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}> <ContentBox title={'SSH Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
<SpinnerOverlay visible={!data && isValidating} /> <SpinnerOverlay visible={!data && isValidating} />
{ {!data || !data.length ? (
!data || !data.length ?
<p css={tw`text-center text-sm`}> <p css={tw`text-center text-sm`}>
{!data ? 'Loading...' : 'No SSH Keys exist for this account.'} {!data ? 'Loading...' : 'No SSH Keys exist for this account.'}
</p> </p>
: ) : (
data.map((key, index) => ( data.map((key, index) => (
<GreyRowBox <GreyRowBox
key={key.fingerprint} key={key.fingerprint}
@ -47,9 +46,7 @@ export default () => {
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`} /> <FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`} />
<div css={tw`flex-1`}> <div css={tw`flex-1`}>
<p css={tw`text-sm break-words font-medium`}>{key.name}</p> <p css={tw`text-sm break-words font-medium`}>{key.name}</p>
<p css={tw`text-xs mt-1 font-mono truncate`}> <p css={tw`text-xs mt-1 font-mono truncate`}>SHA256:{key.fingerprint}</p>
SHA256:{key.fingerprint}
</p>
<p css={tw`text-xs mt-1 text-neutral-300 uppercase`}> <p css={tw`text-xs mt-1 text-neutral-300 uppercase`}>
Added on:&nbsp; Added on:&nbsp;
{format(key.createdAt, 'MMM do, yyyy HH:mm')} {format(key.createdAt, 'MMM do, yyyy HH:mm')}
@ -58,7 +55,7 @@ export default () => {
<DeleteSSHKeyButton name={key.name} fingerprint={key.fingerprint} /> <DeleteSSHKeyButton name={key.name} fingerprint={key.fingerprint} />
</GreyRowBox> </GreyRowBox>
)) ))
} )}
</ContentBox> </ContentBox>
</div> </div>
</PageContentBlock> </PageContentBlock>

View file

@ -15,7 +15,9 @@ interface Values {
publicKey: string; publicKey: string;
} }
const CustomTextarea = styled(Textarea)`${tw`h-32`}`; const CustomTextarea = styled(Textarea)`
${tw`h-32`}
`;
export default () => { export default () => {
const { clearAndAddHttpError } = useFlashKey('account'); const { clearAndAddHttpError } = useFlashKey('account');

View file

@ -18,8 +18,7 @@ export default ({ name, fingerprint }: { name: string; fingerprint: string }) =>
Promise.all([ Promise.all([
mutate((data) => data?.filter((value) => value.fingerprint !== fingerprint), false), mutate((data) => data?.filter((value) => value.fingerprint !== fingerprint), false),
deleteSSHKey(fingerprint), deleteSSHKey(fingerprint),
]) ]).catch((error) => {
.catch((error) => {
mutate(undefined, true).catch(console.error); mutate(undefined, true).catch(console.error);
clearAndAddHttpError(error); clearAndAddHttpError(error);
}); });

View file

@ -3,14 +3,14 @@ import { Redirect, Route, RouteProps } from 'react-router';
import { useStoreState } from '@/state/hooks'; import { useStoreState } from '@/state/hooks';
export default ({ children, ...props }: Omit<RouteProps, 'render'>) => { export default ({ children, ...props }: Omit<RouteProps, 'render'>) => {
const isAuthenticated = useStoreState(state => !!state.user.data?.uuid); const isAuthenticated = useStoreState((state) => !!state.user.data?.uuid);
return ( return (
<Route <Route
{...props} {...props}
render={({ location }) => ( render={({ location }) =>
isAuthenticated ? children : <Redirect to={{ pathname: '/auth/login', state: { from: location } }} /> isAuthenticated ? children : <Redirect to={{ pathname: '/auth/login', state: { from: location } }} />
)} }
/> />
); );
}; };

View file

@ -13,15 +13,19 @@ interface Props {
const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>` const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
${tw`relative inline-block rounded p-2 uppercase tracking-wide text-sm transition-all duration-150 border`}; ${tw`relative inline-block rounded p-2 uppercase tracking-wide text-sm transition-all duration-150 border`};
${props => ((!props.isSecondary && !props.color) || props.color === 'primary') && css<Props>` ${(props) =>
${props => !props.isSecondary && tw`bg-primary-500 border-primary-600 border text-primary-50`}; ((!props.isSecondary && !props.color) || props.color === 'primary') &&
css<Props>`
${(props) => !props.isSecondary && tw`bg-primary-500 border-primary-600 border text-primary-50`};
&:hover:not(:disabled) { &:hover:not(:disabled) {
${tw`bg-primary-600 border-primary-700`}; ${tw`bg-primary-600 border-primary-700`};
} }
`}; `};
${props => props.color === 'grey' && css` ${(props) =>
props.color === 'grey' &&
css`
${tw`border-neutral-600 bg-neutral-500 text-neutral-50`}; ${tw`border-neutral-600 bg-neutral-500 text-neutral-50`};
&:hover:not(:disabled) { &:hover:not(:disabled) {
@ -29,71 +33,82 @@ const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
} }
`}; `};
${props => props.color === 'green' && css<Props>` ${(props) =>
props.color === 'green' &&
css<Props>`
${tw`border-green-600 bg-green-500 text-green-50`}; ${tw`border-green-600 bg-green-500 text-green-50`};
&:hover:not(:disabled) { &:hover:not(:disabled) {
${tw`bg-green-600 border-green-700`}; ${tw`bg-green-600 border-green-700`};
} }
${props => props.isSecondary && css` ${(props) =>
props.isSecondary &&
css`
&:active:not(:disabled) { &:active:not(:disabled) {
${tw`bg-green-600 border-green-700`}; ${tw`bg-green-600 border-green-700`};
} }
`}; `};
`}; `};
${props => props.color === 'red' && css<Props>` ${(props) =>
props.color === 'red' &&
css<Props>`
${tw`border-red-600 bg-red-500 text-red-50`}; ${tw`border-red-600 bg-red-500 text-red-50`};
&:hover:not(:disabled) { &:hover:not(:disabled) {
${tw`bg-red-600 border-red-700`}; ${tw`bg-red-600 border-red-700`};
} }
${props => props.isSecondary && css` ${(props) =>
props.isSecondary &&
css`
&:active:not(:disabled) { &:active:not(:disabled) {
${tw`bg-red-600 border-red-700`}; ${tw`bg-red-600 border-red-700`};
} }
`}; `};
`}; `};
${props => props.size === 'xsmall' && tw`px-2 py-1 text-xs`}; ${(props) => props.size === 'xsmall' && tw`px-2 py-1 text-xs`};
${props => (!props.size || props.size === 'small') && tw`px-4 py-2`}; ${(props) => (!props.size || props.size === 'small') && tw`px-4 py-2`};
${props => props.size === 'large' && tw`p-4 text-sm`}; ${(props) => props.size === 'large' && tw`p-4 text-sm`};
${props => props.size === 'xlarge' && tw`p-4 w-full`}; ${(props) => props.size === 'xlarge' && tw`p-4 w-full`};
${props => props.isSecondary && css<Props>` ${(props) =>
props.isSecondary &&
css<Props>`
${tw`border-neutral-600 bg-transparent text-neutral-200`}; ${tw`border-neutral-600 bg-transparent text-neutral-200`};
&:hover:not(:disabled) { &:hover:not(:disabled) {
${tw`border-neutral-500 text-neutral-100`}; ${tw`border-neutral-500 text-neutral-100`};
${props => props.color === 'red' && tw`bg-red-500 border-red-600 text-red-50`}; ${(props) => props.color === 'red' && tw`bg-red-500 border-red-600 text-red-50`};
${props => props.color === 'primary' && tw`bg-primary-500 border-primary-600 text-primary-50`}; ${(props) => props.color === 'primary' && tw`bg-primary-500 border-primary-600 text-primary-50`};
${props => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`}; ${(props) => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`};
} }
`}; `};
&:disabled { opacity: 0.55; cursor: default } &:disabled {
opacity: 0.55;
cursor: default;
}
`; `;
type ComponentProps = Omit<JSX.IntrinsicElements['button'], 'ref' | keyof Props> & Props; type ComponentProps = Omit<JSX.IntrinsicElements['button'], 'ref' | keyof Props> & Props;
const Button: React.FC<ComponentProps> = ({ children, isLoading, ...props }) => ( const Button: React.FC<ComponentProps> = ({ children, isLoading, ...props }) => (
<ButtonStyle {...props}> <ButtonStyle {...props}>
{isLoading && {isLoading && (
<div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}> <div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}>
<Spinner size={'small'} /> <Spinner size={'small'} />
</div> </div>
} )}
<span css={isLoading ? tw`text-transparent` : undefined}> <span css={isLoading ? tw`text-transparent` : undefined}>{children}</span>
{children}
</span>
</ButtonStyle> </ButtonStyle>
); );
type LinkProps = Omit<JSX.IntrinsicElements['a'], 'ref' | keyof Props> & Props; type LinkProps = Omit<JSX.IntrinsicElements['a'], 'ref' | keyof Props> & Props;
const LinkButton: React.FC<LinkProps> = props => <ButtonStyle as={'a'} {...props}/>; const LinkButton: React.FC<LinkProps> = (props) => <ButtonStyle as={'a'} {...props} />;
export { LinkButton, ButtonStyle }; export { LinkButton, ButtonStyle };
export default Button; export default Button;

View file

@ -14,12 +14,9 @@ const Can = ({ action, matchAny = false, renderOnError, children }: Props) => {
return ( return (
<> <>
{ {(matchAny && can.filter((p) => p).length > 0) || (!matchAny && can.every((p) => p))
((matchAny && can.filter(p => p).length > 0) || (!matchAny && can.every(p => p))) ? ? children
children : renderOnError}
:
renderOnError
}
</> </>
); );
}; };

View file

@ -29,7 +29,7 @@ const Checkbox = ({ name, value, className, ...props }: Props & InputProps) => (
type={'checkbox'} type={'checkbox'}
checked={(field.value || []).includes(value)} checked={(field.value || []).includes(value)}
onClick={() => form.setFieldTouched(field.name, true)} onClick={() => form.setFieldTouched(field.name, true)}
onChange={e => { onChange={(e) => {
const set = new Set(field.value); const set = new Set(field.value);
set.has(value) ? set.delete(value) : set.add(value); set.has(value) ? set.delete(value) : set.add(value);

View file

@ -98,7 +98,7 @@ const EditorContainer = styled.div`
} }
.CodeMirror-foldmarker { .CodeMirror-foldmarker {
color: #CBCCC6; color: #cbccc6;
text-shadow: none; text-shadow: none;
margin-left: 0.25rem; margin-left: 0.25rem;
margin-right: 0.25rem; margin-right: 0.25rem;

View file

@ -17,9 +17,7 @@ const ConfirmationModal: React.FC<Props> = ({ title, children, buttonText, onCon
return ( return (
<> <>
<h2 css={tw`text-2xl mb-6`}>{title}</h2> <h2 css={tw`text-2xl mb-6`}>{title}</h2>
<div css={tw`text-neutral-300`}> <div css={tw`text-neutral-300`}>{children}</div>
{children}
</div>
<div css={tw`flex flex-wrap items-center justify-end mt-8`}> <div css={tw`flex flex-wrap items-center justify-end mt-8`}>
<Button isSecondary onClick={() => dismiss()} css={tw`w-full sm:w-auto border-transparent`}> <Button isSecondary onClick={() => dismiss()} css={tw`w-full sm:w-auto border-transparent`}>
Cancel Cancel
@ -34,6 +32,6 @@ const ConfirmationModal: React.FC<Props> = ({ title, children, buttonText, onCon
ConfirmationModal.displayName = 'ConfirmationModal'; ConfirmationModal.displayName = 'ConfirmationModal';
export default asModal<Props>(props => ({ export default asModal<Props>((props) => ({
showSpinnerOverlay: props.showSpinnerOverlay, showSpinnerOverlay: props.showSpinnerOverlay,
}))(ConfirmationModal); }))(ConfirmationModal);

View file

@ -3,28 +3,22 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import tw from 'twin.macro'; import tw from 'twin.macro';
type Props = Readonly<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & { type Props = Readonly<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
title?: string; title?: string;
borderColor?: string; borderColor?: string;
showFlashes?: string | boolean; showFlashes?: string | boolean;
showLoadingOverlay?: boolean; showLoadingOverlay?: boolean;
}>; }
>;
const ContentBox = ({ title, borderColor, showFlashes, showLoadingOverlay, children, ...props }: Props) => ( const ContentBox = ({ title, borderColor, showFlashes, showLoadingOverlay, children, ...props }: Props) => (
<div {...props}> <div {...props}>
{title && <h2 css={tw`text-neutral-300 mb-4 px-4 text-2xl`}>{title}</h2>} {title && <h2 css={tw`text-neutral-300 mb-4 px-4 text-2xl`}>{title}</h2>}
{showFlashes && {showFlashes && (
<FlashMessageRender <FlashMessageRender byKey={typeof showFlashes === 'string' ? showFlashes : undefined} css={tw`mb-4`} />
byKey={typeof showFlashes === 'string' ? showFlashes : undefined} )}
css={tw`mb-4`} <div css={[tw`bg-neutral-700 p-4 rounded shadow-lg relative`, !!borderColor && tw`border-t-4`]}>
/>
}
<div
css={[
tw`bg-neutral-700 p-4 rounded shadow-lg relative`,
!!borderColor && tw`border-t-4`,
]}
>
<SpinnerOverlay visible={showLoadingOverlay || false} /> <SpinnerOverlay visible={showLoadingOverlay || false} />
{children} {children}
</div> </div>

View file

@ -42,15 +42,15 @@ const CopyOnClick: React.FC<{ text: any }> = ({ text, children }) => {
<> <>
<SwitchTransition> <SwitchTransition>
<Fade timeout={250} key={copied ? 'visible' : 'invisible'}> <Fade timeout={250} key={copied ? 'visible' : 'invisible'}>
{copied ? {copied ? (
<Toast> <Toast>
<div> <div>
<p>Copied &quot;{text}&quot; to clipboard.</p> <p>Copied &quot;{text}&quot; to clipboard.</p>
</div> </div>
</Toast> </Toast>
: ) : (
<></> <></>
} )}
</Fade> </Fade>
</SwitchTransition> </SwitchTransition>
<CopyToClipboard onCopy={onCopy} text={text} options={{ debug: true }} css={tw`cursor-pointer`}> <CopyToClipboard onCopy={onCopy} text={text} options={{ debug: true }} css={tw`cursor-pointer`}>

View file

@ -13,7 +13,7 @@ export const DropdownButtonRow = styled.button<{ danger?: boolean }>`
transition: 150ms all ease; transition: 150ms all ease;
&:hover { &:hover {
${props => props.danger ? tw`text-red-700 bg-red-100` : tw`text-neutral-700 bg-neutral-100`}; ${(props) => (props.danger ? tw`text-red-700 bg-red-100` : tw`text-neutral-700 bg-neutral-100`)};
} }
`; `;
@ -76,7 +76,8 @@ class DropdownMenu extends React.PureComponent<Props, State> {
} }
}; };
triggerMenu = (posX: number) => this.setState(s => ({ triggerMenu = (posX: number) =>
this.setState((s) => ({
posX: !s.visible ? posX : s.posX, posX: !s.visible ? posX : s.posX,
visible: !s.visible, visible: !s.visible,
})); }));
@ -88,7 +89,7 @@ class DropdownMenu extends React.PureComponent<Props, State> {
<Fade timeout={150} in={this.state.visible} unmountOnExit> <Fade timeout={150} in={this.state.visible} unmountOnExit>
<div <div
ref={this.menu} ref={this.menu}
onClick={e => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
this.setState({ visible: false }); this.setState({ visible: false });
}} }}

View file

@ -22,7 +22,7 @@ class ErrorBoundary extends React.Component<{}, State> {
} }
render() { render() {
return this.state.hasError ? return this.state.hasError ? (
<div css={tw`flex items-center justify-center w-full my-4`}> <div css={tw`flex items-center justify-center w-full my-4`}>
<div css={tw`flex items-center bg-neutral-900 rounded p-3 text-red-500`}> <div css={tw`flex items-center bg-neutral-900 rounded p-3 text-red-500`}>
<Icon icon={faExclamationTriangle} css={tw`h-4 w-auto mr-2`} /> <Icon icon={faExclamationTriangle} css={tw`h-4 w-auto mr-2`} />
@ -31,8 +31,9 @@ class ErrorBoundary extends React.Component<{}, State> {
</p> </p>
</div> </div>
</div> </div>
: ) : (
this.props.children; this.props.children
);
} }
} }

View file

@ -8,16 +8,20 @@ interface Props extends Omit<CSSTransitionProps, 'timeout' | 'classNames'> {
} }
const Container = styled.div<{ timeout: number }>` const Container = styled.div<{ timeout: number }>`
.fade-enter, .fade-exit, .fade-appear { .fade-enter,
.fade-exit,
.fade-appear {
will-change: opacity; will-change: opacity;
} }
.fade-enter, .fade-appear { .fade-enter,
.fade-appear {
${tw`opacity-0`}; ${tw`opacity-0`};
&.fade-enter-active, &.fade-appear-active { &.fade-enter-active,
&.fade-appear-active {
${tw`opacity-100 transition-opacity ease-in`}; ${tw`opacity-100 transition-opacity ease-in`};
transition-duration: ${props => props.timeout}ms; transition-duration: ${(props) => props.timeout}ms;
} }
} }
@ -26,7 +30,7 @@ const Container = styled.div<{ timeout: number }>`
&.fade-exit-active { &.fade-exit-active {
${tw`opacity-0 transition-opacity ease-in`}; ${tw`opacity-0 transition-opacity ease-in`};
transition-duration: ${props => props.timeout}ms; transition-duration: ${(props) => props.timeout}ms;
} }
} }
`; `;

View file

@ -13,14 +13,16 @@ interface OwnProps {
type Props = OwnProps & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'>; type Props = OwnProps & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'>;
const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, label, description, validate, ...props }, ref) => ( const Field = forwardRef<HTMLInputElement, Props>(
({ id, name, light = false, label, description, validate, ...props }, ref) => (
<FormikField innerRef={ref} name={name} validate={validate}> <FormikField innerRef={ref} name={name} validate={validate}>
{ {({ field, form: { errors, touched } }: FieldProps) => (
({ field, form: { errors, touched } }: FieldProps) => (
<div> <div>
{label && {label && (
<Label htmlFor={id} isLight={light}>{label}</Label> <Label htmlFor={id} isLight={light}>
} {label}
</Label>
)}
<Input <Input
id={id} id={id}
{...field} {...field}
@ -28,18 +30,19 @@ const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, la
isLight={light} isLight={light}
hasError={!!(touched[field.name] && errors[field.name])} hasError={!!(touched[field.name] && errors[field.name])}
/> />
{touched[field.name] && errors[field.name] ? {touched[field.name] && errors[field.name] ? (
<p className={'input-help error'}> <p className={'input-help error'}>
{(errors[field.name] as string).charAt(0).toUpperCase() + (errors[field.name] as string).slice(1)} {(errors[field.name] as string).charAt(0).toUpperCase() +
(errors[field.name] as string).slice(1)}
</p> </p>
: ) : description ? (
description ? <p className={'input-help'}>{description}</p> : null <p className={'input-help'}>{description}</p>
} ) : null}
</div> </div>
) )}
}
</FormikField> </FormikField>
)); )
);
Field.displayName = 'Field'; Field.displayName = 'Field';
export default Field; export default Field;

View file

@ -15,17 +15,15 @@ interface Props {
const FormikFieldWrapper = ({ id, name, label, className, description, validate, children }: Props) => ( const FormikFieldWrapper = ({ id, name, label, className, description, validate, children }: Props) => (
<Field name={name} validate={validate}> <Field name={name} validate={validate}>
{ {({ field, form: { errors, touched } }: FieldProps) => (
({ field, form: { errors, touched } }: FieldProps) => ( <div className={`${className} ${touched[field.name] && errors[field.name] ? 'has-error' : undefined}`}>
<div className={`${className} ${(touched[field.name] && errors[field.name]) ? 'has-error' : undefined}`}>
{label && <Label htmlFor={id}>{label}</Label>} {label && <Label htmlFor={id}>{label}</Label>}
{children} {children}
<InputError errors={errors} touched={touched} name={field.name}> <InputError errors={errors} touched={touched} name={field.name}>
{description || null} {description || null}
</InputError> </InputError>
</div> </div>
) )}
}
</Field> </Field>
); );

View file

@ -4,7 +4,7 @@ import tw from 'twin.macro';
export default styled.div<{ $hoverable?: boolean }>` export default styled.div<{ $hoverable?: boolean }>`
${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150 overflow-hidden`}; ${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150 overflow-hidden`};
${props => props.$hoverable !== false && tw`hover:border-neutral-500`}; ${(props) => props.$hoverable !== false && tw`hover:border-neutral-500`};
& .icon { & .icon {
${tw`rounded-full bg-neutral-500 p-3`}; ${tw`rounded-full bg-neutral-500 p-3`};

View file

@ -8,7 +8,9 @@ export interface Props {
const light = css<Props>` const light = css<Props>`
${tw`bg-white border-neutral-200 text-neutral-800`}; ${tw`bg-white border-neutral-200 text-neutral-800`};
&:focus { ${tw`border-primary-400`} } &:focus {
${tw`border-primary-400`}
}
&:disabled { &:disabled {
${tw`bg-neutral-100 border-neutral-200`}; ${tw`bg-neutral-100 border-neutral-200`};
@ -43,40 +45,44 @@ const inputStyle = css<Props>`
& + .input-help { & + .input-help {
${tw`mt-1 text-xs`}; ${tw`mt-1 text-xs`};
${props => props.hasError ? tw`text-red-200` : tw`text-neutral-200`}; ${(props) => (props.hasError ? tw`text-red-200` : tw`text-neutral-200`)};
} }
&:required, &:invalid { &:required,
&:invalid {
${tw`shadow-none`}; ${tw`shadow-none`};
} }
&:not(:disabled):not(:read-only):focus { &:not(:disabled):not(:read-only):focus {
${tw`shadow-md border-primary-300 ring-2 ring-primary-400 ring-opacity-50`}; ${tw`shadow-md border-primary-300 ring-2 ring-primary-400 ring-opacity-50`};
${props => props.hasError && tw`border-red-300 ring-red-200`}; ${(props) => props.hasError && tw`border-red-300 ring-red-200`};
} }
&:disabled { &:disabled {
${tw`opacity-75`}; ${tw`opacity-75`};
} }
${props => props.isLight && light}; ${(props) => props.isLight && light};
${props => props.hasError && tw`text-red-100 border-red-400 hover:border-red-300`}; ${(props) => props.hasError && tw`text-red-100 border-red-400 hover:border-red-300`};
`; `;
const Input = styled.input<Props>` const Input = styled.input<Props>`
&:not([type="checkbox"]):not([type="radio"]) { &:not([type='checkbox']):not([type='radio']) {
${inputStyle}; ${inputStyle};
} }
&[type="checkbox"], &[type="radio"] { &[type='checkbox'],
&[type='radio'] {
${checkboxStyle}; ${checkboxStyle};
&[type="radio"] { &[type='radio'] {
${tw`rounded-full`}; ${tw`rounded-full`};
} }
} }
`; `;
const Textarea = styled.textarea<Props>`${inputStyle}`; const Textarea = styled.textarea<Props>`
${inputStyle}
`;
export { Textarea }; export { Textarea };
export default Input; export default Input;

View file

@ -10,19 +10,15 @@ interface Props {
children?: string | number | null | undefined; children?: string | number | null | undefined;
} }
const InputError = ({ errors, touched, name, children }: Props) => ( const InputError = ({ errors, touched, name, children }: Props) =>
touched[name] && errors[name] ? touched[name] && errors[name] ? (
<p css={tw`text-xs text-red-400 pt-2`}> <p css={tw`text-xs text-red-400 pt-2`}>
{typeof errors[name] === 'string' ? {typeof errors[name] === 'string'
capitalize(errors[name] as string) ? capitalize(errors[name] as string)
: : capitalize((errors[name] as unknown as string[])[0])}
capitalize((errors[name] as unknown as string[])[0])
}
</p> </p>
: ) : (
<> <>{children ? <p css={tw`text-xs text-neutral-400 pt-2`}>{children}</p> : null}</>
{children ? <p css={tw`text-xs text-neutral-400 pt-2`}>{children}</p> : null}
</>
); );
export default InputError; export default InputError;

View file

@ -8,14 +8,16 @@ import Select from '@/components/elements/Select';
const Container = styled.div<{ visible?: boolean }>` const Container = styled.div<{ visible?: boolean }>`
${tw`relative`}; ${tw`relative`};
${props => props.visible && css` ${(props) =>
props.visible &&
css`
& ${Select} { & ${Select} {
background-image: none; background-image: none;
} }
`}; `};
`; `;
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => ( const InputSpinner = ({ visible, children }: { visible: boolean; children: React.ReactNode }) => (
<Container visible={visible}> <Container visible={visible}>
<Fade appear unmountOnExit in={visible} timeout={150}> <Fade appear unmountOnExit in={visible} timeout={150}>
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}> <div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>

View file

@ -3,7 +3,7 @@ import tw from 'twin.macro';
const Label = styled.label<{ isLight?: boolean }>` const Label = styled.label<{ isLight?: boolean }>`
${tw`block text-xs uppercase text-neutral-200 mb-1 sm:mb-2`}; ${tw`block text-xs uppercase text-neutral-200 mb-1 sm:mb-2`};
${props => props.isLight && tw`text-neutral-700`}; ${(props) => props.isLight && tw`text-neutral-700`};
`; `;
export default Label; export default Label;

View file

@ -22,7 +22,7 @@ export interface ModalProps extends RequiredModalProps {
export const ModalMask = styled.div` export const ModalMask = styled.div`
${tw`fixed z-50 overflow-auto flex w-full inset-0`}; ${tw`fixed z-50 overflow-auto flex w-full inset-0`};
background: rgba(0, 0, 0, 0.70); background: rgba(0, 0, 0, 0.7);
`; `;
const ModalContainer = styled.div<{ alignTop?: boolean }>` const ModalContainer = styled.div<{ alignTop?: boolean }>`
@ -32,7 +32,9 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
${breakpoint('lg')`max-width: 50%`}; ${breakpoint('lg')`max-width: 50%`};
${tw`relative flex flex-col w-full m-auto`}; ${tw`relative flex flex-col w-full m-auto`};
${props => props.alignTop && css` ${(props) =>
props.alignTop &&
css`
margin-top: 20%; margin-top: 20%;
${breakpoint('md')`margin-top: 10%`}; ${breakpoint('md')`margin-top: 10%`};
`}; `};
@ -43,7 +45,9 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
${tw`absolute right-0 p-2 text-white cursor-pointer opacity-50 transition-all duration-150 ease-linear hover:opacity-100`}; ${tw`absolute right-0 p-2 text-white cursor-pointer opacity-50 transition-all duration-150 ease-linear hover:opacity-100`};
top: -2.5rem; top: -2.5rem;
&:hover {${tw`transform rotate-90`}} &:hover {
${tw`transform rotate-90`}
}
& > svg { & > svg {
${tw`w-6 h-6`}; ${tw`w-6 h-6`};
@ -51,7 +55,17 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
} }
`; `;
const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => { const Modal: React.FC<ModalProps> = ({
visible,
appear,
dismissable,
showSpinnerOverlay,
top = true,
closeOnBackground = true,
closeOnEscape = true,
onDismissed,
children,
}) => {
const [render, setRender] = useState(visible); const [render, setRender] = useState(visible);
const isDismissable = useMemo(() => { const isDismissable = useMemo(() => {
@ -74,17 +88,11 @@ const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinner
useEffect(() => setRender(visible), [visible]); useEffect(() => setRender(visible), [visible]);
return ( return (
<Fade <Fade in={render} timeout={150} appear={appear || true} unmountOnExit onExited={() => onDismissed()}>
in={render}
timeout={150}
appear={appear || true}
unmountOnExit
onExited={() => onDismissed()}
>
<ModalMask <ModalMask
onClick={e => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onContextMenu={e => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}
onMouseDown={e => { onMouseDown={(e) => {
if (isDismissable && closeOnBackground) { if (isDismissable && closeOnBackground) {
e.stopPropagation(); e.stopPropagation();
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
@ -94,9 +102,14 @@ const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinner
}} }}
> >
<ModalContainer alignTop={top}> <ModalContainer alignTop={top}>
{isDismissable && {isDismissable && (
<div className={'close-icon'} onClick={() => setRender(false)}> <div className={'close-icon'} onClick={() => setRender(false)}>
<svg xmlns={'http://www.w3.org/2000/svg'} fill={'none'} viewBox={'0 0 24 24'} stroke={'currentColor'}> <svg
xmlns={'http://www.w3.org/2000/svg'}
fill={'none'}
viewBox={'0 0 24 24'}
stroke={'currentColor'}
>
<path <path
strokeLinecap={'round'} strokeLinecap={'round'}
strokeLinejoin={'round'} strokeLinejoin={'round'}
@ -105,8 +118,8 @@ const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinner
/> />
</svg> </svg>
</div> </div>
} )}
{showSpinnerOverlay && {showSpinnerOverlay && (
<Fade timeout={150} appear in> <Fade timeout={150} appear in>
<div <div
css={tw`absolute w-full h-full rounded flex items-center justify-center`} css={tw`absolute w-full h-full rounded flex items-center justify-center`}
@ -115,8 +128,10 @@ const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinner
<Spinner /> <Spinner />
</div> </div>
</Fade> </Fade>
} )}
<div css={tw`bg-neutral-800 p-3 sm:p-4 md:p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}> <div
css={tw`bg-neutral-800 p-3 sm:p-4 md:p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}
>
{children} {children}
</div> </div>
</ModalContainer> </ModalContainer>

View file

@ -21,9 +21,7 @@ const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey
<CSSTransition timeout={150} classNames={'fade'} appear in> <CSSTransition timeout={150} classNames={'fade'} appear in>
<> <>
<ContentContainer css={tw`my-4 sm:my-10`} className={className}> <ContentContainer css={tw`my-4 sm:my-10`} className={className}>
{showFlashKey && {showFlashKey && <FlashMessageRender byKey={showFlashKey} css={tw`mb-4`} />}
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
}
{children} {children}
</ContentContainer> </ContentContainer>
<ContentContainer css={tw`mb-4`}> <ContentContainer css={tw`mb-4`}>
@ -36,7 +34,7 @@ const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey
> >
Pterodactyl&reg; Pterodactyl&reg;
</a> </a>
&nbsp;&copy; 2015 - {(new Date()).getFullYear()} &nbsp;&copy; 2015 - {new Date().getFullYear()}
</p> </p>
</ContentContainer> </ContentContainer>
</> </>

View file

@ -46,19 +46,14 @@ function Pagination<T> ({ data: { items, pagination }, onPageSelect, children }:
return ( return (
<> <>
{children({ items, isFirstPage, isLastPage })} {children({ items, isFirstPage, isLastPage })}
{(pages.length > 1) && {pages.length > 1 && (
<div css={tw`mt-4 flex justify-center`}> <div css={tw`mt-4 flex justify-center`}>
{(pages[0] > 1 && !isFirstPage) && {pages[0] > 1 && !isFirstPage && (
<Block <Block isSecondary color={'primary'} onClick={() => onPageSelect(1)}>
isSecondary
color={'primary'}
onClick={() => onPageSelect(1)}
>
<FontAwesomeIcon icon={faAngleDoubleLeft} /> <FontAwesomeIcon icon={faAngleDoubleLeft} />
</Block> </Block>
} )}
{ {pages.map((i) => (
pages.map(i => (
<Block <Block
isSecondary={pagination.currentPage !== i} isSecondary={pagination.currentPage !== i}
color={'primary'} color={'primary'}
@ -67,19 +62,14 @@ function Pagination<T> ({ data: { items, pagination }, onPageSelect, children }:
> >
{i} {i}
</Block> </Block>
)) ))}
} {pages[4] < pagination.totalPages && !isLastPage && (
{(pages[4] < pagination.totalPages && !isLastPage) && <Block isSecondary color={'primary'} onClick={() => onPageSelect(pagination.totalPages)}>
<Block
isSecondary
color={'primary'}
onClick={() => onPageSelect(pagination.totalPages)}
>
<FontAwesomeIcon icon={faAngleDoubleRight} /> <FontAwesomeIcon icon={faAngleDoubleRight} />
</Block> </Block>
} )}
</div> </div>
} )}
</> </>
); );
} }

View file

@ -11,20 +11,17 @@ interface Props extends Omit<RouteProps, 'path'> {
export default ({ permission, children, ...props }: Props) => ( export default ({ permission, children, ...props }: Props) => (
<Route {...props}> <Route {...props}>
{!permission ? {!permission ? (
children children
: ) : (
<Can <Can
action={permission} action={permission}
renderOnError={ renderOnError={
<ServerError <ServerError title={'Access Denied'} message={'You do not have permission to access this page.'} />
title={'Access Denied'}
message={'You do not have permission to access this page.'}
/>
} }
> >
{children} {children}
</Can> </Can>
} )}
</Route> </Route>
); );

View file

@ -15,9 +15,9 @@ export default () => {
const interval = useRef<number>(null); const interval = useRef<number>(null);
const timeout = useRef<number>(null); const timeout = useRef<number>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const progress = useStoreState(state => state.progress.progress); const progress = useStoreState((state) => state.progress.progress);
const continuous = useStoreState(state => state.progress.continuous); const continuous = useStoreState((state) => state.progress.continuous);
const setProgress = useStoreActions(actions => actions.progress.setProgress); const setProgress = useStoreActions((actions) => actions.progress.setProgress);
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -60,13 +60,7 @@ export default () => {
return ( return (
<div css={tw`w-full fixed`} style={{ height: '2px' }}> <div css={tw`w-full fixed`} style={{ height: '2px' }}>
<CSSTransition <CSSTransition timeout={150} appear in={visible} unmountOnExit classNames={'fade'}>
timeout={150}
appear
in={visible}
unmountOnExit
classNames={'fade'}
>
<BarFill style={{ width: progress === undefined ? '100%' : `${progress}%` }} /> <BarFill style={{ width: progress === undefined ? '100%' : `${progress}%` }} />
</CSSTransition> </CSSTransition>
</div> </div>

View file

@ -43,22 +43,22 @@ const ActionButton = styled(Button)`
const ScreenBlock = ({ title, image, message, onBack, onRetry }: ScreenBlockProps) => ( const ScreenBlock = ({ title, image, message, onBack, onRetry }: ScreenBlockProps) => (
<PageContentBlock> <PageContentBlock>
<div css={tw`flex justify-center`}> <div css={tw`flex justify-center`}>
<div css={tw`w-full sm:w-3/4 md:w-1/2 p-12 md:p-20 bg-neutral-100 rounded-lg shadow-lg text-center relative`}> <div
{(typeof onBack === 'function' || typeof onRetry === 'function') && css={tw`w-full sm:w-3/4 md:w-1/2 p-12 md:p-20 bg-neutral-100 rounded-lg shadow-lg text-center relative`}
>
{(typeof onBack === 'function' || typeof onRetry === 'function') && (
<div css={tw`absolute left-0 top-0 ml-4 mt-4`}> <div css={tw`absolute left-0 top-0 ml-4 mt-4`}>
<ActionButton <ActionButton
onClick={() => onRetry ? onRetry() : (onBack ? onBack() : null)} onClick={() => (onRetry ? onRetry() : onBack ? onBack() : null)}
className={onRetry ? 'hover:spin' : undefined} className={onRetry ? 'hover:spin' : undefined}
> >
<FontAwesomeIcon icon={onRetry ? faSyncAlt : faArrowLeft} /> <FontAwesomeIcon icon={onRetry ? faSyncAlt : faArrowLeft} />
</ActionButton> </ActionButton>
</div> </div>
} )}
<img src={image} css={tw`w-2/3 h-auto select-none mx-auto`} /> <img src={image} css={tw`w-2/3 h-auto select-none mx-auto`} />
<h2 css={tw`mt-10 text-neutral-900 font-bold text-4xl`}>{title}</h2> <h2 css={tw`mt-10 text-neutral-900 font-bold text-4xl`}>{title}</h2>
<p css={tw`text-sm text-neutral-700 mt-2`}> <p css={tw`text-sm text-neutral-700 mt-2`}>{message}</p>
{message}
</p>
</div> </div>
</div> </div>
</PageContentBlock> </PageContentBlock>
@ -66,7 +66,7 @@ const ScreenBlock = ({ title, image, message, onBack, onRetry }: ScreenBlockProp
type ServerErrorProps = (Omit<PropsWithBack, 'image' | 'title'> | Omit<PropsWithRetry, 'image' | 'title'>) & { type ServerErrorProps = (Omit<PropsWithBack, 'image' | 'title'> | Omit<PropsWithRetry, 'image' | 'title'>) & {
title?: string; title?: string;
} };
const ServerError = ({ title, ...props }: ServerErrorProps) => ( const ServerError = ({ title, ...props }: ServerErrorProps) => (
<ScreenBlock title={title || 'Something went wrong'} image={ServerErrorSvg} {...props} /> <ScreenBlock title={title || 'Something went wrong'} image={ServerErrorSvg} {...props} />

View file

@ -8,7 +8,9 @@ interface Props {
const Select = styled.select<Props>` const Select = styled.select<Props>`
${tw`shadow-none block p-3 pr-8 rounded border w-full text-sm transition-colors duration-150 ease-linear`}; ${tw`shadow-none block p-3 pr-8 rounded border w-full text-sm transition-colors duration-150 ease-linear`};
&, &:hover:not(:disabled), &:focus { &,
&:hover:not(:disabled),
&:focus {
${tw`outline-none`}; ${tw`outline-none`};
} }
@ -23,11 +25,14 @@ const Select = styled.select<Props>`
display: none; display: none;
} }
${props => !props.hideDropdownArrow && css` ${(props) =>
!props.hideDropdownArrow &&
css`
${tw`bg-neutral-600 border-neutral-500 text-neutral-200`}; ${tw`bg-neutral-600 border-neutral-500 text-neutral-200`};
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='%23C3D1DF' d='M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z'/%3e%3c/svg%3e "); background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='%23C3D1DF' d='M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z'/%3e%3c/svg%3e ");
&:hover:not(:disabled), &:focus { &:hover:not(:disabled),
&:focus {
${tw`border-neutral-400`}; ${tw`border-neutral-400`};
} }
`}; `};

View file

@ -7,7 +7,7 @@ interface Props extends PageContentBlockProps {
} }
const ServerContentBlock: React.FC<Props> = ({ title, children, ...props }) => { const ServerContentBlock: React.FC<Props> = ({ title, children, ...props }) => {
const name = ServerContext.useStoreState(state => state.server.data!.name); const name = ServerContext.useStoreState((state) => state.server.data!.name);
return ( return (
<PageContentBlock title={`${name} | ${title}`} {...props}> <PageContentBlock title={`${name} | ${title}`} {...props}>

View file

@ -25,28 +25,28 @@ const SpinnerComponent = styled.div<Props>`
${tw`w-8 h-8`}; ${tw`w-8 h-8`};
border-width: 3px; border-width: 3px;
border-radius: 50%; border-radius: 50%;
animation: ${spin} 1s cubic-bezier(0.55, 0.25, 0.25, 0.70) infinite; animation: ${spin} 1s cubic-bezier(0.55, 0.25, 0.25, 0.7) infinite;
${props => props.size === 'small' ? tw`w-4 h-4 border-2` : (props.size === 'large' ? css` ${(props) =>
props.size === 'small'
? tw`w-4 h-4 border-2`
: props.size === 'large'
? css`
${tw`w-16 h-16`}; ${tw`w-16 h-16`};
border-width: 6px; border-width: 6px;
` : null)}; `
: null};
border-color: ${props => !props.isBlue ? 'rgba(255, 255, 255, 0.2)' : 'hsla(212, 92%, 43%, 0.2)'}; border-color: ${(props) => (!props.isBlue ? 'rgba(255, 255, 255, 0.2)' : 'hsla(212, 92%, 43%, 0.2)')};
border-top-color: ${props => !props.isBlue ? 'rgb(255, 255, 255)' : 'hsl(212, 92%, 43%)'}; border-top-color: ${(props) => (!props.isBlue ? 'rgb(255, 255, 255)' : 'hsl(212, 92%, 43%)')};
`; `;
const Spinner: Spinner = ({ centered, ...props }) => ( const Spinner: Spinner = ({ centered, ...props }) =>
centered ? centered ? (
<div <div css={[tw`flex justify-center items-center`, props.size === 'large' ? tw`m-20` : tw`m-6`]}>
css={[
tw`flex justify-center items-center`,
props.size === 'large' ? tw`m-20` : tw`m-6`,
]}
>
<SpinnerComponent {...props} /> <SpinnerComponent {...props} />
</div> </div>
: ) : (
<SpinnerComponent {...props} /> <SpinnerComponent {...props} />
); );
Spinner.displayName = 'Spinner'; Spinner.displayName = 'Spinner';
@ -59,9 +59,7 @@ Spinner.Size = {
Spinner.Suspense = ({ children, centered = true, size = Spinner.Size.LARGE, ...props }) => ( Spinner.Suspense = ({ children, centered = true, size = Spinner.Size.LARGE, ...props }) => (
<Suspense fallback={<Spinner centered={centered} size={size} {...props} />}> <Suspense fallback={<Spinner centered={centered} size={size} {...props} />}>
<ErrorBoundary> <ErrorBoundary>{children}</ErrorBoundary>
{children}
</ErrorBoundary>
</Suspense> </Suspense>
); );
Spinner.Suspense.displayName = 'Spinner.Suspense'; Spinner.Suspense.displayName = 'Spinner.Suspense';

View file

@ -8,7 +8,8 @@ const SubNavigation = styled.div`
${tw`flex items-center text-sm mx-auto px-2`}; ${tw`flex items-center text-sm mx-auto px-2`};
max-width: 1200px; max-width: 1200px;
& > a, & > div { & > a,
& > div {
${tw`inline-block py-3 px-4 text-neutral-300 no-underline whitespace-nowrap transition-all duration-150`}; ${tw`inline-block py-3 px-4 text-neutral-300 no-underline whitespace-nowrap transition-all duration-150`};
&:not(:first-of-type) { &:not(:first-of-type) {
@ -19,7 +20,8 @@ const SubNavigation = styled.div`
${tw`text-neutral-100`}; ${tw`text-neutral-100`};
} }
&:active, &.active { &:active,
&.active {
${tw`text-neutral-100`}; ${tw`text-neutral-100`};
box-shadow: inset 0 -2px ${theme`colors.cyan.600`.toString()}; box-shadow: inset 0 -2px ${theme`colors.cyan.600`.toString()};
} }

View file

@ -8,7 +8,7 @@ import Input from '@/components/elements/Input';
const ToggleContainer = styled.div` const ToggleContainer = styled.div`
${tw`relative select-none w-12 leading-normal`}; ${tw`relative select-none w-12 leading-normal`};
& > input[type="checkbox"] { & > input[type='checkbox'] {
${tw`hidden`}; ${tw`hidden`};
&:checked + label { &:checked + label {
@ -30,7 +30,7 @@ const ToggleContainer = styled.div`
right: calc(50% + 0.125rem); right: calc(50% + 0.125rem);
//width: 1.25rem; //width: 1.25rem;
//height: 1.25rem; //height: 1.25rem;
content: ""; content: '';
transition: all 75ms ease-in; transition: all 75ms ease-in;
} }
} }
@ -52,35 +52,28 @@ const Switch = ({ name, label, description, defaultChecked, readOnly, onChange,
return ( return (
<div css={tw`flex items-center`}> <div css={tw`flex items-center`}>
<ToggleContainer css={tw`flex-none`}> <ToggleContainer css={tw`flex-none`}>
{children {children || (
|| <Input <Input
id={uuid} id={uuid}
name={name} name={name}
type={'checkbox'} type={'checkbox'}
onChange={e => onChange && onChange(e)} onChange={(e) => onChange && onChange(e)}
defaultChecked={defaultChecked} defaultChecked={defaultChecked}
disabled={readOnly} disabled={readOnly}
/> />
} )}
<Label htmlFor={uuid} /> <Label htmlFor={uuid} />
</ToggleContainer> </ToggleContainer>
{(label || description) && {(label || description) && (
<div css={tw`ml-4 w-full`}> <div css={tw`ml-4 w-full`}>
{label && {label && (
<Label <Label css={[tw`cursor-pointer`, !!description && tw`mb-0`]} htmlFor={uuid}>
css={[ tw`cursor-pointer`, !!description && tw`mb-0` ]}
htmlFor={uuid}
>
{label} {label}
</Label> </Label>
} )}
{description && {description && <p css={tw`text-neutral-400 text-sm mt-2`}>{description}</p>}
<p css={tw`text-neutral-400 text-sm mt-2`}>
{description}
</p>
}
</div> </div>
} )}
</div> </div>
); );
}; };

View file

@ -14,17 +14,16 @@ interface Props {
const TitledGreyBox = ({ icon, title, children, className }: Props) => ( const TitledGreyBox = ({ icon, title, children, className }: Props) => (
<div css={tw`rounded shadow-md bg-neutral-700`} className={className}> <div css={tw`rounded shadow-md bg-neutral-700`} className={className}>
<div css={tw`bg-neutral-900 rounded-t p-3 border-b border-black`}> <div css={tw`bg-neutral-900 rounded-t p-3 border-b border-black`}>
{typeof title === 'string' ? {typeof title === 'string' ? (
<p css={tw`text-sm uppercase`}> <p css={tw`text-sm uppercase`}>
{icon && <FontAwesomeIcon icon={icon} css={tw`mr-2 text-neutral-300`}/>}{title} {icon && <FontAwesomeIcon icon={icon} css={tw`mr-2 text-neutral-300`} />}
{title}
</p> </p>
: ) : (
title title
} )}
</div>
<div css={tw`p-3`}>
{children}
</div> </div>
<div css={tw`p-3`}>{children}</div>
</div> </div>
); );

View file

@ -25,8 +25,11 @@ const formatProperties = (properties: Record<string, unknown>): Record<string, u
return { return {
...obj, ...obj,
[key]: isCount || typeof value !== 'string' [key]:
? (isObject(value) ? formatProperties(value) : value) isCount || typeof value !== 'string'
? isObject(value)
? formatProperties(value)
: value
: `<strong>${value}</strong>`, : `<strong>${value}</strong>`,
}; };
}, {}); }, {});
@ -58,11 +61,13 @@ export default ({ activity, children }: Props) => {
{activity.event} {activity.event}
</Link> </Link>
<div className={classNames(style.icons, 'group-hover:text-gray-300')}> <div className={classNames(style.icons, 'group-hover:text-gray-300')}>
{activity.isApi && {activity.isApi && (
<Tooltip placement={'top'} content={'Performed using API Key'}> <Tooltip placement={'top'} content={'Performed using API Key'}>
<span><TerminalIcon/></span> <span>
<TerminalIcon />
</span>
</Tooltip> </Tooltip>
} )}
{children} {children}
</div> </div>
</div> </div>
@ -77,13 +82,8 @@ export default ({ activity, children }: Props) => {
{activity.ip} {activity.ip}
</Link> </Link>
<span className={'text-gray-400'}>&nbsp;|&nbsp;</span> <span className={'text-gray-400'}>&nbsp;|&nbsp;</span>
<Tooltip <Tooltip placement={'right'} content={format(activity.timestamp, 'MMM do, yyyy H:mm:ss')}>
placement={'right'} <span>{formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })}</span>
content={format(activity.timestamp, 'MMM do, yyyy H:mm:ss')}
>
<span>
{formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })}
</span>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>

View file

@ -8,12 +8,7 @@ export default ({ meta }: { meta: Record<string, unknown> }) => {
return ( return (
<div className={'self-center md:px-4'}> <div className={'self-center md:px-4'}>
<Dialog <Dialog open={open} onClose={() => setOpen(false)} hideCloseIcon title={'Metadata'}>
open={open}
onClose={() => setOpen(false)}
hideCloseIcon
title={'Metadata'}
>
<pre className={'bg-gray-900 rounded p-2 overflow-x-scroll font-mono text-sm leading-relaxed'}> <pre className={'bg-gray-900 rounded p-2 overflow-x-scroll font-mono text-sm leading-relaxed'}>
{JSON.stringify(meta, null, 2)} {JSON.stringify(meta, null, 2)}
</pre> </pre>
@ -23,7 +18,9 @@ export default ({ meta }: { meta: Record<string, unknown> }) => {
</Dialog> </Dialog>
<button <button
aria-describedby={'View additional event metadata'} aria-describedby={'View additional event metadata'}
className={'p-2 transition-colors duration-100 text-gray-400 group-hover:text-gray-300 group-hover:hover:text-gray-50'} className={
'p-2 transition-colors duration-100 text-gray-400 group-hover:text-gray-300 group-hover:hover:text-gray-50'
}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<ClipboardListIcon className={'w-5 h-5'} /> <ClipboardListIcon className={'w-5 h-5'} />

View file

@ -17,14 +17,14 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
[styles.small]: size === Options.Size.Small, [styles.small]: size === Options.Size.Small,
[styles.large]: size === Options.Size.Large, [styles.large]: size === Options.Size.Large,
}, },
className, className
)} )}
{...rest} {...rest}
> >
{children} {children}
</button> </button>
); );
}, }
); );
const TextButton = forwardRef<HTMLButtonElement, ButtonProps>(({ className, ...props }, ref) => ( const TextButton = forwardRef<HTMLButtonElement, ButtonProps>(({ className, ...props }, ref) => (

View file

@ -20,4 +20,4 @@ export type ButtonProps = JSX.IntrinsicElements['button'] & {
shape?: Shape; shape?: Shape;
size?: Size; size?: Size;
variant?: Variant; variant?: Variant;
} };

View file

@ -7,7 +7,7 @@ type ConfirmationProps = Omit<DialogProps, 'description' | 'children'> & {
children: React.ReactNode; children: React.ReactNode;
confirm?: string | undefined; confirm?: string | undefined;
onConfirmed: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onConfirmed: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
} };
export default ({ confirm = 'Okay', children, onConfirmed, ...props }: ConfirmationProps) => { export default ({ confirm = 'Okay', children, onConfirmed, ...props }: ConfirmationProps) => {
return ( return (

View file

@ -16,19 +16,17 @@ export interface DialogProps {
children?: React.ReactNode; children?: React.ReactNode;
} }
const DialogButtons = ({ children }: { children: React.ReactNode }) => ( const DialogButtons = ({ children }: { children: React.ReactNode }) => <>{children}</>;
<>{children}</>
);
const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }: DialogProps) => { const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }: DialogProps) => {
const items = React.Children.toArray(children || []); const items = React.Children.toArray(children || []);
const [buttons, icon, content] = [ const [buttons, icon, content] = [
// @ts-expect-error // @ts-expect-error
items.find(child => child.type === DialogButtons), items.find((child) => child.type === DialogButtons),
// @ts-expect-error // @ts-expect-error
items.find(child => child.type === DialogIcon), items.find((child) => child.type === DialogIcon),
// @ts-expect-error // @ts-expect-error
items.filter(child => ![ DialogIcon, DialogButtons ].includes(child.type)), items.filter((child) => ![DialogIcon, DialogButtons].includes(child.type)),
]; ];
return ( return (
@ -61,22 +59,28 @@ const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }:
<div className={'flex p-6 overflow-y-auto'}> <div className={'flex p-6 overflow-y-auto'}>
{icon && <div className={'mr-4'}>{icon}</div>} {icon && <div className={'mr-4'}>{icon}</div>}
<div className={'flex-1 max-h-[70vh]'}> <div className={'flex-1 max-h-[70vh]'}>
{title && {title && (
<HDialog.Title className={'font-header text-xl font-medium mb-2 text-gray-50 pr-4'}> <HDialog.Title
className={'font-header text-xl font-medium mb-2 text-gray-50 pr-4'}
>
{title} {title}
</HDialog.Title> </HDialog.Title>
} )}
{description && <HDialog.Description>{description}</HDialog.Description>} {description && <HDialog.Description>{description}</HDialog.Description>}
{content} {content}
</div> </div>
</div> </div>
{buttons && {buttons && (
<div className={'px-6 py-3 bg-gray-700 flex items-center justify-end space-x-3 rounded-b'}> <div
className={
'px-6 py-3 bg-gray-700 flex items-center justify-end space-x-3 rounded-b'
}
>
{buttons} {buttons}
</div> </div>
} )}
{/* Keep this below the other buttons so that it isn't the default focus if they're present. */} {/* Keep this below the other buttons so that it isn't the default focus if they're present. */}
{!hideCloseIcon && {!hideCloseIcon && (
<div className={'absolute right-0 top-0 m-4'}> <div className={'absolute right-0 top-0 m-4'}>
<Button.Text <Button.Text
size={Button.Sizes.Small} size={Button.Sizes.Small}
@ -87,7 +91,7 @@ const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }:
<XIcon className={'w-5 h-5'} /> <XIcon className={'w-5 h-5'} />
</Button.Text> </Button.Text>
</div> </div>
} )}
</HDialog.Panel> </HDialog.Panel>
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show more