chore: delete old client ui

This commit is contained in:
Scai 2025-01-06 15:20:20 +02:00
parent 295134fb6c
commit 66ec86694f
298 changed files with 0 additions and 15493 deletions

View File

@ -1,30 +0,0 @@
import React from 'react';
import { Route } from 'react-router';
import { SwitchTransition } from 'react-transition-group';
import Fade from '@/components/elements/Fade';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
const StyledSwitchTransition = styled(SwitchTransition)`
${tw`relative`};
& section {
${tw`absolute w-full top-0 left-0`};
}
`;
const TransitionRouter: React.FC = ({ children }) => {
return (
<Route
render={({ location }) => (
<StyledSwitchTransition>
<Fade timeout={150} key={location.pathname + location.search} in appear unmountOnExit>
<section>{children}</section>
</Fade>
</StyledSwitchTransition>
)}
/>
);
};
export default TransitionRouter;

View File

@ -1 +0,0 @@
module.exports = 'test-file-stub';

View File

@ -1,33 +0,0 @@
import useSWR, { ConfigInterface, responseInterface } from 'swr';
import { ActivityLog, Transformers } from '@definitions/user';
import { AxiosError } from 'axios';
import http, { PaginatedResult, QueryBuilderParams, withQueryBuilderParams } from '@/api/http';
import { toPaginatedSet } from '@definitions/helpers';
import useFilteredObject from '@/plugins/useFilteredObject';
import { useUserSWRKey } from '@/plugins/useSWRKey';
export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>;
const useActivityLogs = (
filters?: ActivityLogFilters,
config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>
): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => {
const key = useUserSWRKey(['account', 'activity', JSON.stringify(useFilteredObject(filters || {}))]);
return useSWR<PaginatedResult<ActivityLog>>(
key,
async () => {
const { data } = await http.get('/api/client/account/activity', {
params: {
...withQueryBuilderParams(filters),
include: ['actor'],
},
});
return toPaginatedSet(data, Transformers.toActivityLog);
},
{ revalidateOnMount: false, ...(config || {}) }
);
};
export { useActivityLogs };

View File

@ -1,19 +0,0 @@
import http from '@/api/http';
import { ApiKey, rawDataToApiKey } from '@/api/account/getApiKeys';
export default (description: string, allowedIps: string): Promise<ApiKey & { secretToken: string }> => {
return new Promise((resolve, reject) => {
http.post('/api/client/account/api-keys', {
description,
allowed_ips: allowedIps.length > 0 ? allowedIps.split('\n') : [],
})
.then(({ data }) =>
resolve({
...rawDataToApiKey(data.attributes),
// eslint-disable-next-line camelcase
secretToken: data.meta?.secret_token ?? '',
})
)
.catch(reject);
});
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (identifier: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/account/api-keys/${identifier}`)
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (password: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete('/api/client/account/two-factor', { params: { password } })
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,7 +0,0 @@
import http from '@/api/http';
export default async (code: string, password: string): Promise<string[]> => {
const { data } = await http.post('/api/client/account/two-factor', { code, password });
return data.attributes.tokens;
};

View File

@ -1,25 +0,0 @@
import http from '@/api/http';
export interface ApiKey {
identifier: string;
description: string;
allowedIps: string[];
createdAt: Date | null;
lastUsedAt: Date | null;
}
export const rawDataToApiKey = (data: any): ApiKey => ({
identifier: data.identifier,
description: data.description,
allowedIps: data.allowed_ips,
createdAt: data.created_at ? new Date(data.created_at) : null,
lastUsedAt: data.last_used_at ? new Date(data.last_used_at) : null,
});
export default (): Promise<ApiKey[]> => {
return new Promise((resolve, reject) => {
http.get('/api/client/account/api-keys')
.then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToApiKey(d.attributes))))
.catch(reject);
});
};

View File

@ -1,38 +0,0 @@
/* The MIT License (MIT)
Pterodactyl®
Copyright © Dane Everitt <dane@daneeveritt.com> and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. */
import http from '@/api/http';
export interface TwoFactorTokenData {
// eslint-disable-next-line camelcase
image_url_data: string;
secret: string;
}
export default (): Promise<TwoFactorTokenData> => {
return new Promise((resolve, reject) => {
http.get('/api/client/account/two-factor')
.then(({ data }) => resolve(data.data))
.catch(reject);
});
};

View File

@ -1,32 +0,0 @@
import useSWR, { ConfigInterface } from 'swr';
import http, { FractalResponseList } from '@/api/http';
import { SSHKey, Transformers } from '@definitions/user';
import { AxiosError } from 'axios';
import { useUserSWRKey } from '@/plugins/useSWRKey';
const useSSHKeys = (config?: ConfigInterface<SSHKey[], AxiosError>) => {
const key = useUserSWRKey(['account', 'ssh-keys']);
return useSWR(
key,
async () => {
const { data } = await http.get('/api/client/account/ssh-keys');
return (data as FractalResponseList).data.map((datum: any) => {
return Transformers.toSSHKey(datum.attributes);
});
},
{ revalidateOnMount: false, ...(config || {}) }
);
};
const createSSHKey = async (name: string, publicKey: string): Promise<SSHKey> => {
const { data } = await http.post('/api/client/account/ssh-keys', { name, public_key: publicKey });
return Transformers.toSSHKey(data.attributes);
};
const deleteSSHKey = async (fingerprint: string): Promise<void> =>
await http.post('/api/client/account/ssh-keys/remove', { fingerprint });
export { useSSHKeys, createSSHKey, deleteSSHKey };

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (email: string, password: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.put('/api/client/account/email', { email, password })
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,19 +0,0 @@
import http from '@/api/http';
interface Data {
current: string;
password: string;
confirmPassword: string;
}
export default ({ current, password, confirmPassword }: Data): Promise<void> => {
return new Promise((resolve, reject) => {
http.put('/api/client/account/password', {
current_password: current,
password: password,
password_confirmation: confirmPassword,
})
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,38 +0,0 @@
import http from '@/api/http';
export interface LoginResponse {
complete: boolean;
intended?: string;
confirmationToken?: string;
}
export interface LoginData {
username: string;
password: string;
recaptchaData?: string | null;
}
export default ({ username, password, recaptchaData }: LoginData): Promise<LoginResponse> => {
return new Promise((resolve, reject) => {
http.get('/sanctum/csrf-cookie')
.then(() =>
http.post('/auth/login', {
user: username,
password,
'cf-turnstile-response': recaptchaData,
})
)
.then((response) => {
if (!(response.data instanceof Object)) {
return reject(new Error('An error occurred while processing the login request.'));
}
return resolve({
complete: response.data.data.complete,
intended: response.data.data.intended || undefined,
confirmationToken: response.data.data.confirmation_token || undefined,
});
})
.catch(reject);
});
};

View File

@ -1,19 +0,0 @@
import http from '@/api/http';
import { LoginResponse } from '@/api/auth/login';
export default (token: string, code: string, recoveryToken?: string): Promise<LoginResponse> => {
return new Promise((resolve, reject) => {
http.post('/auth/login/checkpoint', {
confirmation_token: token,
authentication_code: code,
recovery_token: recoveryToken && recoveryToken.length > 0 ? recoveryToken : undefined,
})
.then((response) =>
resolve({
complete: response.data.data.complete,
intended: response.data.data.intended || undefined,
})
)
.catch(reject);
});
};

View File

@ -1,30 +0,0 @@
import http from '@/api/http';
interface Data {
token: string;
password: string;
passwordConfirmation: string;
}
interface PasswordResetResponse {
redirectTo?: string | null;
sendToLogin: boolean;
}
export default (email: string, data: Data): Promise<PasswordResetResponse> => {
return new Promise((resolve, reject) => {
http.post('/auth/password/reset', {
email,
token: data.token,
password: data.password,
password_confirmation: data.passwordConfirmation,
})
.then((response) =>
resolve({
redirectTo: response.data.redirect_to,
sendToLogin: response.data.send_to_login,
})
)
.catch(reject);
});
};

View File

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

View File

@ -1,55 +0,0 @@
import {
FractalPaginatedResponse,
FractalResponseData,
FractalResponseList,
getPaginationSet,
PaginatedResult,
} from '@/api/http';
import { Model } from '@definitions/index';
type TransformerFunc<T> = (callback: FractalResponseData) => T;
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: FractalResponseData | null | 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) {
return missing;
}
if (isList(data)) {
return data.data.map(transformer);
}
if (!data || !data.attributes || data.object === 'null_resource') {
return missing;
}
return transformer(data);
}
function toPaginatedSet<T extends TransformerFunc<Model>>(
response: FractalPaginatedResponse,
transformer: T
): PaginatedResult<ReturnType<T>> {
return {
items: transform(response, transformer) as ReturnType<T>[],
pagination: getPaginationSet(response.meta.pagination),
};
}
export { transform, toPaginatedSet };

View File

@ -1,33 +0,0 @@
import { MarkRequired } from 'ts-essentials';
import { FractalResponseData, FractalResponseList } from '../http';
export type UUID = string;
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Model {}
interface ModelWithRelationships extends Model {
relationships: Record<string, FractalResponseData | FractalResponseList | undefined>;
}
/**
* Allows a model to have optional relationships that are marked as being
* present in a given pathway. This allows different API calls to specify the
* "completeness" of a response object without having to make every API return
* the same information, or every piece of logic do explicit null checking.
*
* Example:
* >> const user: WithLoaded<User, 'servers'> = {};
* >> // "user.servers" is no longer potentially undefined.
*/
type WithLoaded<M extends ModelWithRelationships, R extends keyof M['relationships']> = M & {
relationships: MarkRequired<M['relationships'], R>;
};
/**
* Helper type that allows you to infer the type of an object by giving
* it the specific API request function with a return type. For example:
*
* type Egg = InferModel<typeof getEgg>;
*/
export type InferModel<T extends (...args: any) => any> = ReturnType<T> extends Promise<infer U> ? U : T;

View File

@ -1,2 +0,0 @@
export * from './models.d';
export { default as Transformers, MetaTransformers } from './transformers';

View File

@ -1,35 +0,0 @@
import { Model, UUID } from '@/api/definitions';
import { SubuserPermission } from '@/state/server/subusers';
interface User extends Model {
uuid: string;
username: string;
email: string;
image: string;
twoFactorEnabled: boolean;
createdAt: Date;
permissions: SubuserPermission[];
can(permission: SubuserPermission): boolean;
}
interface SSHKey extends Model {
name: string;
publicKey: string;
fingerprint: string;
createdAt: Date;
}
interface ActivityLog extends Model<'actor'> {
id: string;
batch: UUID | null;
event: string;
ip: string | null;
isApi: boolean;
description: string | null;
properties: Record<string, string | unknown>;
hasAdditionalMetadata: boolean;
timestamp: Date;
relationships: {
actor: User | null;
};
}

View File

@ -1,50 +0,0 @@
import * as Models from '@definitions/user/models';
import { FractalResponseData } from '@/api/http';
import { transform } from '@definitions/helpers';
export default class Transformers {
static toSSHKey = (data: Record<any, any>): Models.SSHKey => {
return {
name: data.name,
publicKey: data.public_key,
fingerprint: data.fingerprint,
createdAt: new Date(data.created_at),
};
};
static toUser = ({ attributes }: FractalResponseData): Models.User => {
return {
uuid: attributes.uuid,
username: attributes.username,
email: attributes.email,
image: attributes.image,
twoFactorEnabled: attributes['2fa_enabled'],
permissions: attributes.permissions || [],
createdAt: new Date(attributes.created_at),
can(permission): boolean {
return this.permissions.includes(permission);
},
};
};
static toActivityLog = ({ attributes }: FractalResponseData): Models.ActivityLog => {
const { actor } = attributes.relationships || {};
return {
id: attributes.id,
batch: attributes.batch,
event: attributes.event,
ip: attributes.ip,
isApi: attributes.is_api,
description: attributes.description,
properties: attributes.properties,
hasAdditionalMetadata: attributes.has_additional_metadata ?? false,
timestamp: new Date(attributes.timestamp),
relationships: {
actor: transform(actor as FractalResponseData, this.toUser, null),
},
};
};
}
export class MetaTransformers {}

View File

@ -1,26 +0,0 @@
import { rawDataToServerObject, Server } from '@/api/server/getServer';
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
interface QueryParams {
query?: string;
page?: number;
type?: string;
}
export default ({ query, ...params }: QueryParams): Promise<PaginatedResult<Server>> => {
return new Promise((resolve, reject) => {
http.get('/api/client', {
params: {
'filter[*]': query,
...params,
},
})
.then(({ data }) =>
resolve({
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum)),
pagination: getPaginationSet(data.meta.pagination),
})
)
.catch(reject);
});
};

View File

@ -1,10 +0,0 @@
import { PanelPermissions } from '@/state/permissions';
import http from '@/api/http';
export default (): Promise<PanelPermissions> => {
return new Promise((resolve, reject) => {
http.get('/api/client/permissions')
.then(({ data }) => resolve(data.attributes.permissions))
.catch(reject);
});
};

View File

@ -1,160 +0,0 @@
import axios, { AxiosInstance } from 'axios';
import { store } from '@/state';
const http: AxiosInstance = axios.create({
withCredentials: true,
timeout: 20000,
headers: {
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
http.interceptors.request.use((req) => {
if (!req.url?.endsWith('/resources')) {
store.getActions().progress.startContinuous();
}
return req;
});
http.interceptors.response.use(
(resp) => {
if (!resp.request?.url?.endsWith('/resources')) {
store.getActions().progress.setComplete();
}
return resp;
},
(error) => {
store.getActions().progress.setComplete();
throw error;
}
);
export default http;
/**
* Converts an error into a human readable response. Mostly just a generic helper to
* make sure we display the message from the server back to the user if we can.
*/
export function httpErrorToHuman(error: any): string {
if (error.response && error.response.data) {
let { data } = error.response;
// Some non-JSON requests can still return the error as a JSON block. In those cases, attempt
// to parse it into JSON so we can display an actual error.
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (e) {
// do nothing, bad json
}
}
if (data.errors && data.errors[0] && data.errors[0].detail) {
return data.errors[0].detail;
}
// Errors from daemon directory, mostly just for file uploads.
if (data.error && typeof data.error === 'string') {
return data.error;
}
}
return error.message;
}
export interface FractalResponseData {
object: string;
attributes: {
[k: string]: any;
relationships?: Record<string, FractalResponseData | FractalResponseList | null | undefined>;
};
}
export interface FractalResponseList {
object: 'list';
data: FractalResponseData[];
}
export interface FractalPaginatedResponse extends FractalResponseList {
meta: {
pagination: {
total: number;
count: number;
/* eslint-disable camelcase */
per_page: number;
current_page: number;
total_pages: number;
/* eslint-enable camelcase */
};
};
}
export interface PaginatedResult<T> {
items: T[];
pagination: PaginationDataSet;
}
export interface PaginationDataSet {
total: number;
count: number;
perPage: number;
currentPage: number;
totalPages: number;
}
export function getPaginationSet(data: any): PaginationDataSet {
return {
total: data.total,
count: data.count,
perPage: data.per_page,
currentPage: data.current_page,
totalPages: data.total_pages,
};
}
type QueryBuilderFilterValue = string | number | boolean | null;
export interface QueryBuilderParams<FilterKeys extends string = string, SortKeys extends string = string> {
page?: number;
filters?: {
[K in FilterKeys]?: QueryBuilderFilterValue | Readonly<QueryBuilderFilterValue[]>;
};
sorts?: {
[K in SortKeys]?: -1 | 0 | 1 | 'asc' | 'desc' | null;
};
}
/**
* Helper function that parses a data object provided and builds query parameters
* for the Laravel Query Builder package automatically. This will apply sorts and
* filters deterministically based on the provided values.
*/
export const withQueryBuilderParams = (data?: QueryBuilderParams): Record<string, unknown> => {
if (!data) return {};
const filters = Object.keys(data.filters || {}).reduce((obj, key) => {
const value = data.filters?.[key];
return !value || value === '' ? obj : { ...obj, [`filter[${key}]`]: value };
}, {} as NonNullable<QueryBuilderParams['filters']>);
const sorts = Object.keys(data.sorts || {}).reduce((arr, key) => {
const value = data.sorts?.[key];
if (!value || !['asc', 'desc', 1, -1].includes(value)) {
return arr;
}
return [...arr, (value === -1 || value === 'desc' ? '-' : '') + key];
}, [] as string[]);
return {
...filters,
sort: !sorts.length ? undefined : sorts.join(','),
page: data.page,
};
};

View File

@ -1,21 +0,0 @@
import http from '@/api/http';
import { AxiosError } from 'axios';
import { History } from 'history';
export const setupInterceptors = (history: History) => {
http.interceptors.response.use(
(resp) => resp,
(error: AxiosError) => {
if (error.response?.status === 400) {
if (
(error.response?.data as Record<string, any>).errors?.[0].code === 'TwoFactorAuthRequiredException'
) {
if (!window.location.pathname.startsWith('/account')) {
history.replace('/account', { twoFactorRedirect: true });
}
}
}
throw error;
}
);
};

View File

@ -1,35 +0,0 @@
import useSWR, { ConfigInterface, responseInterface } from 'swr';
import { ActivityLog, Transformers } from '@definitions/user';
import { AxiosError } from 'axios';
import http, { PaginatedResult, QueryBuilderParams, withQueryBuilderParams } from '@/api/http';
import { toPaginatedSet } from '@definitions/helpers';
import useFilteredObject from '@/plugins/useFilteredObject';
import { useServerSWRKey } from '@/plugins/useSWRKey';
import { ServerContext } from '@/state/server';
export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>;
const useActivityLogs = (
filters?: ActivityLogFilters,
config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>
): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => {
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
const key = useServerSWRKey(['activity', useFilteredObject(filters || {})]);
return useSWR<PaginatedResult<ActivityLog>>(
key,
async () => {
const { data } = await http.get(`/api/client/servers/${uuid}/activity`, {
params: {
...withQueryBuilderParams(filters),
include: ['actor'],
},
});
return toPaginatedSet(data, Transformers.toActivityLog);
},
{ revalidateOnMount: false, ...(config || {}) }
);
};
export { useActivityLogs };

View File

@ -1,19 +0,0 @@
import http from '@/api/http';
import { ServerBackup } from '@/api/server/types';
import { rawDataToServerBackup } from '@/api/transformers';
interface RequestParameters {
name?: string;
ignored?: string;
isLocked: boolean;
}
export default async (uuid: string, params: RequestParameters): Promise<ServerBackup> => {
const { data } = await http.post(`/api/client/servers/${uuid}/backups`, {
name: params.name,
ignored: params.ignored,
is_locked: params.isLocked,
});
return rawDataToServerBackup(data);
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, backup: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/servers/${uuid}/backups/${backup}`)
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, backup: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/backups/${backup}/download`)
.then(({ data }) => resolve(data.attributes.url))
.catch(reject);
});
};

View File

@ -1,7 +0,0 @@
import http from '@/api/http';
export const restoreServerBackup = async (uuid: string, backup: string, truncate?: boolean): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/backups/${backup}/restore`, {
truncate,
});
};

