mirror of
https://github.com/pelican-dev/panel.git
synced 2025-05-20 11:04:45 +02:00
chore: delete old client ui
This commit is contained in:
parent
295134fb6c
commit
66ec86694f
@ -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;
|
@ -1 +0,0 @@
|
||||
module.exports = 'test-file-stub';
|
@ -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 };
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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;
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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 };
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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 };
|
33
resources/scripts/api/definitions/index.d.ts
vendored
33
resources/scripts/api/definitions/index.d.ts
vendored
@ -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;
|
@ -1,2 +0,0 @@
|
||||
export * from './models.d';
|
||||
export { default as Transformers, MetaTransformers } from './transformers';
|
@ -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;
|
||||
};
|
||||
}
|
@ -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 {}
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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;
|
||||
}
|
||||
);
|
||||
};
|
@ -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 };
|
@ -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);
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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,
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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.',
|
||||
}
|
||||
);
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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',
|
||||
},
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
};
|
@ -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}`);
|
@ -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);
|
||||
};
|
@ -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);
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
};
|
@ -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);
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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));
|
||||
};
|
@ -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`);
|
@ -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 });
|
||||
};
|
29
resources/scripts/api/server/types.d.ts
vendored
29
resources/scripts/api/server/types.d.ts
vendored
@ -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[];
|
||||
}
|
@ -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];
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
};
|
@ -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 }
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
});
|
||||
};
|
@ -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 || {}) }
|
||||
);
|
@ -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('|'),
|
||||
});
|
@ -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 |
@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
@ -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);
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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} />
|
||||
);
|
||||
};
|
@ -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;
|
@ -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 © 2024 - {new Date().getFullYear()}
|
||||
</p>
|
||||
</Container>
|
||||
));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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' })}:
|
||||
{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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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);
|
@ -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'}> </span>
|
||||
{value[1]}
|
||||
<span className={'selection:bg-gray-800'}> </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>
|
||||
);
|
||||
};
|
@ -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);
|
@ -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
Loading…
x
Reference in New Issue
Block a user