View File

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

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, database: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/servers/${uuid}/databases/${database}`)
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,31 +0,0 @@
import http from '@/api/http';
export interface ServerDatabase {
id: string;
name: string;
username: string;
connectionString: string;
allowConnectionsFrom: string;
password?: string;
}
export const rawDataToServerDatabase = (data: any): ServerDatabase => ({
id: data.id,
name: data.name,
username: data.username,
connectionString: `${data.host.address}:${data.host.port}`,
allowConnectionsFrom: data.connections_from,
password: data.relationships.password?.attributes?.password,
});
export default (uuid: string, includePassword = true): Promise<ServerDatabase[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/databases`, {
params: includePassword ? { include: 'password' } : undefined,
})
.then((response) =>
resolve((response.data.data || []).map((item: any) => rawDataToServerDatabase(item.attributes)))
)
.catch(reject);
});
};

View File

@ -1,10 +0,0 @@
import { rawDataToServerDatabase, ServerDatabase } from '@/api/server/databases/getServerDatabases';
import http from '@/api/http';
export default (uuid: string, database: string): Promise<ServerDatabase> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/databases/${database}/rotate-password`)
.then((response) => resolve(rawDataToServerDatabase(response.data.attributes)))
.catch(reject);
});
};

View File

@ -1,37 +0,0 @@
/* The MIT License (MIT)
Pterodactyl®
Copyright © Dane Everitt <dane@daneeveritt.com> and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. */
import http from '@/api/http';
interface Data {
file: string;
mode: string;
}
export default (uuid: string, directory: string, files: Data[]): Promise<void> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/files/chmod`, { root: directory, files })
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,17 +0,0 @@
import { FileObject } from '@/api/server/files/loadDirectory';
import http from '@/api/http';
import { rawDataToFileObject } from '@/api/transformers';
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 },
{
timeout: 60000,
timeoutErrorMessage:
'It looks like this archive is taking a long time to generate. It will appear once completed.',
}
);
return rawDataToFileObject(data);
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, location: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/files/copy`, { location })
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, root: string, name: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/files/create-folder`, { root, name })
.then(() => resolve())
.catch(reject);
});
};

View File

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

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, directory: string, files: string[]): Promise<void> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/files/delete`, { root: directory, files })
.then(() => resolve())
.catch(reject);
});
};

View File

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

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, file: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/files/download`, { params: { file } })
.then(({ data }) => resolve(data.attributes.url))
.catch(reject);
});
};

View File

@ -1,32 +0,0 @@
/* The MIT License (MIT)
Pterodactyl®
Copyright © Dane Everitt <dane@daneeveritt.com> and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. */
import http from '@/api/http';
export default (uuid: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/files/upload`)
.then(({ data }) => resolve(data.attributes.url))
.catch(reject);
});
};

View File

@ -1,25 +0,0 @@
import http from '@/api/http';
import { rawDataToFileObject } from '@/api/transformers';
export interface FileObject {
key: string;
name: string;
mode: string;
modeBits: string;
size: number;
isFile: boolean;
isSymlink: boolean;
mimetype: string;
createdAt: Date;
modifiedAt: Date;
isArchiveType: () => boolean;
isEditable: () => boolean;
}
export default async (uuid: string, directory?: string): Promise<FileObject[]> => {
const { data } = await http.get(`/api/client/servers/${uuid}/files/list`, {
params: { directory: directory ?? '/' },
});
return (data.data || []).map(rawDataToFileObject);
};

View File

@ -1,14 +0,0 @@
import http from '@/api/http';
interface Data {
to: string;
from: string;
}
export default (uuid: string, directory: string, files: Data[]): Promise<void> => {
return new Promise((resolve, reject) => {
http.put(`/api/client/servers/${uuid}/files/rename`, { root: directory, files })
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,10 +0,0 @@
import http from '@/api/http';
export default async (uuid: string, file: string, content: string): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/files/write`, content, {
params: { file },
headers: {
'Content-Type': 'text/plain',
},
});
};

View File

@ -1,89 +0,0 @@
import http, { FractalResponseData, FractalResponseList } from '@/api/http';
import { rawDataToServerAllocation, rawDataToServerEggVariable } from '@/api/transformers';
import { ServerEggVariable, ServerStatus } from '@/api/server/types';
export interface Allocation {
id: number;
ip: string;
alias: string | null;
port: number;
notes: string | null;
isDefault: boolean;
}
export interface Server {
id: string;
internalId: number | string;
uuid: string;
name: string;
node: string;
isNodeUnderMaintenance: boolean;
status: ServerStatus;
sftpDetails: {
ip: string;
alias: string;
port: number;
};
invocation: string;
dockerImage: string;
description: string;
limits: {
memory: number;
swap: number;
disk: number;
io: number;
cpu: number;
threads: string;
};
eggFeatures: string[];
featureLimits: {
databases: number;
allocations: number;
backups: number;
};
isTransferring: boolean;
variables: ServerEggVariable[];
allocations: Allocation[];
}
export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
id: data.identifier,
internalId: data.internal_id,
uuid: data.uuid,
name: data.name,
node: data.node,
isNodeUnderMaintenance: data.is_node_under_maintenance,
status: data.status,
invocation: data.invocation,
dockerImage: data.docker_image,
sftpDetails: {
ip: data.sftp_details.ip,
alias: data.sftp_details.alias,
port: data.sftp_details.port,
},
description: data.description ? (data.description.length > 0 ? data.description : null) : null,
limits: { ...data.limits },
eggFeatures: data.egg_features || [],
featureLimits: { ...data.feature_limits },
isTransferring: data.is_transferring,
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(
rawDataToServerEggVariable
),
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(
rawDataToServerAllocation
),
});
export default (uuid: string): Promise<[Server, string[]]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}`)
.then(({ data }) =>
resolve([
rawDataToServerObject(data),
// eslint-disable-next-line camelcase
data.meta?.is_server_owner ? ['*'] : data.meta?.user_permissions || [],
])
)
.catch(reject);
});
};

View File

@ -1,33 +0,0 @@
import http from '@/api/http';
export type ServerPowerState = 'offline' | 'starting' | 'running' | 'stopping';
export interface ServerStats {
status: ServerPowerState;
isSuspended: boolean;
memoryUsageInBytes: number;
cpuUsagePercent: number;
diskUsageInBytes: number;
networkRxInBytes: number;
networkTxInBytes: number;
uptime: number;
}
export default (server: string): Promise<ServerStats> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${server}/resources`)
.then(({ data: { attributes } }) =>
resolve({
status: attributes.current_state,
isSuspended: attributes.is_suspended,
memoryUsageInBytes: attributes.resources.memory_bytes,
cpuUsagePercent: attributes.resources.cpu_absolute,
diskUsageInBytes: attributes.resources.disk_bytes,
networkRxInBytes: attributes.resources.network_rx_bytes,
networkTxInBytes: attributes.resources.network_tx_bytes,
uptime: attributes.resources.uptime,
})
)
.catch(reject);
});
};

View File

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

View File

@ -1,9 +0,0 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export default async (uuid: string): Promise<Allocation> => {
const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations`);
return rawDataToServerAllocation(data);
};

View File

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

View File

@ -1,9 +0,0 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export default async (uuid: string, id: number): Promise<Allocation> => {
const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations/${id}/primary`);
return rawDataToServerAllocation(data);
};

View File

@ -1,9 +0,0 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export default async (uuid: string, id: number, notes: string | null): Promise<Allocation> => {
const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations/${id}`, { notes });
return rawDataToServerAllocation(data);
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/settings/reinstall`)
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, name: string, description?: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/settings/rename`, { name, description })
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,19 +0,0 @@
import { rawDataToServerSchedule, Schedule } from '@/api/server/schedules/getServerSchedules';
import http from '@/api/http';
type Data = Pick<Schedule, 'cron' | 'name' | 'onlyWhenOnline' | 'isActive'> & { id?: number };
export default async (uuid: string, schedule: Data): Promise<Schedule> => {
const { data } = await http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, {
is_active: schedule.isActive,
only_when_online: schedule.onlyWhenOnline,
name: schedule.name,
minute: schedule.cron.minute,
hour: schedule.cron.hour,
day_of_month: schedule.cron.dayOfMonth,
month: schedule.cron.month,
day_of_week: schedule.cron.dayOfWeek,
});
return rawDataToServerSchedule(data.attributes);
};

View File

@ -1,23 +0,0 @@
import { rawDataToServerTask, Task } from '@/api/server/schedules/getServerSchedules';
import http from '@/api/http';
interface Data {
action: string;
payload: string;
timeOffset: string | number;
continueOnFailure: boolean;
}
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}` : ''}`,
{
action: data.action,
payload: data.payload,
continue_on_failure: data.continueOnFailure,
time_offset: data.timeOffset,
}
);
return rawDataToServerTask(response.attributes);
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, schedule: number): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/servers/${uuid}/schedules/${schedule}`)
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, scheduleId: number, taskId: number): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/servers/${uuid}/schedules/${scheduleId}/tasks/${taskId}`)
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,14 +0,0 @@
import http from '@/api/http';
import { rawDataToServerSchedule, Schedule } from '@/api/server/schedules/getServerSchedules';
export default (uuid: string, schedule: number): Promise<Schedule> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/schedules/${schedule}`, {
params: {
include: ['tasks'],
},
})
.then(({ data }) => resolve(rawDataToServerSchedule(data.attributes)))
.catch(reject);
});
};

View File

@ -1,77 +0,0 @@
import http from '@/api/http';
export interface Schedule {
id: number;
name: string;
cron: {
dayOfWeek: string;
month: string;
dayOfMonth: string;
hour: string;
minute: string;
};
isActive: boolean;
isProcessing: boolean;
onlyWhenOnline: boolean;
lastRunAt: Date | null;
nextRunAt: Date | null;
createdAt: Date;
updatedAt: Date;
tasks: Task[];
}
export interface Task {
id: number;
sequenceId: number;
action: string;
payload: string;
timeOffset: number;
isQueued: boolean;
continueOnFailure: boolean;
createdAt: Date;
updatedAt: Date;
}
export const rawDataToServerTask = (data: any): Task => ({
id: data.id,
sequenceId: data.sequence_id,
action: data.action,
payload: data.payload,
timeOffset: data.time_offset,
isQueued: data.is_queued,
continueOnFailure: data.continue_on_failure,
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
});
export const rawDataToServerSchedule = (data: any): Schedule => ({
id: data.id,
name: data.name,
cron: {
dayOfWeek: data.cron.day_of_week,
month: data.cron.month,
dayOfMonth: data.cron.day_of_month,
hour: data.cron.hour,
minute: data.cron.minute,
},
isActive: data.is_active,
isProcessing: data.is_processing,
onlyWhenOnline: data.only_when_online,
lastRunAt: data.last_run_at ? new Date(data.last_run_at) : null,
nextRunAt: data.next_run_at ? new Date(data.next_run_at) : null,
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
tasks: (data.relationships?.tasks?.data || []).map((row: any) => rawDataToServerTask(row.attributes)),
});
export default async (uuid: string): Promise<Schedule[]> => {
const { data } = await http.get(`/api/client/servers/${uuid}/schedules`, {
params: {
include: ['tasks'],
},
});
return (data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes));
};

View File

@ -1,4 +0,0 @@
import http from '@/api/http';
export default async (server: string, schedule: number): Promise<void> =>
await http.post(`/api/client/servers/${server}/schedules/${schedule}/execute`);

View File

@ -1,5 +0,0 @@
import http from '@/api/http';
export default async (uuid: string, image: string): Promise<void> => {
await http.put(`/api/client/servers/${uuid}/settings/docker-image`, { docker_image: image });
};

View File

@ -1,29 +0,0 @@
export type ServerStatus =
| 'installing'
| 'install_failed'
| 'reinstall_failed'
| 'suspended'
| 'restoring_backup'
| null;
export interface ServerBackup {
uuid: string;
isSuccessful: boolean;
isLocked: boolean;
name: string;
ignoredFiles: string;
checksum: string;
bytes: number;
createdAt: Date;
completedAt: Date | null;
}
export interface ServerEggVariable {
name: string;
description: string;
envVariable: string;
defaultValue: string;
serverValue: string | null;
isEditable: boolean;
rules: string[];
}

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
import { ServerEggVariable } from '@/api/server/types';
import { rawDataToServerEggVariable } from '@/api/transformers';
export default async (uuid: string, key: string, value: string): Promise<[ServerEggVariable, string]> => {
const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value });
return [rawDataToServerEggVariable(data), data.meta.startup_command];
};

View File

@ -1,18 +0,0 @@
import http from '@/api/http';
import { rawDataToServerSubuser } from '@/api/server/users/getServerSubusers';
import { Subuser } from '@/state/server/subusers';
interface Params {
email: string;
permissions: string[];
}
export default (uuid: string, params: Params, subuser?: Subuser): Promise<Subuser> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/users${subuser ? `/${subuser.uuid}` : ''}`, {
...params,
})
.then((data) => resolve(rawDataToServerSubuser(data.data)))
.catch(reject);
});
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
export default (uuid: string, userId: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/servers/${uuid}/users/${userId}`)
.then(() => resolve())
.catch(reject);
});
};

View File

@ -1,21 +0,0 @@
import http, { FractalResponseData } from '@/api/http';
import { Subuser } from '@/state/server/subusers';
export const rawDataToServerSubuser = (data: FractalResponseData): Subuser => ({
uuid: data.attributes.uuid,
username: data.attributes.username,
email: data.attributes.email,
image: data.attributes.image,
twoFactorEnabled: data.attributes['2fa_enabled'],
createdAt: new Date(data.attributes.created_at),
permissions: data.attributes.permissions || [],
can: (permission) => (data.attributes.permissions || []).indexOf(permission) >= 0,
});
export default (uuid: string): Promise<Subuser[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/users`)
.then(({ data }) => resolve((data.data || []).map(rawDataToServerSubuser)))
.catch(reject);
});
};

View File

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

View File

@ -1,30 +0,0 @@
import useSWR from 'swr';
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
import { ServerBackup } from '@/api/server/types';
import { rawDataToServerBackup } from '@/api/transformers';
import { ServerContext } from '@/state/server';
import { createContext, useContext } from 'react';
interface ctx {
page: number;
setPage: (value: number | ((s: number) => number)) => void;
}
export const Context = createContext<ctx>({ page: 1, setPage: () => 1 });
type BackupResponse = PaginatedResult<ServerBackup> & { backupCount: number };
export default () => {
const { page } = useContext(Context);
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
return useSWR<BackupResponse>(['server:backups', uuid, page], async () => {
const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } });
return {
items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination),
backupCount: data.meta.backup_count,
};
});
};

View File

@ -1,27 +0,0 @@
import useSWR, { ConfigInterface } from 'swr';
import http, { FractalResponseList } from '@/api/http';
import { rawDataToServerEggVariable } from '@/api/transformers';
import { ServerEggVariable } from '@/api/server/types';
interface Response {
invocation: string;
variables: ServerEggVariable[];
dockerImages: Record<string, string>;
}
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 variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable);
return {
variables,
invocation: data.meta.startup_command,
dockerImages: data.meta.docker_images || {},
};
},
{ initialData: initialData || undefined, errorRetryCount: 3, ...(config || {}) }
);

View File

@ -1,77 +0,0 @@
import { Allocation } from '@/api/server/getServer';
import { FractalResponseData } from '@/api/http';
import { FileObject } from '@/api/server/files/loadDirectory';
import { ServerBackup, ServerEggVariable } from '@/api/server/types';
export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({
id: data.attributes.id,
ip: data.attributes.ip,
alias: data.attributes.ip_alias,
port: data.attributes.port,
notes: data.attributes.notes,
isDefault: data.attributes.is_default,
});
export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
key: `${data.attributes.is_file ? 'file' : 'dir'}_${data.attributes.name}`,
name: data.attributes.name,
mode: data.attributes.mode,
modeBits: data.attributes.mode_bits,
size: Number(data.attributes.size),
isFile: data.attributes.is_file,
isSymlink: data.attributes.is_symlink,
mimetype: data.attributes.mimetype,
createdAt: new Date(data.attributes.created_at),
modifiedAt: new Date(data.attributes.modified_at),
isArchiveType: function () {
return (
this.isFile &&
[
'application/vnd.rar', // .rar
'application/x-rar-compressed', // .rar (2)
'application/x-tar', // .tar
'application/x-br', // .tar.br
'application/x-bzip2', // .tar.bz2, .bz2
'application/gzip', // .tar.gz, .gz
'application/x-gzip',
'application/x-lzip', // .tar.lz4, .lz4 (not sure if this mime type is correct)
'application/x-sz', // .tar.sz, .sz (not sure if this mime type is correct)
'application/x-xz', // .tar.xz, .xz
'application/x-7z-compressed', // .7z
'application/zstd', // .tar.zst, .zst
'application/zip', // .zip
].indexOf(this.mimetype) >= 0
);
},
isEditable: function () {
if (this.isArchiveType() || !this.isFile) return false;
const matches = ['application/jar', 'application/octet-stream', 'inode/directory', /^image\/(?!svg\+xml)/];
return matches.every((m) => !this.mimetype.match(m));
},
});
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
uuid: attributes.uuid,
isSuccessful: attributes.is_successful,
isLocked: attributes.is_locked,
name: attributes.name,
ignoredFiles: attributes.ignored_files,
checksum: attributes.checksum,
bytes: attributes.bytes,
createdAt: new Date(attributes.created_at),
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
});
export const rawDataToServerEggVariable = ({ attributes }: FractalResponseData): ServerEggVariable => ({
name: attributes.name,
description: attributes.description,
envVariable: attributes.env_variable,
defaultValue: attributes.default_value,
serverValue: attributes.server_value,
isEditable: attributes.is_editable,
rules: attributes.rules.split('|'),
});

View File

@ -1,66 +0,0 @@
import tw from 'twin.macro';
import { createGlobalStyle } from 'styled-components/macro';
export default createGlobalStyle`
body {
${tw`font-sans bg-neutral-800 text-neutral-200`};
letter-spacing: 0.015em;
}
h1, h2, h3, h4, h5, h6 {
${tw`font-medium tracking-normal font-header`};
}
p {
${tw`text-neutral-200 leading-snug font-sans`};
}
form {
${tw`m-0`};
}
textarea, select, input, button, button:focus, button:focus-visible {
${tw`outline-none`};
}
input[type=number]::-webkit-outer-spin-button,
input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none !important;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield !important;
}
/* Scroll Bar Style */
::-webkit-scrollbar {
background: none;
width: 16px;
height: 16px;
}
::-webkit-scrollbar-thumb {
border: solid 0 rgb(0 0 0 / 0%);
border-right-width: 4px;
border-left-width: 4px;
-webkit-border-radius: 9px 4px;
-webkit-box-shadow: inset 0 0 0 1px hsl(211, 10%, 53%), inset 0 0 0 4px hsl(209deg 18% 30%);
}
::-webkit-scrollbar-track-piece {
margin: 4px 0;
}
::-webkit-scrollbar-thumb:horizontal {
border-right-width: 0;
border-left-width: 0;
border-top-width: 4px;
border-bottom-width: 4px;
-webkit-border-radius: 4px 9px;
}
::-webkit-scrollbar-corner {
background: transparent;
}
`;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,97 +0,0 @@
import React, { lazy } from 'react';
import { hot } from 'react-hot-loader/root';
import { Route, Router, Switch } from 'react-router-dom';
import { StoreProvider } from 'easy-peasy';
import { store } from '@/state';
import { SiteSettings } from '@/state/settings';
import ProgressBar from '@/components/elements/ProgressBar';
import { NotFound } from '@/components/elements/ScreenBlock';
import tw from 'twin.macro';
import GlobalStylesheet from '@/assets/css/GlobalStylesheet';
import { history } from '@/components/history';
import { setupInterceptors } from '@/api/interceptors';
import AuthenticatedRoute from '@/components/elements/AuthenticatedRoute';
import { ServerContext } from '@/state/server';
import '@/assets/tailwind.css';
import Spinner from '@/components/elements/Spinner';
const DashboardRouter = lazy(() => import(/* webpackChunkName: "dashboard" */ '@/routers/DashboardRouter'));
const ServerRouter = lazy(() => import(/* webpackChunkName: "server" */ '@/routers/ServerRouter'));
const AuthenticationRouter = lazy(() => import(/* webpackChunkName: "auth" */ '@/routers/AuthenticationRouter'));
interface ExtendedWindow extends Window {
SiteConfiguration?: SiteSettings;
PanelUser?: {
uuid: string;
username: string;
email: string;
/* eslint-disable camelcase */
root_admin: boolean;
admin: boolean;
use_totp: boolean;
language: string;
updated_at: string;
created_at: string;
/* eslint-enable camelcase */
};
}
setupInterceptors(history);
const App = () => {
const { PanelUser, SiteConfiguration } = window as ExtendedWindow;
if (PanelUser && !store.getState().user.data) {
store.getActions().user.setUserData({
uuid: PanelUser.uuid,
username: PanelUser.username,
email: PanelUser.email,
language: PanelUser.language,
rootAdmin: PanelUser.root_admin,
admin: PanelUser.admin,
useTotp: PanelUser.use_totp,
createdAt: new Date(PanelUser.created_at),
updatedAt: new Date(PanelUser.updated_at),
});
}
if (!store.getState().settings.data) {
store.getActions().settings.setSettings(SiteConfiguration!);
}
return (
<>
<GlobalStylesheet />
<StoreProvider store={store}>
<ProgressBar />
<div css={tw`mx-auto w-auto`}>
<Router history={history}>
<Switch>
<Route path={'/auth'}>
<Spinner.Suspense>
<AuthenticationRouter />
</Spinner.Suspense>
</Route>
<AuthenticatedRoute path={'/server/:id'}>
<Spinner.Suspense>
<ServerContext.Provider>
<ServerRouter />
</ServerContext.Provider>
</Spinner.Suspense>
</AuthenticatedRoute>
<AuthenticatedRoute path={'/'}>
<Spinner.Suspense>
<DashboardRouter />
</Spinner.Suspense>
</AuthenticatedRoute>
<Route path={'*'}>
<NotFound />
</Route>
</Switch>
</Router>
</div>
</StoreProvider>
</>
);
};
export default hot(App);

View File

@ -1,26 +0,0 @@
import React from 'react';
import BoringAvatar, { AvatarProps } from 'boring-avatars';
import { useStoreState } from '@/state/hooks';
const palette = ['#FFAD08', '#EDD75A', '#73B06F', '#0C8F8F', '#587291'];
type Props = Omit<AvatarProps, 'colors'>;
const _Avatar = ({ variant = 'beam', ...props }: AvatarProps) => (
<BoringAvatar colors={palette} variant={variant} {...props} />
);
const _UserAvatar = ({ variant = 'beam', ...props }: Omit<Props, 'name'>) => {
const uuid = useStoreState((state) => state.user.data?.uuid);
return <BoringAvatar colors={palette} name={uuid || 'system'} variant={variant} {...props} />;
};
_Avatar.displayName = 'Avatar';
_UserAvatar.displayName = 'Avatar.User';
const Avatar = Object.assign(_Avatar, {
User: _UserAvatar,
});
export default Avatar;

View File

@ -1,30 +0,0 @@
import React from 'react';
import MessageBox from '@/components/MessageBox';
import { useStoreState } from 'easy-peasy';
import tw from 'twin.macro';
type Props = Readonly<{
byKey?: string;
className?: string;
}>;
const FlashMessageRender = ({ byKey, className }: Props) => {
const flashes = useStoreState((state) =>
state.flashes.items.filter((flash) => (byKey ? flash.key === byKey : true))
);
return flashes.length ? (
<div className={className}>
{flashes.map((flash, index) => (
<React.Fragment key={flash.id || flash.type + flash.message}>
{index > 0 && <div css={tw`mt-2`}></div>}
<MessageBox type={flash.type} title={flash.title}>
{flash.message}
</MessageBox>
</React.Fragment>
))}
</div>
) : null;
};
export default FlashMessageRender;

View File

@ -1,67 +0,0 @@
import * as React from 'react';
import tw, { TwStyle } from 'twin.macro';
import styled from 'styled-components/macro';
export type FlashMessageType = 'success' | 'info' | 'warning' | 'error';
interface Props {
title?: string;
children: string;
type?: FlashMessageType;
}
const styling = (type?: FlashMessageType): TwStyle | string => {
switch (type) {
case 'error':
return tw`bg-red-600 border-red-800`;
case 'info':
return tw`bg-primary-600 border-primary-800`;
case 'success':
return tw`bg-green-600 border-green-800`;
case 'warning':
return tw`bg-yellow-600 border-yellow-800`;
default:
return '';
}
};
const getBackground = (type?: FlashMessageType): TwStyle | string => {
switch (type) {
case 'error':
return tw`bg-red-500`;
case 'info':
return tw`bg-primary-500`;
case 'success':
return tw`bg-green-500`;
case 'warning':
return tw`bg-yellow-500`;
default:
return '';
}
};
const Container = styled.div<{ $type?: FlashMessageType }>`
${tw`p-2 border items-center leading-normal rounded flex w-full text-sm text-white`};
${(props) => styling(props.$type)};
`;
Container.displayName = 'MessageBox.Container';
const MessageBox = ({ title, children, type }: Props) => (
<Container css={tw`lg:inline-flex`} $type={type} role={'alert'}>
{title && (
<span
className={'title'}
css={[
tw`flex rounded-full uppercase px-2 py-1 text-xs font-bold mr-3 leading-none`,
getBackground(type),
]}
>
{title}
</span>
)}
<span css={tw`mr-2 text-left flex-auto`}>{children}</span>
</Container>
);
MessageBox.displayName = 'MessageBox';
export default MessageBox;

View File

@ -1,100 +0,0 @@
import * as React from 'react';
import { useState } from 'react';
import { Link, NavLink } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCogs, faHandSparkles, faLayerGroup, faSignOutAlt } from '@fortawesome/free-solid-svg-icons';
import { useStoreState } from 'easy-peasy';
import { useTranslation } from 'react-i18next';
import { ApplicationStore } from '@/state';
import SearchContainer from '@/components/dashboard/search/SearchContainer';
import tw, { theme } from 'twin.macro';
import styled from 'styled-components/macro';
import http from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import Tooltip from '@/components/elements/tooltip/Tooltip';
import Avatar from '@/components/Avatar';
const RightNavigation = styled.div`
& > a,
& > button,
& > .navigation-link {
${tw`flex items-center h-full no-underline text-neutral-300 px-6 cursor-pointer transition-all duration-150`};
&:active,
&:hover {
${tw`text-neutral-100 bg-black`};
}
&:active,
&:hover,
&.active {
box-shadow: inset 0 -2px ${theme`colors.cyan.600`.toString()};
}
}
`;
export default () => {
const { t } = useTranslation('strings');
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
const isAdmin = useStoreState((state: ApplicationStore) => state.user.data!.admin);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const onTriggerLogout = () => {
setIsLoggingOut(true);
http.post('/auth/logout').finally(() => {
// @ts-expect-error this is valid
window.location = '/';
});
};
return (
<div className={'w-full bg-neutral-900 shadow-md overflow-x-auto'}>
<SpinnerOverlay visible={isLoggingOut} />
<div className={'mx-auto w-full flex items-center h-[3.5rem] max-w-[1200px]'}>
<div id={'logo'} className={'flex-1'}>
<Link
to={'/'}
className={
'text-2xl font-header px-4 no-underline text-neutral-200 hover:text-neutral-100 transition-colors duration-150'
}
>
{name}
</Link>
</div>
<RightNavigation className={'flex h-full items-center justify-center'}>
<SearchContainer />
<Tooltip placement={'bottom'} content={'New Client Area'}>
<NavLink to={'/app/'} target={'_blank'} rel={'noreferrer'}>
<FontAwesomeIcon icon={faHandSparkles} />
</NavLink>
</Tooltip>
<Tooltip placement={'bottom'} content={t<string>('dashboard')}>
<NavLink to={'/'} exact>
<FontAwesomeIcon icon={faLayerGroup} />
</NavLink>
</Tooltip>
{isAdmin && (
<Tooltip placement={'bottom'} content={t<string>('admin')}>
<a href={'/admin'} rel={'noreferrer'}>
<FontAwesomeIcon icon={faCogs} />
</a>
</Tooltip>
)}
<Tooltip placement={'bottom'} content={t<string>('account_settings')}>
<NavLink to={'/account'}>
<span className={'flex items-center w-5 h-5'}>
<Avatar.User />
</span>
</NavLink>
</Tooltip>
<Tooltip placement={'bottom'} content={t<string>('sign_out')}>
<button onClick={onTriggerLogout}>
<FontAwesomeIcon icon={faSignOutAlt} />
</button>
</Tooltip>
</RightNavigation>
</div>
</div>
);
};

View File

@ -1,118 +0,0 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { useStoreState } from 'easy-peasy';
import Field from '@/components/elements/Field';
import { Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import { useTranslation } from 'react-i18next';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Turnstile, { useTurnstile } from 'react-turnstile';
import useFlash from '@/plugins/useFlash';
interface Values {
email: string;
}
export default () => {
const { t } = useTranslation('auth');
const turnstile = useTurnstile();
const [token, setToken] = useState('');
const { clearFlashes, addFlash, addError } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha);
useEffect(() => {
clearFlashes();
}, []);
const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
clearFlashes();
if (recaptchaEnabled && !token) {
addError({ message: 'No captcha token found.' });
setSubmitting(false);
return;
}
requestPasswordResetEmail(email, token)
.then((response) => {
resetForm();
addFlash({ type: 'success', title: 'Success', message: response });
})
.catch((error) => {
console.error(error);
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
})
.then(() => {
setToken('');
turnstile.reset();
setSubmitting(false);
});
};
return (
<Formik
onSubmit={handleSubmission}
initialValues={{ email: '' }}
validationSchema={object().shape({
email: string()
.email(t('forgot_password.required.email'))
.required(t('forgot_password.required.email')),
})}
>
{({ isSubmitting, setSubmitting }) => (
<LoginFormContainer title={t('forgot_password.title')} css={tw`w-full flex`}>
<Field
light
label={'Email'}
description={t('forgot_password.label_help')}
name={'email'}
type={'email'}
/>
{recaptchaEnabled && (
<Turnstile
sitekey={siteKey || '_invalid_key'}
className='mt-6 flex justify-center'
retry='never'
onVerify={(token) => {
setToken(token);
}}
onError={(error) => {
console.error('Error verifying captcha: ' + error);
addError({ message: 'Error verifying captcha: ' + error });
setSubmitting(false);
setToken('');
}}
onExpire={() => {
setSubmitting(false);
setToken('');
}}
/>
)}
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} disabled={isSubmitting} isLoading={isSubmitting}>
{t('forgot_password.button')}
</Button>
</div>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/login'}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
{t('return_to_login')}
</Link>
</div>
</LoginFormContainer>
)}
</Formik>
);
};

View File

@ -1,115 +0,0 @@
import React, { useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import loginCheckpoint from '@/api/auth/loginCheckpoint';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { ActionCreator } from 'easy-peasy';
import { StaticContext } from 'react-router';
import { useFormikContext, withFormik } from 'formik';
import { useTranslation } from 'react-i18next';
import useFlash from '@/plugins/useFlash';
import { FlashStore } from '@/state/flashes';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
code: string;
recoveryCode: '';
}
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }>;
type Props = OwnProps & {
clearAndAddHttpError: ActionCreator<FlashStore['clearAndAddHttpError']['payload']>;
};
const LoginCheckpointContainer = () => {
const { t } = useTranslation('auth');
const { isSubmitting, setFieldValue } = useFormikContext<Values>();
const [isMissingDevice, setIsMissingDevice] = useState(false);
return (
<LoginFormContainer title={t('checkpoint.title')} css={tw`w-full flex`}>
<div css={tw`mt-6`}>
<Field
light
name={isMissingDevice ? 'recoveryCode' : 'code'}
title={isMissingDevice ? t('checkpoint.recovery_code') : t('checkpoint.authentication_code')}
description={
isMissingDevice
? t('checkpoint.recovery_code_description')
: t('checkpoint.authentication_code_description')
}
type={'text'}
autoComplete={'one-time-code'}
autoFocus
/>
</div>
<div css={tw`mt-6`}>
<Button size={'xlarge'} type={'submit'} disabled={isSubmitting} isLoading={isSubmitting}>
{t('checkpoint.button')}
</Button>
</div>
<div css={tw`mt-6 text-center`}>
<span
onClick={() => {
setFieldValue('code', '');
setFieldValue('recoveryCode', '');
setIsMissingDevice((s) => !s);
}}
css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
{!isMissingDevice ? t('checkpoint.lost_device') : t('checkpoint.have_device')}
</span>
</div>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/login'}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
{t('return_to_login')}
</Link>
</div>
</LoginFormContainer>
);
};
const EnhancedForm = withFormik<Props, Values>({
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { clearAndAddHttpError, location } }) => {
loginCheckpoint(location.state?.token || '', code, recoveryCode)
.then((response) => {
if (response.complete) {
// @ts-expect-error this is valid
window.location = response.intended || '/';
return;
}
setSubmitting(false);
})
.catch((error) => {
console.error(error);
setSubmitting(false);
clearAndAddHttpError({ error });
});
},
mapPropsToValues: () => ({
code: '',
recoveryCode: '',
}),
})(LoginCheckpointContainer);
export default ({ history, location, ...props }: OwnProps) => {
const { clearAndAddHttpError } = useFlash();
if (!location.state?.token) {
history.replace('/auth/login');
return null;
}
return (
<EnhancedForm clearAndAddHttpError={clearAndAddHttpError} history={history} location={location} {...props} />
);
};

View File

@ -1,134 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import login from '@/api/auth/login';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { useStoreState } from 'easy-peasy';
import { Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import { useTranslation } from 'react-i18next';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Turnstile, { useTurnstile } from 'react-turnstile';
import useFlash from '@/plugins/useFlash';
interface Values {
username: string;
password: string;
}
const LoginContainer = ({ history }: RouteComponentProps) => {
const { t } = useTranslation(['auth', 'strings']);
const turnstile = useTurnstile();
const [token, setToken] = useState('');
const { clearFlashes, clearAndAddHttpError, addError } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha);
useEffect(() => {
clearFlashes();
}, []);
const onSubmit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes();
if (recaptchaEnabled && !token) {
addError({ message: 'No captcha token found.' });
setSubmitting(false);
return;
}
login({ ...values, recaptchaData: token })
.then((response) => {
if (response.complete) {
// @ts-expect-error this is valid
window.location = response.intended || '/';
return;
}
history.replace('/auth/login/checkpoint', { token: response.confirmationToken });
})
.catch((error) => {
setSubmitting(false);
addError({ message: 'Invalid login, please try again.' });
setToken('');
if (turnstile) {
turnstile.reset();
}
setSubmitting(false);
clearAndAddHttpError({ error });
});
};
return (
<Formik
onSubmit={onSubmit}
initialValues={{ username: '', password: '' }}
validationSchema={object().shape({
username: string().required(t('login.required.username_or_email')),
password: string().required(t('login.required.password')),
})}
>
{({ isSubmitting, setSubmitting }) => (
<LoginFormContainer title={t('login.title')} css={tw`w-full flex`}>
<Field
light
type={'text'}
label={t('user_identifier', { ns: 'strings' })}
name={'username'}
disabled={isSubmitting}
/>
<div css={tw`mt-6`}>
<Field
light
type={'password'}
label={t('password', { ns: 'strings' })}
name={'password'}
disabled={isSubmitting}
/>
</div>
{recaptchaEnabled && (
<Turnstile
sitekey={siteKey || '_invalid_key'}
className='mt-6 flex justify-center'
retry='never'
onVerify={(token) => {
setToken(token);
}}
onError={(error) => {
console.error('Error verifying captcha: ' + error);
addError({ message: 'Error verifying captcha: ' + error });
setSubmitting(false);
setToken('');
}}
onExpire={() => {
setSubmitting(false);
setToken('');
}}
/>
)}
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting} disabled={isSubmitting}>
{t('login.button')}
</Button>
</div>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/password'}
css={tw`text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600`}
>
{t('forgot_password.label')}
</Link>
</div>
</LoginFormContainer>
)}
</Formik>
);
};
export default LoginContainer;

View File

@ -1,47 +0,0 @@
import React, { forwardRef } from 'react';
import { Form } from 'formik';
import styled from 'styled-components/macro';
import { breakpoint } from '@/theme';
import FlashMessageRender from '@/components/FlashMessageRender';
import tw from 'twin.macro';
type Props = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
title?: string;
};
const Container = styled.div`
${breakpoint('sm')`
${tw`w-4/5 mx-auto`}
`};
${breakpoint('md')`
${tw`p-10`}
`};
${breakpoint('lg')`
${tw`w-3/5`}
`};
${breakpoint('xl')`
${tw`w-full`}
max-width: 700px;
`};
`;
export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) => (
<Container>
{title && <h2 css={tw`text-3xl text-center text-neutral-100 font-medium py-4`}>{title}</h2>}
<FlashMessageRender css={tw`mb-2 px-1`} />
<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`flex-none select-none mb-6 md:mb-0 self-center p-6`}>
<img src={'/pelican.svg'} css={tw`block w-48 md:w-56 mx-auto`} />
</div>
<div css={tw`flex-1`}>{props.children}</div>
</div>
</Form>
<p css={tw`text-center text-neutral-500 text-xs mt-4`}>
Pelican &copy; 2024 - {new Date().getFullYear()}&nbsp;
</p>
</Container>
));

View File

@ -1,106 +0,0 @@
import React, { useState } from 'react';
import { RouteComponentProps } from 'react-router';
import { Link } from 'react-router-dom';
import performPasswordReset from '@/api/auth/performPasswordReset';
import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { Formik, FormikHelpers } from 'formik';
import { object, ref, string } from 'yup';
import { useTranslation } from 'react-i18next';
import Field from '@/components/elements/Field';
import Input from '@/components/elements/Input';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
interface Values {
password: string;
passwordConfirmation: string;
}
export default ({ match, location }: RouteComponentProps<{ token: string }>) => {
const { t } = useTranslation('auth');
const [email, setEmail] = useState('');
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const parsed = new URLSearchParams(location.search);
if (email.length === 0 && parsed.get('email')) {
setEmail(parsed.get('email') || '');
}
const submit = ({ password, passwordConfirmation }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes();
performPasswordReset(email, { token: match.params.token, password, passwordConfirmation })
.then(() => {
// @ts-expect-error this is valid
window.location = '/';
})
.catch((error) => {
console.error(error);
setSubmitting(false);
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
});
};
return (
<Formik
onSubmit={submit}
initialValues={{
password: '',
passwordConfirmation: '',
}}
validationSchema={object().shape({
password: string()
.required(t('reset_password.required.password'))
.min(8, t('reset_password.validation.password')),
passwordConfirmation: string()
.required(t('reset_password.required.password_confirmation'))
// @ts-expect-error this is valid
.oneOf([ref('password'), null], t('reset_password.validation.password_confirmation')),
})}
>
{({ isSubmitting }) => (
<LoginFormContainer title={t('reset_password.title')} css={tw`w-full flex`}>
<div>
<label>{t('email')}</label>
<Input value={email} isLight disabled />
</div>
<div css={tw`mt-6`}>
<Field
light
label={t('reset_password.new_password')}
name={'password'}
type={'password'}
description={t('reset_password.requirement.password')}
/>
</div>
<div css={tw`mt-6`}>
<Field
light
label={t('reset_password.confirm_new_password')}
name={'passwordConfirmation'}
type={'password'}
/>
</div>
<div css={tw`mt-6`}>
<Button size={'xlarge'} type={'submit'} disabled={isSubmitting} isLoading={isSubmitting}>
{t('reset_password.button')}
</Button>
</div>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/login'}
css={tw`text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600`}
>
{t('return_to_login')}
</Link>
</div>
</LoginFormContainer>
)}
</Formik>
);
};

View File

@ -1,101 +0,0 @@
import React, { useEffect, useState } from 'react';
import ContentBox from '@/components/elements/ContentBox';
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import deleteApiKey from '@/api/account/deleteApiKey';
import FlashMessageRender from '@/components/FlashMessageRender';
import { format } from 'date-fns';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import { useTranslation } from 'react-i18next';
import GreyRowBox from '@/components/elements/GreyRowBox';
import { Dialog } from '@/components/elements/dialog';
import { useFlashKey } from '@/plugins/useFlash';
import Code from '@/components/elements/Code';
export default () => {
const { t } = useTranslation(['dashboard/account', 'strings']);
const [deleteIdentifier, setDeleteIdentifier] = useState('');
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const { clearAndAddHttpError } = useFlashKey('account');
useEffect(() => {
getApiKeys()
.then((keys) => setKeys(keys))
.then(() => setLoading(false))
.catch((error) => clearAndAddHttpError(error));
}, []);
const doDeletion = (identifier: string) => {
setLoading(true);
clearAndAddHttpError();
deleteApiKey(identifier)
.then(() => setKeys((s) => [...(s || []).filter((key) => key.identifier !== identifier)]))
.catch((error) => clearAndAddHttpError(error))
.then(() => {
setLoading(false);
setDeleteIdentifier('');
});
};
return (
<PageContentBlock title={'Account API'}>
<FlashMessageRender byKey={'account'} />
<div css={tw`md:flex flex-nowrap my-10`}>
<ContentBox title={'Create API Key'} css={tw`flex-none w-full md:w-1/2`}>
<CreateApiKeyForm onKeyCreated={(key) => setKeys((s) => [...s!, key])} />
</ContentBox>
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
<SpinnerOverlay visible={loading} />
<Dialog.Confirm
title={'Delete API Key'}
confirm={'Delete Key'}
open={!!deleteIdentifier}
onClose={() => setDeleteIdentifier('')}
onConfirmed={() => doDeletion(deleteIdentifier)}
>
All requests using the <Code>{deleteIdentifier}</Code> key will be invalidated.
</Dialog.Confirm>
{keys.length === 0 ? (
<p css={tw`text-center text-sm`}>
{loading ? t('loading', { ns: 'strings' }) : 'No API keys exist for this account.'}
</p>
) : (
keys.map((key, index) => (
<GreyRowBox
key={key.identifier}
css={[tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2`]}
>
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`} />
<div css={tw`ml-4 flex-1 overflow-hidden`}>
<p css={tw`text-sm break-words`}>{key.description}</p>
<p css={tw`text-2xs text-neutral-300 uppercase`}>
{t('last_used', { ns: 'strings' })}:&nbsp;
{key.lastUsedAt
? format(key.lastUsedAt, 'MMM do, yyyy HH:mm')
: t('never', { ns: 'strings' })}
</p>
</div>
<p css={tw`text-sm ml-4 hidden md:block`}>
<code css={tw`font-mono py-1 px-2 bg-neutral-900 rounded`}>{key.identifier}</code>
</p>
<button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteIdentifier(key.identifier)}>
<FontAwesomeIcon
icon={faTrashAlt}
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
/>
</button>
</GreyRowBox>
))
)}
</ContentBox>
</div>
</PageContentBlock>
);
};

View File

@ -1,55 +0,0 @@
import * as React from 'react';
import ContentBox from '@/components/elements/ContentBox';
import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm';
import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm';
import ConfigureTwoFactorForm from '@/components/dashboard/forms/ConfigureTwoFactorForm';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import { breakpoint } from '@/theme';
import styled from 'styled-components/macro';
import MessageBox from '@/components/MessageBox';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const Container = styled.div`
${tw`flex flex-wrap`};
& > div {
${tw`w-full`};
${breakpoint('sm')`
width: calc(50% - 1rem);
`}
${breakpoint('md')`
${tw`w-auto flex-1`};
`}
}
`;
export default () => {
const { t } = useTranslation('dashboard/account');
const { state } = useLocation<undefined | { twoFactorRedirect?: boolean }>();
return (
<PageContentBlock title={t('title')}>
{state?.twoFactorRedirect && (
<MessageBox title={t('two_factor.required.title')} type={'error'}>
{t('two_factor.required.description')}
</MessageBox>
)}
<Container css={[tw`lg:grid lg:grid-cols-3 mb-10`, state?.twoFactorRedirect ? tw`mt-4` : tw`mt-10`]}>
<ContentBox title={t('password.title')} showFlashes={'account:password'}>
<UpdatePasswordForm />
</ContentBox>
<ContentBox css={tw`mt-8 sm:mt-0 sm:ml-8`} title={t('email.title')} showFlashes={'account:email'}>
<UpdateEmailAddressForm />
</ContentBox>
<ContentBox css={tw`md:ml-8 mt-8 md:mt-0`} title={t('two_factor.title')}>
<ConfigureTwoFactorForm />
</ContentBox>
</Container>
</PageContentBlock>
);
};

View File

@ -1,41 +0,0 @@
import React, { useContext } from 'react';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
import CopyOnClick from '@/components/elements/CopyOnClick';
interface Props {
apiKey: string;
}
const ApiKeyModal = ({ apiKey }: Props) => {
const { dismiss } = useContext(ModalContext);
return (
<>
<h3 css={tw`mb-6 text-2xl`}>Your API Key</h3>
<p css={tw`text-sm mb-6`}>
The API key you have requested is shown below. Please store this in a safe location, it will not be
shown again.
</p>
<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>
</pre>
<div css={tw`flex justify-end mt-6`}>
<Button type={'button'} onClick={() => dismiss()}>
Close
</Button>
</div>
</>
);
};
ApiKeyModal.displayName = 'ApiKeyModal';
export default asModal<Props>({
closeOnEscape: false,
closeOnBackground: false,
})(ApiKeyModal);

View File

@ -1,87 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Server } from '@/api/server/getServer';
import getServers from '@/api/getServers';
import ServerRow from '@/components/dashboard/ServerRow';
import Spinner from '@/components/elements/Spinner';
import PageContentBlock from '@/components/elements/PageContentBlock';
import useFlash from '@/plugins/useFlash';
import { useStoreState } from 'easy-peasy';
import { usePersistedState } from '@/plugins/usePersistedState';
import Switch from '@/components/elements/Switch';
import tw from 'twin.macro';
import useSWR from 'swr';
import { PaginatedResult } from '@/api/http';
import Pagination from '@/components/elements/Pagination';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export default () => {
const { t } = useTranslation('dashboard/index');
const { search } = useLocation();
const defaultPage = Number(new URLSearchParams(search).get('page') || '1');
const [page, setPage] = useState(!isNaN(defaultPage) && defaultPage > 0 ? defaultPage : 1);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const uuid = useStoreState((state) => state.user.data!.uuid);
const rootAdmin = useStoreState((state) => state.user.data!.rootAdmin);
const [showOnlyAdmin, setShowOnlyAdmin] = usePersistedState(`${uuid}:show_all_servers`, false);
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
['/api/client/servers', showOnlyAdmin && rootAdmin, page],
() => getServers({ page, type: showOnlyAdmin && rootAdmin ? 'admin' : undefined })
);
useEffect(() => {
if (!servers) return;
if (servers.pagination.currentPage > 1 && !servers.items.length) {
setPage(1);
}
}, [servers?.pagination.currentPage]);
useEffect(() => {
// Don't use react-router to handle changing this part of the URL, otherwise it
// triggers a needless re-render. We just want to track this in the URL incase the
// user refreshes the page.
window.history.replaceState(null, document.title, `/${page <= 1 ? '' : `?page=${page}`}`);
}, [page]);
useEffect(() => {
if (error) clearAndAddHttpError({ key: 'dashboard', error });
if (!error) clearFlashes('dashboard');
}, [error]);
return (
<PageContentBlock title={t('title')} showFlashKey={'dashboard'}>
{rootAdmin && (
<div css={tw`mb-2 flex justify-end items-center`}>
<p css={tw`uppercase text-xs text-neutral-400 mr-2`}>
{showOnlyAdmin ? t('showing-others-servers') : t('showing-your-servers')}
</p>
<Switch
name={'show_all_servers'}
defaultChecked={showOnlyAdmin}
onChange={() => setShowOnlyAdmin((s) => !s)}
/>
</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 ? t('no-other-servers') : t('no-servers-associated')}
</p>
)
}
</Pagination>
)}
</PageContentBlock>
);
};

View File

@ -1,176 +0,0 @@
import React, { memo, useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
import { Link } from 'react-router-dom';
import { Server } from '@/api/server/getServer';
import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/server/getServerResourceUsage';
import { bytesToString, ip, mbToBytes } from '@/lib/formatters';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
import Spinner from '@/components/elements/Spinner';
import styled from 'styled-components/macro';
import isEqual from 'react-fast-compare';
// Determines if the current value is in an alarm threshold so we can show it in red rather
// than the more faded default style.
const isAlarmState = (current: number, limit: number): boolean => limit > 0 && current / (limit * 1024 * 1024) >= 0.9;
const Icon = memo(
styled(FontAwesomeIcon)<{ $alarm: boolean }>`
${(props) => (props.$alarm ? tw`text-red-400` : tw`text-neutral-500`)};
`,
isEqual
);
const IconDescription = styled.p<{ $alarm: boolean }>`
${tw`text-sm ml-2`};
${(props) => (props.$alarm ? tw`text-white` : tw`text-neutral-400`)};
`;
const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>`
${tw`grid grid-cols-12 gap-4 relative`};
& .status-bar {
${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);
${({ $status }) =>
!$status || $status === 'offline'
? tw`bg-red-500`
: $status === 'running'
? tw`bg-green-500`
: tw`bg-yellow-500`};
}
&:hover .status-bar {
${tw`opacity-75`};
}
`;
type Timer = ReturnType<typeof setInterval>;
export default ({ server, className }: { server: Server; className?: string }) => {
const interval = useRef<Timer>(null) as React.MutableRefObject<Timer>;
const [isSuspended, setIsSuspended] = useState(server.status === 'suspended');
const [stats, setStats] = useState<ServerStats | null>(null);
const getStats = () =>
getServerResourceUsage(server.uuid)
.then((data) => setStats(data))
.catch((error) => console.error(error));
useEffect(() => {
setIsSuspended(stats?.isSuspended || server.status === 'suspended');
}, [stats?.isSuspended, server.status]);
useEffect(() => {
// Don't waste a HTTP request if there is nothing important to show to the user because
// the server is suspended.
if (isSuspended) return;
getStats().then(() => {
interval.current = setInterval(() => getStats(), 30000);
});
return () => {
interval.current && clearInterval(interval.current);
};
}, [isSuspended]);
const alarms = { cpu: false, memory: false, disk: false };
if (stats) {
alarms.cpu = server.limits.cpu === 0 ? false : stats.cpuUsagePercent >= server.limits.cpu * 0.9;
alarms.memory = isAlarmState(stats.memoryUsageInBytes, server.limits.memory);
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
}
const diskLimit = server.limits.disk !== 0 ? bytesToString(mbToBytes(server.limits.disk)) : 'Unlimited';
const memoryLimit = server.limits.memory !== 0 ? bytesToString(mbToBytes(server.limits.memory)) : 'Unlimited';
const cpuLimit = server.limits.cpu !== 0 ? server.limits.cpu + ' %' : 'Unlimited';
return (
<StatusIndicatorBox as={Link} to={`/server/${server.id}`} className={className} $status={stats?.status}>
<div css={tw`flex items-center col-span-12 sm:col-span-5 lg:col-span-6`}>
<div className={'icon mr-4'}>
<FontAwesomeIcon icon={faServer} />
</div>
<div>
<p css={tw`text-lg break-words`}>{server.name}</p>
{!!server.description && (
<p css={tw`text-sm text-neutral-300 break-words line-clamp-2`}>{server.description}</p>
)}
</div>
</div>
<div css={tw`flex-1 ml-4 lg:block lg:col-span-2 hidden`}>
<div css={tw`flex justify-center`}>
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`} />
<p css={tw`text-sm text-neutral-400 ml-2`}>
{server.allocations
.filter((alloc) => alloc.isDefault)
.map((allocation) => (
<React.Fragment key={allocation.ip + allocation.port.toString()}>
{allocation.alias || ip(allocation.ip)}:{allocation.port}
</React.Fragment>
))}
</p>
</div>
</div>
<div css={tw`hidden col-span-7 lg:col-span-4 sm:flex items-baseline justify-center`}>
{!stats || isSuspended ? (
isSuspended ? (
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}>
{server.status === 'suspended' ? 'Suspended' : 'Connection Error'}
</span>
</div>
) : server.isTransferring || server.status ? (
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
{server.isTransferring
? 'Transferring'
: server.status === 'installing'
? 'Installing'
: server.status === 'restoring_backup'
? 'Restoring Backup'
: 'Unavailable'}
</span>
</div>
) : (
<Spinner size={'small'} />
)
) : (
<React.Fragment>
<div css={tw`flex-1 ml-4 sm:block hidden`}>
<div css={tw`flex justify-center`}>
<Icon icon={faMicrochip} $alarm={alarms.cpu} />
<IconDescription $alarm={alarms.cpu}>
{stats.cpuUsagePercent.toFixed(2)} %
</IconDescription>
</div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {cpuLimit}</p>
</div>
<div css={tw`flex-1 ml-4 sm:block hidden`}>
<div css={tw`flex justify-center`}>
<Icon icon={faMemory} $alarm={alarms.memory} />
<IconDescription $alarm={alarms.memory}>
{bytesToString(stats.memoryUsageInBytes)}
</IconDescription>
</div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memoryLimit}</p>
</div>
<div css={tw`flex-1 ml-4 sm:block hidden`}>
<div css={tw`flex justify-center`}>
<Icon icon={faHdd} $alarm={alarms.disk} />
<IconDescription $alarm={alarms.disk}>
{bytesToString(stats.diskUsageInBytes)}
</IconDescription>
</div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {diskLimit}</p>
</div>
</React.Fragment>
)}
</div>
<div className={'status-bar'} />
</StatusIndicatorBox>
);
};

View File

@ -1,72 +0,0 @@
import React, { useEffect, useState } from 'react';
import { ActivityLogFilters, useActivityLogs } from '@/api/account/activity';
import { useFlashKey } from '@/plugins/useFlash';
import PageContentBlock from '@/components/elements/PageContentBlock';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Link } from 'react-router-dom';
import PaginationFooter from '@/components/elements/table/PaginationFooter';
import { DesktopComputerIcon, XCircleIcon } from '@heroicons/react/solid';
import Spinner from '@/components/elements/Spinner';
import { styles as btnStyles } from '@/components/elements/button/index';
import classNames from 'classnames';
import ActivityLogEntry from '@/components/elements/activity/ActivityLogEntry';
import Tooltip from '@/components/elements/tooltip/Tooltip';
import useLocationHash from '@/plugins/useLocationHash';
export default () => {
const { hash } = useLocationHash();
const { clearAndAddHttpError } = useFlashKey('account');
const [filters, setFilters] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
const { data, isValidating, error } = useActivityLogs(filters, {
revalidateOnMount: true,
revalidateOnFocus: false,
});
useEffect(() => {
setFilters((value) => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
}, [hash]);
useEffect(() => {
clearAndAddHttpError(error);
}, [error]);
return (
<PageContentBlock title={'Account Activity Log'}>
<FlashMessageRender byKey={'account'} />
{(filters.filters?.event || filters.filters?.ip) && (
<div className={'flex justify-end mb-2'}>
<Link
to={'#'}
className={classNames(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')}
onClick={() => setFilters((value) => ({ ...value, filters: {} }))}
>
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'} />
</Link>
</div>
)}
{!data && isValidating ? (
<Spinner centered />
) : (
<div className={'bg-gray-700'}>
{data?.items.map((activity) => (
<ActivityLogEntry key={activity.id} activity={activity}>
{typeof activity.properties.useragent === 'string' && (
<Tooltip content={activity.properties.useragent} placement={'top'}>
<span>
<DesktopComputerIcon />
</span>
</Tooltip>
)}
</ActivityLogEntry>
))}
</div>
)}
{data && (
<PaginationFooter
pagination={data.pagination}
onPageSelect={(page) => setFilters((value) => ({ ...value, page }))}
/>
)}
</PageContentBlock>
);
};

View File

@ -1,48 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import tw from 'twin.macro';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/elements/button/index';
import SetupTOTPDialog from '@/components/dashboard/forms/SetupTOTPDialog';
import RecoveryTokensDialog from '@/components/dashboard/forms/RecoveryTokensDialog';
import DisableTOTPDialog from '@/components/dashboard/forms/DisableTOTPDialog';
import { useFlashKey } from '@/plugins/useFlash';
export default () => {
const { t } = useTranslation('dashboard/account');
const [tokens, setTokens] = useState<string[]>([]);
const [visible, setVisible] = useState<'enable' | 'disable' | null>(null);
const isEnabled = useStoreState((state: ApplicationStore) => state.user.data!.useTotp);
const { clearAndAddHttpError } = useFlashKey('account:two-step');
useEffect(() => {
return () => {
clearAndAddHttpError();
};
}, [visible]);
const onTokens = (tokens: string[]) => {
setTokens(tokens);
setVisible(null);
};
return (
<div>
<SetupTOTPDialog open={visible === 'enable'} onClose={() => setVisible(null)} onTokens={onTokens} />
<RecoveryTokensDialog tokens={tokens} open={tokens.length > 0} onClose={() => setTokens([])} />
<DisableTOTPDialog open={visible === 'disable'} onClose={() => setVisible(null)} />
<p css={tw`text-sm`}>{isEnabled ? t('two_factor.disable.help') : t('two_factor.enable.help')}</p>
<div css={tw`mt-6`}>
{isEnabled ? (
<Button.Danger onClick={() => setVisible('disable')}>
{t('two_factor.disable.button')}
</Button.Danger>
) : (
<Button onClick={() => setVisible('enable')}>{t('two_factor.enable.button')}</Button>
)}
</div>
</div>
);
};

View File

@ -1,86 +0,0 @@
import React, { useState } from 'react';
import { Field, Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import createApiKey from '@/api/account/createApiKey';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { ApiKey } from '@/api/account/getApiKeys';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Input, { Textarea } from '@/components/elements/Input';
import styled from 'styled-components/macro';
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
interface Values {
description: string;
allowedIps: string;
}
const CustomTextarea = styled(Textarea)`
${tw`h-32`}
`;
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
const [apiKey, setApiKey] = useState('');
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
clearFlashes('account');
createApiKey(values.description, values.allowedIps)
.then(({ secretToken, ...key }) => {
resetForm();
setSubmitting(false);
setApiKey(`${key.identifier}${secretToken}`);
onKeyCreated(key);
})
.catch((error) => {
console.error(error);
addError({ key: 'account', message: httpErrorToHuman(error) });
setSubmitting(false);
});
};
return (
<>
<ApiKeyModal visible={apiKey.length > 0} onModalDismissed={() => setApiKey('')} apiKey={apiKey} />
<Formik
onSubmit={submit}
initialValues={{ description: '', allowedIps: '' }}
validationSchema={object().shape({
allowedIps: string(),
description: string().required().min(4),
})}
>
{({ isSubmitting }) => (
<Form>
<SpinnerOverlay visible={isSubmitting} />
<FormikFieldWrapper
label={'Description'}
name={'description'}
description={'A description of this API key.'}
css={tw`mb-6`}
>
<Field name={'description'} as={Input} />
</FormikFieldWrapper>
<FormikFieldWrapper
label={'Allowed IPs'}
name={'allowedIps'}
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} />
</FormikFieldWrapper>
<div css={tw`flex justify-end mt-6`}>
<Button>Create</Button>
</div>
</Form>
)}
</Formik>
</>
);
};

View File

@ -1,75 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import asDialog from '@/hoc/asDialog';
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
import { Button } from '@/components/elements/button/index';
import { Input } from '@/components/elements/inputs';
import Tooltip from '@/components/elements/tooltip/Tooltip';
import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor';
import { useFlashKey } from '@/plugins/useFlash';
import { useStoreActions } from '@/state/hooks';
import FlashMessageRender from '@/components/FlashMessageRender';
const DisableTOTPDialog = () => {
const { t } = useTranslation(['dashboard/account', 'strings']);
const [submitting, setSubmitting] = useState(false);
const [password, setPassword] = useState('');
const { clearAndAddHttpError } = useFlashKey('account:two-step');
const { close, setProps } = useContext(DialogWrapperContext);
const updateUserData = useStoreActions((actions) => actions.user.updateUserData);
useEffect(() => {
setProps((state) => ({ ...state, preventExternalClose: submitting }));
}, [submitting]);
const submit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
if (submitting) return;
setSubmitting(true);
clearAndAddHttpError();
disableAccountTwoFactor(password)
.then(() => {
updateUserData({ useTotp: false });
close();
})
.catch(clearAndAddHttpError)
.then(() => setSubmitting(false));
};
return (
<form id={'disable-totp-form'} className={'mt-6'} onSubmit={submit}>
<FlashMessageRender byKey={'account:two-step'} className={'-mt-2 mb-6'} />
<label className={'block pb-1'} htmlFor={'totp-password'}>
{t('password', { ns: 'strings' })}
</label>
<Input.Text
id={'totp-password'}
type={'password'}
variant={Input.Text.Variants.Loose}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<Dialog.Footer>
<Button.Text onClick={close}>{t('cancel', { ns: 'strings' })}</Button.Text>
<Tooltip
delay={100}
disabled={password.length > 0}
content={t<string>('password.validation.account_password')}
>
<Button.Danger type={'submit'} form={'disable-totp-form'} disabled={submitting || !password.length}>
{t('disable', { ns: 'strings' })}
</Button.Danger>
</Tooltip>
</Dialog.Footer>
</form>
);
};
export default asDialog({
title: 'Disable Two-Step Verification',
description: 'Disabling two-step verification will make your account less secure.',
})(DisableTOTPDialog);

View File

@ -1,51 +0,0 @@
import React from 'react';
import { Dialog, DialogProps } from '@/components/elements/dialog';
import { Button } from '@/components/elements/button/index';
import CopyOnClick from '@/components/elements/CopyOnClick';
import { Alert } from '@/components/elements/alert';
interface RecoveryTokenDialogProps extends DialogProps {
tokens: string[];
}
export default ({ tokens, open, onClose }: RecoveryTokenDialogProps) => {
const grouped = [] as [string, string][];
tokens.forEach((token, index) => {
if (index % 2 === 0) {
grouped.push([token, tokens[index + 1] || '']);
}
});
return (
<Dialog
open={open}
onClose={onClose}
title={'Two-Step Authentication Enabled'}
description={
'Store the codes below somewhere safe. If you lose access to your phone you can use these backup codes to sign in.'
}
hideCloseIcon
preventExternalClose
>
<Dialog.Icon position={'container'} type={'success'} />
<CopyOnClick text={tokens.join('\n')} showInNotification={false}>
<pre className={'bg-gray-800 rounded p-2 mt-6'}>
{grouped.map((value) => (
<span key={value.join('_')} className={'block'}>
{value[0]}
<span className={'mx-2 selection:bg-gray-800'}>&nbsp;</span>
{value[1]}
<span className={'selection:bg-gray-800'}>&nbsp;</span>
</span>
))}
</pre>
</CopyOnClick>
<Alert type={'danger'} className={'mt-3'}>
These codes will not be shown again.
</Alert>
<Dialog.Footer>
<Button.Text onClick={onClose}>Done</Button.Text>
</Dialog.Footer>
</Dialog>
);
};

View File

@ -1,132 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
import getTwoFactorTokenData, { TwoFactorTokenData } from '@/api/account/getTwoFactorTokenData';
import { useFlashKey } from '@/plugins/useFlash';
import tw from 'twin.macro';
import { useTranslation } from 'react-i18next';
import QRCode from 'qrcode.react';
import { Button } from '@/components/elements/button/index';
import Spinner from '@/components/elements/Spinner';
import { Input } from '@/components/elements/inputs';
import CopyOnClick from '@/components/elements/CopyOnClick';
import Tooltip from '@/components/elements/tooltip/Tooltip';
import enableAccountTwoFactor from '@/api/account/enableAccountTwoFactor';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import asDialog from '@/hoc/asDialog';
interface Props {
onTokens: (tokens: string[]) => void;
}
const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
const { t } = useTranslation(['dashboard/account', 'strings']);
const [submitting, setSubmitting] = useState(false);
const [value, setValue] = useState('');
const [password, setPassword] = useState('');
const [token, setToken] = useState<TwoFactorTokenData | null>(null);
const { clearAndAddHttpError } = useFlashKey('account:two-step');
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
const { close, setProps } = useContext(DialogWrapperContext);
useEffect(() => {
getTwoFactorTokenData()
.then(setToken)
.catch((error) => clearAndAddHttpError(error));
}, []);
useEffect(() => {
setProps((state) => ({ ...state, preventExternalClose: submitting }));
}, [submitting]);
const submit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
if (submitting) return;
setSubmitting(true);
clearAndAddHttpError();
enableAccountTwoFactor(value, password)
.then((tokens) => {
updateUserData({ useTotp: true });
onTokens(tokens);
})
.catch((error) => {
clearAndAddHttpError(error);
setSubmitting(false);
});
};
return (
<form id={'enable-totp-form'} onSubmit={submit}>
<FlashMessageRender byKey={'account:two-step'} className={'mt-4'} />
<div className={'flex items-center justify-center w-56 h-56 p-2 bg-gray-50 shadow mx-auto mt-6'}>
{!token ? (
<Spinner />
) : (
<QRCode renderAs={'svg'} value={token.image_url_data} css={tw`w-full h-full shadow-none`} />
)}
</div>
<CopyOnClick text={token?.secret}>
<p className={'font-mono text-sm text-gray-100 text-center mt-2'}>
{token?.secret.match(/.{1,4}/g)!.join(' ') || t('loading', { ns: 'strings' })}
</p>
</CopyOnClick>
<p id={'totp-code-description'} className={'mt-6'}>
{t('two_factor.setup.help')}
</p>
<Input.Text
aria-labelledby={'totp-code-description'}
variant={Input.Text.Variants.Loose}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
className={'mt-3'}
placeholder={'000000'}
type={'text'}
inputMode={'numeric'}
autoComplete={'one-time-code'}
pattern={'\\d{6}'}
/>
<label htmlFor={'totp-password'} className={'block mt-3'}>
{t('account_password', { ns: 'strings' })}
</label>
<Input.Text
variant={Input.Text.Variants.Loose}
className={'mt-1'}
type={'password'}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<Dialog.Footer>
<Button.Text onClick={close}>{t('cancel', { ns: 'strings' })}</Button.Text>
<Tooltip
disabled={password.length > 0 && value.length === 6}
content={
!token
? 'Waiting for QR code to load...'
: 'You must enter the 6-digit code and your password to continue.'
}
delay={100}
>
<Button
disabled={!token || value.length !== 6 || !password.length}
type={'submit'}
form={'enable-totp-form'}
>
{t('enable', { ns: 'strings' })}
</Button>
</Tooltip>
</Dialog.Footer>
</form>
);
};
export default asDialog({
title: 'Enable Two-Step Verification',
description:
"Help protect your account from unauthorized access. You'll be prompted for a verification code each time you sign in.",
})(ConfigureTwoFactorForm);

View File

@ -1,84 +0,0 @@
import React from 'react';
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
import { Form, Formik, FormikHelpers } from 'formik';
import * as Yup from 'yup';
import { useTranslation } from 'react-i18next';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import Field from '@/components/elements/Field';
import { httpErrorToHuman } from '@/api/http';
import { ApplicationStore } from '@/state';
import tw from 'twin.macro';
import { Button } from '@/components/elements/button/index';
interface Values {
email: string;
password: string;
}
export default () => {
const { t } = useTranslation(['dashboard/account', 'strings']);
const schema = Yup.object().shape({
email: Yup.string().email().required(),
password: Yup.string().required(t('password.validation.account_password')),
});
const user = useStoreState((state: State<ApplicationStore>) => state.user.data);
const updateEmail = useStoreActions((state: Actions<ApplicationStore>) => state.user.updateUserEmail);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const submit = (values: Values, { resetForm, setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('account:email');
updateEmail({ ...values })
.then(() =>
addFlash({
type: 'success',
key: 'account:email',
message: t('email.updated'),
})
)
.catch((error) =>
addFlash({
type: 'error',
key: 'account:email',
title: t('error', { ns: 'strings' }),
message: httpErrorToHuman(error),
})
)
.then(() => {
resetForm();
setSubmitting(false);
});
};
return (
<Formik onSubmit={submit} validationSchema={schema} initialValues={{ email: user!.email, password: '' }}>
{({ isSubmitting, isValid }) => (
<React.Fragment>
<SpinnerOverlay size={'large'} visible={isSubmitting} />
<Form css={tw`m-0`}>
<Field
id={'current_email'}
type={'email'}
name={'email'}
label={t('email', { ns: 'strings' })}
/>
<div css={tw`mt-6`}>
<Field
id={'confirm_password'}
type={'password'}
name={'password'}
label={t('current_password', { ns: 'strings' })}
/>
</div>
<div css={tw`mt-6`}>
<Button disabled={isSubmitting || !isValid}>{t('email.button')}</Button>
</div>
</Form>
</React.Fragment>
)}
</Formik>
);
};

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