mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
fix: improve create api key modal
This commit is contained in:
@@ -1,23 +1,26 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"importOrder": [
|
||||
"^@/routers/(.*)$",
|
||||
"^@/components/(.*)$",
|
||||
"^@/hoc/(.*)$",
|
||||
"^@/lib/(.*)$",
|
||||
"^@/api/(.*)$",
|
||||
"^@/state(.*)$",
|
||||
"^@/state/(.*)$",
|
||||
"^@/plugins/(.*)$",
|
||||
"^@feature/(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["prettier-plugin-tailwindcss", "@trivago/prettier-plugin-sort-imports"]
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"importOrder": [
|
||||
"^@/routers/(.*)$",
|
||||
"^@/components/(.*)$",
|
||||
"^@/hoc/(.*)$",
|
||||
"^@/lib/(.*)$",
|
||||
"^@/api/(.*)$",
|
||||
"^@/state(.*)$",
|
||||
"^@/state/(.*)$",
|
||||
"^@/plugins/(.*)$",
|
||||
"^@feature/(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss",
|
||||
"@trivago/prettier-plugin-sort-imports"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,272 +1,261 @@
|
||||
import { Eye, EyeSlash, Key, Plus, TrashBin } from '@gravity-ui/icons';
|
||||
import { format } from 'date-fns';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { object, string } from 'yup';
|
||||
import { Eye, EyeSlash, Key, Plus, TrashBin } from "@gravity-ui/icons";
|
||||
import { format } from "date-fns";
|
||||
import { Actions, useStoreActions } from "easy-peasy";
|
||||
import { Field, Form, Formik, FormikHelpers } from "formik";
|
||||
import { lazy, useEffect, useState } from "react";
|
||||
import { object, string } from "yup";
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Code from '@/components/elements/Code';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import Input from '@/components/elements/Input';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
import FlashMessageRender from "@/components/FlashMessageRender";
|
||||
import ApiKeyModal from "@/components/dashboard/ApiKeyModal";
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
import Code from "@/components/elements/Code";
|
||||
import FormikFieldWrapper from "@/components/elements/FormikFieldWrapper";
|
||||
import Input from "@/components/elements/Input";
|
||||
import { MainPageHeader } from "@/components/elements/MainPageHeader";
|
||||
import PageContentBlock from "@/components/elements/PageContentBlock";
|
||||
import SpinnerOverlay from "@/components/elements/SpinnerOverlay";
|
||||
import { Dialog } from "@/components/elements/dialog";
|
||||
|
||||
import createApiKey from '@/api/account/createApiKey';
|
||||
import deleteApiKey from '@/api/account/deleteApiKey';
|
||||
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import createApiKey from "@/api/account/createApiKey";
|
||||
import deleteApiKey from "@/api/account/deleteApiKey";
|
||||
import getApiKeys, { ApiKey } from "@/api/account/getApiKeys";
|
||||
import { httpErrorToHuman } from "@/api/http";
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ApplicationStore } from "@/state";
|
||||
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
import { useFlashKey } from "@/plugins/useFlash";
|
||||
|
||||
const CreateApiKeyModal = lazy(() => import("./CreateApiKeyModal"));
|
||||
|
||||
interface CreateValues {
|
||||
description: string;
|
||||
allowedIps: string;
|
||||
description: string;
|
||||
allowedIps: string;
|
||||
}
|
||||
|
||||
const AccountApiContainer = () => {
|
||||
const [deleteIdentifier, setDeleteIdentifier] = useState('');
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
const [deleteIdentifier, setDeleteIdentifier] = useState("");
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { clearAndAddHttpError } = useFlashKey('api-keys');
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const { clearAndAddHttpError } = useFlashKey("api-keys");
|
||||
const { addError, clearFlashes } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getApiKeys()
|
||||
.then((keys) => setKeys(keys))
|
||||
.then(() => setLoading(false))
|
||||
.catch((error) => clearAndAddHttpError(error));
|
||||
}, []);
|
||||
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('');
|
||||
});
|
||||
};
|
||||
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("");
|
||||
});
|
||||
};
|
||||
|
||||
const submitCreate = (values: CreateValues, { setSubmitting, resetForm }: FormikHelpers<CreateValues>) => {
|
||||
clearFlashes('account:api-keys');
|
||||
createApiKey(values.description, values.allowedIps)
|
||||
.then(({ secretToken, ...key }) => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
setApiKey(`${key.identifier}${secretToken}`);
|
||||
setKeys((s) => [...s!, key]);
|
||||
setShowCreateModal(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: 'account:api-keys', message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
const submitCreate = (
|
||||
values: CreateValues,
|
||||
{ setSubmitting, resetForm }: FormikHelpers<CreateValues>,
|
||||
) => {
|
||||
clearFlashes("account:api-keys");
|
||||
createApiKey(values.description, values.allowedIps)
|
||||
.then(({ secretToken, ...key }) => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
setApiKey(`${key.identifier}${secretToken}`);
|
||||
setKeys((s) => [...s!, key]);
|
||||
setShowCreateModal(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: "account:api-keys", message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKeyVisibility = (identifier: string) => {
|
||||
setShowKeys((prev) => ({
|
||||
...prev,
|
||||
[identifier]: !prev[identifier],
|
||||
}));
|
||||
};
|
||||
const toggleKeyVisibility = (identifier: string) => {
|
||||
setShowKeys((prev) => ({
|
||||
...prev,
|
||||
[identifier]: !prev[identifier],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'API Keys'}>
|
||||
<FlashMessageRender byKey='account:api-keys' />
|
||||
<ApiKeyModal visible={apiKey.length > 0} onModalDismissed={() => setApiKey('')} apiKey={apiKey} />
|
||||
return (
|
||||
<PageContentBlock title={"API Keys"}>
|
||||
<FlashMessageRender byKey="account:api-keys" />
|
||||
<ApiKeyModal
|
||||
visible={apiKey.length > 0}
|
||||
onModalDismissed={() => setApiKey("")}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
|
||||
{/* Create API Key Modal */}
|
||||
{showCreateModal && (
|
||||
<Dialog.Confirm
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title='Create API Key'
|
||||
confirm='Create Key'
|
||||
onConfirmed={() => {
|
||||
const form = document.getElementById('create-api-form') as HTMLFormElement;
|
||||
if (form) {
|
||||
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
if (submitButton) submitButton.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Formik
|
||||
onSubmit={submitCreate}
|
||||
initialValues={{ description: '', allowedIps: '' }}
|
||||
validationSchema={object().shape({
|
||||
allowedIps: string(),
|
||||
description: string().required().min(4),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form id='create-api-form' className='space-y-4'>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
<CreateApiKeyModal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={submitCreate}
|
||||
/>
|
||||
|
||||
<FormikFieldWrapper
|
||||
label='Description'
|
||||
name='description'
|
||||
description='A description of this API key.'
|
||||
>
|
||||
<Field name='description' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
<div className="w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0">
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2 mb-3 sm:mb-4"
|
||||
style={{
|
||||
animationDelay: "50ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<MainPageHeader
|
||||
title="API Keys"
|
||||
titleChildren={
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus width={22} height={22} fill="currentColor" />
|
||||
Create API Key
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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. Note: You can also use CIDR ranges here.'
|
||||
>
|
||||
<Field name='allowedIps' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2"
|
||||
style={{
|
||||
animationDelay: "75ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<div className="bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-4 sm:p-6 shadow-sm">
|
||||
<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>
|
||||
|
||||
<button type='submit' className='hidden' />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog.Confirm>
|
||||
)}
|
||||
|
||||
<div className='w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0'>
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
|
||||
style={{
|
||||
animationDelay: '50ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<MainPageHeader
|
||||
title='API Keys'
|
||||
titleChildren={
|
||||
<ActionButton
|
||||
variant='primary'
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<Plus width={22} height={22} fill='currentColor' />
|
||||
Create API Key
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '75ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-4 sm:p-6 shadow-sm'>
|
||||
<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 ? (
|
||||
<div className='text-center py-12'>
|
||||
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
|
||||
<Key width={22} height={22} className='text-zinc-400' fill='currentColor' />
|
||||
</div>
|
||||
<h3 className='text-lg font-medium text-zinc-200 mb-2'>No API Keys</h3>
|
||||
<p className='text-sm text-zinc-400 max-w-sm mx-auto'>
|
||||
{loading
|
||||
? 'Loading your API keys...'
|
||||
: "You haven't created any API keys yet. Create one to get started with the API."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{keys.map((key, index) => (
|
||||
<div
|
||||
key={key.identifier}
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: `${index * 25 + 100}ms`,
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-[#ffffff05] border-[1px] border-[#ffffff08] rounded-lg p-4 hover:border-[#ffffff15] transition-all duration-150'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-3 mb-2'>
|
||||
<h4 className='text-sm font-medium text-zinc-100 truncate'>
|
||||
{key.description}
|
||||
</h4>
|
||||
</div>
|
||||
<div className='flex items-center gap-4 text-xs text-zinc-400'>
|
||||
<span>
|
||||
Last used:{' '}
|
||||
{key.lastUsedAt
|
||||
? format(key.lastUsedAt, 'MMM d, yyyy HH:mm')
|
||||
: 'Never'}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>Key:</span>
|
||||
<code className='font-mono px-2 py-1 bg-[#ffffff08] border border-[#ffffff08] rounded text-zinc-300'>
|
||||
{showKeys[key.identifier]
|
||||
? key.identifier
|
||||
: '••••••••••••••••'}
|
||||
</code>
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={() => toggleKeyVisibility(key.identifier)}
|
||||
className='p-1 text-zinc-400 hover:text-zinc-300'
|
||||
>
|
||||
{showKeys[key.identifier] ? (
|
||||
<EyeSlash
|
||||
width={18}
|
||||
height={18}
|
||||
fill='currentColor'
|
||||
/>
|
||||
) : (
|
||||
<Eye width={18} height={18} fill='currentColor' />
|
||||
)}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant='danger'
|
||||
size='sm'
|
||||
className='ml-4'
|
||||
onClick={() => setDeleteIdentifier(key.identifier)}
|
||||
>
|
||||
<TrashBin width={20} height={20} fill='currentColor' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center">
|
||||
<Key
|
||||
width={22}
|
||||
height={22}
|
||||
className="text-zinc-400"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-200 mb-2">
|
||||
No API Keys
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400 max-w-sm mx-auto">
|
||||
{loading
|
||||
? "Loading your API keys..."
|
||||
: "You haven't created any API keys yet. Create one to get started with the API."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{keys.map((key, index) => (
|
||||
<div
|
||||
key={key.identifier}
|
||||
className="transform-gpu skeleton-anim-2"
|
||||
style={{
|
||||
animationDelay: `${index * 25 + 100}ms`,
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ffffff05] border-[1px] border-[#ffffff08] rounded-lg p-4 hover:border-[#ffffff15] transition-all duration-150">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="text-sm font-medium text-zinc-100 truncate">
|
||||
{key.description}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-zinc-400">
|
||||
<span>
|
||||
Last used:{" "}
|
||||
{key.lastUsedAt
|
||||
? format(key.lastUsedAt, "MMM d, yyyy HH:mm")
|
||||
: "Never"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Key:</span>
|
||||
<code className="font-mono px-2 py-1 bg-[#ffffff08] border border-[#ffffff08] rounded text-zinc-300">
|
||||
{showKeys[key.identifier]
|
||||
? key.identifier
|
||||
: "••••••••••••••••"}
|
||||
</code>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
toggleKeyVisibility(key.identifier)
|
||||
}
|
||||
className="p-1 text-zinc-400 hover:text-zinc-300"
|
||||
>
|
||||
{showKeys[key.identifier] ? (
|
||||
<EyeSlash
|
||||
width={18}
|
||||
height={18}
|
||||
fill="currentColor"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
width={18}
|
||||
height={18}
|
||||
fill="currentColor"
|
||||
/>
|
||||
)}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="ml-4"
|
||||
onClick={() => setDeleteIdentifier(key.identifier)}
|
||||
>
|
||||
<TrashBin
|
||||
width={20}
|
||||
height={20}
|
||||
fill="currentColor"
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountApiContainer;
|
||||
|
||||
@@ -1,92 +1,99 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import MessageBox from '@/components/MessageBox';
|
||||
import ConfigureTwoFactorForm from '@/components/dashboard/forms/ConfigureTwoFactorForm';
|
||||
import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm';
|
||||
import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm';
|
||||
import ContentBox from '@/components/elements/ContentBox';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import MessageBox from "@/components/MessageBox";
|
||||
import ConfigureTwoFactorForm from "@/components/dashboard/forms/ConfigureTwoFactorForm";
|
||||
import UpdateEmailAddressForm from "@/components/dashboard/forms/UpdateEmailAddressForm";
|
||||
import UpdatePasswordForm from "@/components/dashboard/forms/UpdatePasswordForm";
|
||||
import ContentBox from "@/components/elements/ContentBox";
|
||||
import PageContentBlock from "@/components/elements/PageContentBlock";
|
||||
|
||||
import Code from '../elements/Code';
|
||||
import Code from "../elements/Code";
|
||||
|
||||
const AccountOverviewContainer = () => {
|
||||
const { state } = useLocation();
|
||||
const { state } = useLocation();
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Your Settings'}>
|
||||
<div className='w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0'>
|
||||
{state?.twoFactorRedirect && (
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
|
||||
style={{
|
||||
animationDelay: '25ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<MessageBox title={'2-Factor Required'} type={'error'}>
|
||||
Your account must have two-factor authentication enabled in order to continue.
|
||||
</MessageBox>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<PageContentBlock title={"Your Settings"}>
|
||||
<div className="w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0">
|
||||
{state?.twoFactorRedirect && (
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2 mb-3 sm:mb-4"
|
||||
style={{
|
||||
animationDelay: "25ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<MessageBox title={"2-Factor Required"} type={"error"}>
|
||||
Your account must have two-factor authentication enabled in order
|
||||
to continue.
|
||||
</MessageBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col w-full h-full gap-4'>
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '50ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<ContentBox title={'Account Email'} showFlashes={'account:email'}>
|
||||
<UpdateEmailAddressForm />
|
||||
</ContentBox>
|
||||
</div>
|
||||
<div className="flex flex-col w-full h-full gap-4">
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2"
|
||||
style={{
|
||||
animationDelay: "50ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<ContentBox title={"Account Email"} showFlashes={"account:email"}>
|
||||
<UpdateEmailAddressForm />
|
||||
</ContentBox>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '75ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<ContentBox title={'Account Password'} showFlashes={'account:password'}>
|
||||
<UpdatePasswordForm />
|
||||
</ContentBox>
|
||||
<ContentBox title={'Multi-Factor Authentication'}>
|
||||
<ConfigureTwoFactorForm />
|
||||
</ContentBox>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2"
|
||||
style={{
|
||||
animationDelay: "75ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<ContentBox
|
||||
title={"Account Password"}
|
||||
showFlashes={"account:password"}
|
||||
>
|
||||
<UpdatePasswordForm />
|
||||
</ContentBox>
|
||||
<ContentBox title={"Multi-Factor Authentication"}>
|
||||
<ConfigureTwoFactorForm />
|
||||
</ContentBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '100ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<ContentBox title={'Panel Version'}>
|
||||
<p className='text-sm mb-4 text-zinc-300'>
|
||||
This is useful to provide Pyro staff if you run into an unexpected issue.
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Code>
|
||||
Version: {import.meta.env.VITE_PYRODACTYL_VERSION} -{' '}
|
||||
{import.meta.env.VITE_BRANCH_NAME}
|
||||
</Code>
|
||||
<Code>Commit : {import.meta.env.VITE_COMMIT_HASH.slice(0, 7)}</Code>
|
||||
</div>
|
||||
</ContentBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2"
|
||||
style={{
|
||||
animationDelay: "100ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<ContentBox title={"Panel Version"}>
|
||||
<p className="text-sm mb-4 text-zinc-300">
|
||||
This is useful to provide Pyro staff if you run into an
|
||||
unexpected issue.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Code>
|
||||
Version: {import.meta.env.VITE_PYRODACTYL_VERSION} -{" "}
|
||||
{import.meta.env.VITE_BRANCH_NAME}
|
||||
</Code>
|
||||
<Code>
|
||||
Commit : {import.meta.env.VITE_COMMIT_HASH.slice(0, 7)}
|
||||
</Code>
|
||||
</div>
|
||||
</ContentBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountOverviewContainer;
|
||||
|
||||
@@ -1,57 +1,63 @@
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
import { useContext } from 'react';
|
||||
import ModalContext from "@/context/ModalContext";
|
||||
import { Activity02Icon } from "@hugeicons/core-free-icons";
|
||||
import { useContext } from "react";
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import FlashMessageRender from "@/components/FlashMessageRender";
|
||||
import CopyOnClick from "@/components/elements/CopyOnClick";
|
||||
|
||||
import asModal from '@/hoc/asModal';
|
||||
import asModal from "@/hoc/asModal";
|
||||
|
||||
import ActionButton from '../elements/ActionButton';
|
||||
import ActionButton from "../elements/ActionButton";
|
||||
|
||||
interface Props {
|
||||
apiKey: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
const ApiKeyModal = ({ apiKey }: Props) => {
|
||||
const { dismiss } = useContext(ModalContext);
|
||||
const { dismiss } = useContext(ModalContext);
|
||||
|
||||
return (
|
||||
<div className='p-6 space-y-6 max-w-lg mx-auto rounded-lg shadow-lg'>
|
||||
{/* Flash message section */}
|
||||
<FlashMessageRender byKey='account' />
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-lg mx-auto rounded-lg shadow-lg">
|
||||
{/* Flash message section */}
|
||||
<FlashMessageRender byKey="account" />
|
||||
|
||||
{/* Modal Header */}
|
||||
<p className='text-sm text-white-600 mt-2'>
|
||||
The API key you have requested is shown below. Please store it in a safe place, as it will not be shown
|
||||
again.
|
||||
</p>
|
||||
{/* Modal Header */}
|
||||
<p className="text-sm text-white-600 mt-2">
|
||||
The API key you have requested is shown below. Please store it in a safe
|
||||
place, as it will not be shown again.
|
||||
</p>
|
||||
|
||||
{/* API Key Display Section */}
|
||||
<div className='relative mt-6'>
|
||||
<pre className='bg-gray-900 text-white p-4 rounded-lg font-mono overflow-x-auto'>
|
||||
<CopyOnClick text={apiKey}>
|
||||
<code className='text-sm break-words'>{apiKey}</code>
|
||||
</CopyOnClick>
|
||||
{/* API Key Display Section */}
|
||||
<div className="relative mt-6">
|
||||
<pre className="bg-gray-900 text-white p-4 rounded-lg font-mono overflow-x-auto">
|
||||
<CopyOnClick text={apiKey}>
|
||||
<code className="text-sm break-words">{apiKey}</code>
|
||||
</CopyOnClick>
|
||||
|
||||
{/* Copy button with icon */}
|
||||
<div className='absolute top-2 right-2'></div>
|
||||
</pre>
|
||||
</div>
|
||||
{/* Copy button with icon */}
|
||||
<div className="absolute top-2 right-2"></div>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className='flex justify-end space-x-4'>
|
||||
<ActionButton type='button' onClick={() => dismiss()} variant='danger' className='flex items-center'>
|
||||
Close
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<ActionButton
|
||||
type="button"
|
||||
onClick={() => dismiss()}
|
||||
variant="danger"
|
||||
className="flex items-center"
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ApiKeyModal.displayName = 'ApiKeyModal';
|
||||
ApiKeyModal.displayName = "ApiKeyModal";
|
||||
|
||||
export default asModal<Props>({
|
||||
title: 'Your API Key',
|
||||
closeOnEscape: true, // Allows closing the modal by pressing Escape
|
||||
closeOnBackground: true, // Allows closing by clicking outside the modal
|
||||
title: "Your API Key",
|
||||
closeOnEscape: true, // Allows closing the modal by pressing Escape
|
||||
closeOnBackground: true, // Allows closing by clicking outside the modal
|
||||
})(ApiKeyModal);
|
||||
|
||||
104
resources/scripts/components/dashboard/CreateApiKeyModal.tsx
Normal file
104
resources/scripts/components/dashboard/CreateApiKeyModal.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Form, Formik, FormikHelpers } from "formik";
|
||||
import { Field } from "formik";
|
||||
import { object, string } from "yup";
|
||||
|
||||
import FormikFieldWrapper from "@/components/elements/FormikFieldWrapper";
|
||||
import Input from "@/components/elements/Input";
|
||||
import SpinnerOverlay from "@/components/elements/SpinnerOverlay";
|
||||
import { Dialog } from "@/components/elements/dialog";
|
||||
|
||||
interface CreateValues {
|
||||
description: string;
|
||||
allowedIps: string;
|
||||
}
|
||||
|
||||
interface CreateApiKeyModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
values: CreateValues,
|
||||
helpers: FormikHelpers<CreateValues>,
|
||||
) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const validationSchema = object().shape({
|
||||
description: string()
|
||||
.required("Description is required")
|
||||
.min(4, "Must be at least 4 characters"),
|
||||
allowedIps: string(),
|
||||
});
|
||||
|
||||
export default function CreateApiKeyModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
}: CreateApiKeyModalProps) {
|
||||
return (
|
||||
<Dialog.Confirm
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Create API Key"
|
||||
confirm="Create Key"
|
||||
onConfirmed={() => {
|
||||
// Trigger form submission programmatically
|
||||
const form = document.getElementById(
|
||||
"create-api-form",
|
||||
) as HTMLFormElement;
|
||||
if (form) {
|
||||
const submitButton = form.querySelector(
|
||||
'button[type="submit"]',
|
||||
) as HTMLButtonElement;
|
||||
if (submitButton) submitButton.click();
|
||||
}
|
||||
}}
|
||||
// Optional: disable confirm button while submitting
|
||||
confirmDisabled={isSubmitting}
|
||||
>
|
||||
<Formik
|
||||
initialValues={{ description: "", allowedIps: "" }}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting: formikIsSubmitting }) => (
|
||||
<Form id="create-api-form" className="space-y-4">
|
||||
<SpinnerOverlay visible={formikIsSubmitting || isSubmitting} />
|
||||
|
||||
<FormikFieldWrapper
|
||||
label="Description"
|
||||
name="description"
|
||||
description="A description of this API key."
|
||||
>
|
||||
<Field
|
||||
name="description"
|
||||
as={Input}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</FormikFieldWrapper>
|
||||
|
||||
<FormikFieldWrapper
|
||||
label="Allowed IPs"
|
||||
name="allowedIps"
|
||||
description={
|
||||
"Leave blank to allow any IP address. " +
|
||||
"Otherwise provide each IP or CIDR range on a new line (e.g. 192.168.1.1, 10.0.0.0/24)."
|
||||
}
|
||||
>
|
||||
<Field
|
||||
name="allowedIps"
|
||||
as="textarea"
|
||||
rows={4}
|
||||
className="w-full rounded bg-[#ffffff0d] border border-[#ffffff12] p-3 text-sm text-zinc-100 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</FormikFieldWrapper>
|
||||
|
||||
{/* Hidden submit button — triggered by Dialog confirm */}
|
||||
<button type="submit" className="hidden" />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog.Confirm>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +1,282 @@
|
||||
import { useHeader } from '@/contexts/HeaderContext';
|
||||
import { ArrowDown01Icon, FilterIcon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import { useHeader } from "@/contexts/HeaderContext";
|
||||
import { ArrowDown01Icon, FilterIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useStoreState } from "easy-peasy";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
|
||||
import ServerRow from '@/components/dashboard/ServerRow';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import Pagination from '@/components/elements/Pagination';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/elements/Tabs';
|
||||
import ServerRow from "@/components/dashboard/ServerRow";
|
||||
import PageContentBlock from "@/components/elements/PageContentBlock";
|
||||
import Pagination from "@/components/elements/Pagination";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/elements/Tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
import getServers from '@/api/getServers';
|
||||
import { PaginatedResult } from '@/api/http';
|
||||
import { Server } from '@/api/server/getServer';
|
||||
import getServers from "@/api/getServers";
|
||||
import { PaginatedResult } from "@/api/http";
|
||||
import { Server } from "@/api/server/getServer";
|
||||
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { usePersistedState } from '@/plugins/usePersistedState';
|
||||
import useFlash from "@/plugins/useFlash";
|
||||
import { usePersistedState } from "@/plugins/usePersistedState";
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import HeaderCentered from './header/HeaderCentered';
|
||||
import SearchSection from './header/SearchSection';
|
||||
import { Button } from "../ui/button";
|
||||
import HeaderCentered from "./header/HeaderCentered";
|
||||
import SearchSection from "./header/SearchSection";
|
||||
|
||||
const DashboardContainer = () => {
|
||||
const { search } = useLocation();
|
||||
const defaultPage = Number(new URLSearchParams(search).get('page') || '1');
|
||||
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 [serverViewMode, setServerViewMode] = usePersistedState<'owner' | 'admin-all' | 'all'>(
|
||||
`${uuid}:server_view_mode`,
|
||||
'owner',
|
||||
);
|
||||
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 [serverViewMode, setServerViewMode] = usePersistedState<
|
||||
"owner" | "admin-all" | "all"
|
||||
>(`${uuid}:server_view_mode`, "owner");
|
||||
|
||||
const { setHeaderActions, clearHeaderActions } = useHeader();
|
||||
const { setHeaderActions, clearHeaderActions } = useHeader();
|
||||
|
||||
const [dashboardDisplayOption, setDashboardDisplayOption] = usePersistedState(
|
||||
`${uuid}:dashboard_display_option`,
|
||||
'list',
|
||||
);
|
||||
const [dashboardDisplayOption, setDashboardDisplayOption] = usePersistedState(
|
||||
`${uuid}:dashboard_display_option`,
|
||||
"list",
|
||||
);
|
||||
|
||||
const getApiType = (): string | undefined => {
|
||||
if (serverViewMode === 'owner') return 'owner'; // Servers the User owns
|
||||
if (serverViewMode === 'admin-all') return 'admin-all'; // All servers(Admin only)
|
||||
if (serverViewMode === 'all') return 'all'; // All servers user has Access too. (Subusers and owned)
|
||||
return undefined;
|
||||
};
|
||||
const getApiType = (): string | undefined => {
|
||||
if (serverViewMode === "owner") return "owner"; // Servers the User owns
|
||||
if (serverViewMode === "admin-all") return "admin-all"; // All servers(Admin only)
|
||||
if (serverViewMode === "all") return "all"; // All servers user has Access too. (Subusers and owned)
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
|
||||
['/api/client/servers', serverViewMode, page],
|
||||
() => getServers({ page, type: getApiType() }),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
|
||||
["/api/client/servers", serverViewMode, page],
|
||||
() => getServers({ page, type: getApiType() }),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const handleFilterToggle = useCallback(() => {
|
||||
setShowOnlyAdmin((s) => !s);
|
||||
}, [setShowOnlyAdmin]);
|
||||
const handleFilterToggle = useCallback(() => {
|
||||
setShowOnlyAdmin((s) => !s);
|
||||
}, [setShowOnlyAdmin]);
|
||||
|
||||
const searchSection = useMemo(
|
||||
() => (
|
||||
<HeaderCentered>
|
||||
<SearchSection className='max-w-128 xl:w-[30vw] hidden md:flex ' />
|
||||
</HeaderCentered>
|
||||
),
|
||||
[],
|
||||
);
|
||||
const searchSection = useMemo(
|
||||
() => (
|
||||
<HeaderCentered>
|
||||
<SearchSection className="max-w-128 xl:w-[30vw] hidden md:flex " />
|
||||
</HeaderCentered>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const viewTabs = useMemo(
|
||||
() => (
|
||||
<Tabs value={dashboardDisplayOption} onValueChange={setDashboardDisplayOption} className='lg:block hidden'>
|
||||
<TabsList>
|
||||
<TabsTrigger aria-label='View servers in a list layout.' value='list'>
|
||||
<svg width='13' height='14' viewBox='0 0 16 17' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='M1 2.3C1 2.02 1 1.88 1.054 1.773C1.10215 1.67903 1.17881 1.60272 1.273 1.555C1.38 1.5 1.52 1.5 1.8 1.5H14.2C14.48 1.5 14.62 1.5 14.727 1.554C14.821 1.60215 14.8973 1.67881 14.945 1.773C15 1.88 15 2.02 15 2.3V2.7C15 2.98 15 3.12 14.945 3.227C14.8973 3.32119 14.821 3.39785 14.727 3.446C14.62 3.5 14.48 3.5 14.2 3.5H1.8C1.52 3.5 1.38 3.5 1.273 3.446C1.17903 3.39785 1.10272 3.32119 1.055 3.227C1 3.12 1 2.98 1 2.7V2.3ZM1 14.3C1 14.02 1 13.88 1.054 13.773C1.10215 13.679 1.17881 13.6027 1.273 13.555C1.38 13.5 1.52 13.5 1.8 13.5H14.2C14.48 13.5 14.62 13.5 14.727 13.555C14.8208 13.6029 14.8971 13.6792 14.945 13.773C15 13.88 15 14.02 15 14.3V14.7C15 14.98 15 15.12 14.945 15.227C14.8971 15.3208 14.8208 15.3971 14.727 15.445C14.62 15.5 14.48 15.5 14.2 15.5H1.8C1.52 15.5 1.38 15.5 1.273 15.445C1.17919 15.3971 1.10289 15.3208 1.055 15.227C1 15.12 1 14.98 1 14.7V14.3ZM1 10.3C1 10.02 1 9.88 1.054 9.773C1.10215 9.67903 1.17881 9.60272 1.273 9.555C1.38 9.5 1.52 9.5 1.8 9.5H14.2C14.48 9.5 14.62 9.5 14.727 9.555C14.8208 9.60289 14.8971 9.67918 14.945 9.773C15 9.88 15 10.02 15 10.3V10.7C15 10.98 15 11.12 14.945 11.227C14.8971 11.3208 14.8208 11.3971 14.727 11.445C14.62 11.5 14.48 11.5 14.2 11.5H1.8C1.52 11.5 1.38 11.5 1.273 11.445C1.17919 11.3971 1.10289 11.3208 1.055 11.227C1 11.12 1 10.98 1 10.7V10.3ZM1 6.3C1 6.02 1 5.88 1.054 5.773C1.10215 5.67903 1.17881 5.60272 1.273 5.555C1.38 5.5 1.52 5.5 1.8 5.5H14.2C14.48 5.5 14.62 5.5 14.727 5.554C14.821 5.60215 14.8973 5.67881 14.945 5.773C15 5.88 15 6.02 15 6.3V6.7C15 6.98 15 7.12 14.945 7.227C14.8971 7.32082 14.8208 7.39711 14.727 7.445C14.62 7.5 14.48 7.5 14.2 7.5H1.8C1.52 7.5 1.38 7.5 1.273 7.446C1.17903 7.39785 1.10272 7.32119 1.055 7.227C1 7.12 1 6.98 1 6.7V6.3Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger aria-label='View servers in a grid layout.' value='grid'>
|
||||
<svg width='13' height='14' viewBox='0 0 16 17' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='M1 3.1C1 2.54 1 2.26 1.109 2.046C1.20487 1.85785 1.35785 1.70487 1.546 1.609C1.76 1.5 2.04 1.5 2.6 1.5H5.4C5.96 1.5 6.24 1.5 6.454 1.609C6.64215 1.70487 6.79513 1.85785 6.891 2.046C7 2.26 7 2.54 7 3.1V3.9C7 4.46 7 4.74 6.891 4.954C6.79513 5.14215 6.64215 5.29513 6.454 5.391C6.24 5.5 5.96 5.5 5.4 5.5H2.6C2.04 5.5 1.76 5.5 1.546 5.391C1.35785 5.29513 1.20487 5.14215 1.109 4.954C1 4.74 1 4.46 1 3.9V3.1ZM9 3.1C9 2.54 9 2.26 9.109 2.046C9.20487 1.85785 9.35785 1.70487 9.546 1.609C9.76 1.5 10.04 1.5 10.6 1.5H13.4C13.96 1.5 14.24 1.5 14.454 1.609C14.6422 1.70487 14.7951 1.85785 14.891 2.046C15 2.26 15 2.54 15 3.1V3.9C15 4.46 15 4.74 14.891 4.954C14.7951 5.14215 14.6422 5.29513 14.454 5.391C14.24 5.5 13.96 5.5 13.4 5.5H10.6C10.04 5.5 9.76 5.5 9.546 5.391C9.35785 5.29513 9.20487 5.14215 9.109 4.954C9 4.74 9 4.46 9 3.9V3.1ZM1 8.1C1 7.54 1 7.26 1.109 7.046C1.20487 6.85785 1.35785 6.70487 1.546 6.609C1.76 6.5 2.04 6.5 2.6 6.5H5.4C5.96 6.5 6.24 6.5 6.454 6.609C6.64215 6.70487 6.79513 6.85785 6.891 7.046C7 7.26 7 7.54 7 8.1V8.9C7 9.46 7 9.74 6.891 9.954C6.79513 10.1422 6.64215 10.2951 6.454 10.391C6.24 10.5 5.96 10.5 5.4 10.5H2.6C2.04 10.5 1.76 10.5 1.546 10.391C1.35785 10.2951 1.20487 10.1422 1.109 9.954C1 9.74 1 9.46 1 8.9V8.1ZM9 8.1C9 7.54 9 7.26 9.109 7.046C9.20487 6.85785 9.35785 6.70487 9.546 6.609C9.76 6.5 10.04 6.5 10.6 6.5H13.4C13.96 6.5 14.24 6.5 14.454 6.609C14.6422 6.70487 14.7951 6.85785 14.891 7.046C15 7.26 15 7.54 15 8.1V8.9C15 9.46 15 9.74 14.891 9.954C14.7951 10.1422 14.6422 10.2951 14.454 10.391C14.24 10.5 13.96 10.5 13.4 10.5H10.6C10.04 10.5 9.76 10.5 9.546 10.391C9.35785 10.2951 9.20487 10.1422 9.109 9.954C9 9.74 9 9.46 9 8.9V8.1ZM1 13.1C1 12.54 1 12.26 1.109 12.046C1.20487 11.8578 1.35785 11.7049 1.546 11.609C1.76 11.5 2.04 11.5 2.6 11.5H5.4C5.96 11.5 6.24 11.5 6.454 11.609C6.64215 11.7049 6.79513 11.8578 6.891 12.046C7 12.26 7 12.54 7 13.1V13.9C7 14.46 7 14.74 6.891 14.954C6.79513 15.1422 6.64215 15.2951 6.454 15.391C6.24 15.5 5.96 15.5 5.4 15.5H2.6C2.04 15.5 1.76 15.5 1.546 15.391C1.35785 15.2951 1.20487 15.1422 1.109 14.954C1 14.74 1 14.46 1 13.9V13.1ZM9 13.1C9 12.54 9 12.26 9.109 12.046C9.20487 11.8578 9.35785 11.7049 9.546 11.609C9.76 11.5 10.04 11.5 10.6 11.5H13.4C13.96 11.5 14.24 11.5 14.454 11.609C14.6422 11.7049 14.7951 11.8578 14.891 12.046C15 12.26 15 12.54 15 13.1V13.9C15 14.46 15 14.74 14.891 14.954C14.7951 15.1422 14.6422 15.2951 14.454 15.391C14.24 15.5 13.96 15.5 13.4 15.5H10.6C10.04 15.5 9.76 15.5 9.546 15.391C9.35785 15.2951 1.20487 15.1422 9.109 14.954C9 14.74 9 14.46 9 13.9V13.1Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
),
|
||||
[dashboardDisplayOption, setDashboardDisplayOption],
|
||||
);
|
||||
const viewTabs = useMemo(
|
||||
() => (
|
||||
<Tabs
|
||||
value={dashboardDisplayOption}
|
||||
onValueChange={setDashboardDisplayOption}
|
||||
className="lg:block hidden"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger aria-label="View servers in a list layout." value="list">
|
||||
<svg
|
||||
width="13"
|
||||
height="14"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1 2.3C1 2.02 1 1.88 1.054 1.773C1.10215 1.67903 1.17881 1.60272 1.273 1.555C1.38 1.5 1.52 1.5 1.8 1.5H14.2C14.48 1.5 14.62 1.5 14.727 1.554C14.821 1.60215 14.8973 1.67881 14.945 1.773C15 1.88 15 2.02 15 2.3V2.7C15 2.98 15 3.12 14.945 3.227C14.8973 3.32119 14.821 3.39785 14.727 3.446C14.62 3.5 14.48 3.5 14.2 3.5H1.8C1.52 3.5 1.38 3.5 1.273 3.446C1.17903 3.39785 1.10272 3.32119 1.055 3.227C1 3.12 1 2.98 1 2.7V2.3ZM1 14.3C1 14.02 1 13.88 1.054 13.773C1.10215 13.679 1.17881 13.6027 1.273 13.555C1.38 13.5 1.52 13.5 1.8 13.5H14.2C14.48 13.5 14.62 13.5 14.727 13.555C14.8208 13.6029 14.8971 13.6792 14.945 13.773C15 13.88 15 14.02 15 14.3V14.7C15 14.98 15 15.12 14.945 15.227C14.8971 15.3208 14.8208 15.3971 14.727 15.445C14.62 15.5 14.48 15.5 14.2 15.5H1.8C1.52 15.5 1.38 15.5 1.273 15.445C1.17919 15.3971 1.10289 15.3208 1.055 15.227C1 15.12 1 14.98 1 14.7V14.3ZM1 10.3C1 10.02 1 9.88 1.054 9.773C1.10215 9.67903 1.17881 9.60272 1.273 9.555C1.38 9.5 1.52 9.5 1.8 9.5H14.2C14.48 9.5 14.62 9.5 14.727 9.555C14.8208 9.60289 14.8971 9.67918 14.945 9.773C15 9.88 15 10.02 15 10.3V10.7C15 10.98 15 11.12 14.945 11.227C14.8971 11.3208 14.8208 11.3971 14.727 11.445C14.62 11.5 14.48 11.5 14.2 11.5H1.8C1.52 11.5 1.38 11.5 1.273 11.445C1.17919 11.3971 1.10289 11.3208 1.055 11.227C1 11.12 1 10.98 1 10.7V10.3ZM1 6.3C1 6.02 1 5.88 1.054 5.773C1.10215 5.67903 1.17881 5.60272 1.273 5.555C1.38 5.5 1.52 5.5 1.8 5.5H14.2C14.48 5.5 14.62 5.5 14.727 5.554C14.821 5.60215 14.8973 5.67881 14.945 5.773C15 5.88 15 6.02 15 6.3V6.7C15 6.98 15 7.12 14.945 7.227C14.8971 7.32082 14.8208 7.39711 14.727 7.445C14.62 7.5 14.48 7.5 14.2 7.5H1.8C1.52 7.5 1.38 7.5 1.273 7.446C1.17903 7.39785 1.10272 7.32119 1.055 7.227C1 7.12 1 6.98 1 6.7V6.3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger aria-label="View servers in a grid layout." value="grid">
|
||||
<svg
|
||||
width="13"
|
||||
height="14"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1 3.1C1 2.54 1 2.26 1.109 2.046C1.20487 1.85785 1.35785 1.70487 1.546 1.609C1.76 1.5 2.04 1.5 2.6 1.5H5.4C5.96 1.5 6.24 1.5 6.454 1.609C6.64215 1.70487 6.79513 1.85785 6.891 2.046C7 2.26 7 2.54 7 3.1V3.9C7 4.46 7 4.74 6.891 4.954C6.79513 5.14215 6.64215 5.29513 6.454 5.391C6.24 5.5 5.96 5.5 5.4 5.5H2.6C2.04 5.5 1.76 5.5 1.546 5.391C1.35785 5.29513 1.20487 5.14215 1.109 4.954C1 4.74 1 4.46 1 3.9V3.1ZM9 3.1C9 2.54 9 2.26 9.109 2.046C9.20487 1.85785 9.35785 1.70487 9.546 1.609C9.76 1.5 10.04 1.5 10.6 1.5H13.4C13.96 1.5 14.24 1.5 14.454 1.609C14.6422 1.70487 14.7951 1.85785 14.891 2.046C15 2.26 15 2.54 15 3.1V3.9C15 4.46 15 4.74 14.891 4.954C14.7951 5.14215 14.6422 5.29513 14.454 5.391C14.24 5.5 13.96 5.5 13.4 5.5H10.6C10.04 5.5 9.76 5.5 9.546 5.391C9.35785 5.29513 9.20487 5.14215 9.109 4.954C9 4.74 9 4.46 9 3.9V3.1ZM1 8.1C1 7.54 1 7.26 1.109 7.046C1.20487 6.85785 1.35785 6.70487 1.546 6.609C1.76 6.5 2.04 6.5 2.6 6.5H5.4C5.96 6.5 6.24 6.5 6.454 6.609C6.64215 6.70487 6.79513 6.85785 6.891 7.046C7 7.26 7 7.54 7 8.1V8.9C7 9.46 7 9.74 6.891 9.954C6.79513 10.1422 6.64215 10.2951 6.454 10.391C6.24 10.5 5.96 10.5 5.4 10.5H2.6C2.04 10.5 1.76 10.5 1.546 10.391C1.35785 10.2951 1.20487 10.1422 1.109 9.954C1 9.74 1 9.46 1 8.9V8.1ZM9 8.1C9 7.54 9 7.26 9.109 7.046C9.20487 6.85785 9.35785 6.70487 9.546 6.609C9.76 6.5 10.04 6.5 10.6 6.5H13.4C13.96 6.5 14.24 6.5 14.454 6.609C14.6422 6.70487 14.7951 6.85785 14.891 7.046C15 7.26 15 7.54 15 8.1V8.9C15 9.46 15 9.74 14.891 9.954C14.7951 10.1422 14.6422 10.2951 14.454 10.391C14.24 10.5 13.96 10.5 13.4 10.5H10.6C10.04 10.5 9.76 10.5 9.546 10.391C9.35785 10.2951 9.20487 10.1422 9.109 9.954C9 9.74 9 9.46 9 8.9V8.1ZM1 13.1C1 12.54 1 12.26 1.109 12.046C1.20487 11.8578 1.35785 11.7049 1.546 11.609C1.76 11.5 2.04 11.5 2.6 11.5H5.4C5.96 11.5 6.24 11.5 6.454 11.609C6.64215 11.7049 6.79513 11.8578 6.891 12.046C7 12.26 7 12.54 7 13.1V13.9C7 14.46 7 14.74 6.891 14.954C6.79513 15.1422 6.64215 15.2951 6.454 15.391C6.24 15.5 5.96 15.5 5.4 15.5H2.6C2.04 15.5 1.76 15.5 1.546 15.391C1.35785 15.2951 1.20487 15.1422 1.109 14.954C1 14.74 1 14.46 1 13.9V13.1ZM9 13.1C9 12.54 9 12.26 9.109 12.046C9.20487 11.8578 9.35785 11.7049 9.546 11.609C9.76 11.5 10.04 11.5 10.6 11.5H13.4C13.96 11.5 14.24 11.5 14.454 11.609C14.6422 11.7049 14.7951 11.8578 14.891 12.046C15 12.26 15 12.54 15 13.1V13.9C15 14.46 15 14.74 14.891 14.954C14.7951 15.1422 14.6422 15.2951 14.454 15.391C14.24 15.5 13.96 15.5 13.4 15.5H10.6C10.04 15.5 9.76 15.5 9.546 15.391C9.35785 15.2951 1.20487 15.1422 9.109 14.954C9 14.74 9 14.46 9 13.9V13.1Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
),
|
||||
[dashboardDisplayOption, setDashboardDisplayOption],
|
||||
);
|
||||
|
||||
const filterDropdown = useMemo(
|
||||
() => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size={'sm'} variant={'secondary'} className='px-1 pl-3 gap-1 rounded-full'>
|
||||
<div className='flex flex-row items-center gap-1'>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<HugeiconsIcon size={16} strokeWidth={2} icon={FilterIcon} className='size-4' />
|
||||
Filter
|
||||
</div>
|
||||
<HugeiconsIcon size={16} strokeWidth={2} icon={ArrowDown01Icon} />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='flex flex-col gap-1 z-99999' sideOffset={8}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setServerViewMode('owner')}
|
||||
className={serverViewMode === 'owner' ? 'bg-accent/20' : ''}
|
||||
>
|
||||
Your Servers Only
|
||||
</DropdownMenuItem>
|
||||
const filterDropdown = useMemo(
|
||||
() => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
className="px-1 pl-3 gap-1 rounded-full"
|
||||
>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div className="flex flex-row items-center gap-1.5">
|
||||
<HugeiconsIcon
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
icon={FilterIcon}
|
||||
className="size-4"
|
||||
/>
|
||||
Filter
|
||||
</div>
|
||||
<HugeiconsIcon size={16} strokeWidth={2} icon={ArrowDown01Icon} />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="flex flex-col gap-1 z-99999"
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setServerViewMode("owner")}
|
||||
className={serverViewMode === "owner" ? "bg-accent/20" : ""}
|
||||
>
|
||||
Your Servers Only
|
||||
</DropdownMenuItem>
|
||||
|
||||
{rootAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setServerViewMode('admin-all')}
|
||||
className={serverViewMode === 'admin-all' ? 'bg-accent/20' : ''}
|
||||
>
|
||||
All Servers (Admin)
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setServerViewMode('all')}
|
||||
className={serverViewMode === 'all' ? 'bg-accent/20' : ''}
|
||||
>
|
||||
All Servers
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
[rootAdmin, showOnlyAdmin],
|
||||
);
|
||||
{rootAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setServerViewMode("admin-all")}
|
||||
className={serverViewMode === "admin-all" ? "bg-accent/20" : ""}
|
||||
>
|
||||
All Servers (Admin)
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setServerViewMode("all")}
|
||||
className={serverViewMode === "all" ? "bg-accent/20" : ""}
|
||||
>
|
||||
All Servers
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
[rootAdmin, showOnlyAdmin],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderActions([searchSection, viewTabs, filterDropdown]);
|
||||
return () => clearHeaderActions();
|
||||
}, [setHeaderActions, clearHeaderActions, searchSection, viewTabs, filterDropdown]);
|
||||
useEffect(() => {
|
||||
setHeaderActions([searchSection, viewTabs, filterDropdown]);
|
||||
return () => clearHeaderActions();
|
||||
}, [
|
||||
setHeaderActions,
|
||||
clearHeaderActions,
|
||||
searchSection,
|
||||
viewTabs,
|
||||
filterDropdown,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!servers) return;
|
||||
if (servers.pagination.currentPage > 1 && !servers.items.length) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [servers]);
|
||||
useEffect(() => {
|
||||
if (!servers) return;
|
||||
if (servers.pagination.currentPage > 1 && !servers.items.length) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [servers]);
|
||||
|
||||
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(() => {
|
||||
// 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, clearAndAddHttpError, clearFlashes]);
|
||||
useEffect(() => {
|
||||
if (error) clearAndAddHttpError({ key: "dashboard", error });
|
||||
if (!error) clearFlashes("dashboard");
|
||||
}, [error, clearAndAddHttpError, clearFlashes]);
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Dashboard'} showFlashKey={'dashboard'}>
|
||||
{!servers ? (
|
||||
<></>
|
||||
) : (
|
||||
<Pagination data={servers} onPageSelect={setPage}>
|
||||
{({ items }) =>
|
||||
items.length > 0 ? (
|
||||
<div
|
||||
className={
|
||||
dashboardDisplayOption === 'grid'
|
||||
? 'flex flex-wrap gap-4 max-lg:flex-col max-lg:gap-0'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{items.map((server, index) => (
|
||||
<div
|
||||
key={`${server.uuid}-${dashboardDisplayOption}`}
|
||||
className={`transform-gpu skeleton-anim-2 ${dashboardDisplayOption === 'grid'
|
||||
? items.length === 1
|
||||
? 'w-[calc(50%-0.5rem)] max-lg:w-full'
|
||||
: 'w-[calc(50%-0.5rem)] max-lg:w-full'
|
||||
: 'mb-4'
|
||||
} max-lg:mb-4`}
|
||||
style={{
|
||||
animationDelay: `${index * 50 + 50}ms`,
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<ServerRow
|
||||
className={
|
||||
dashboardDisplayOption === 'list'
|
||||
? 'flex-row'
|
||||
: 'items-start! flex-col w-full gap-4 [&>div~div]:w-full max-lg:flex-row max-lg:items-center max-lg:gap-0 max-lg:[&>div~div]:w-auto'
|
||||
}
|
||||
key={server.uuid}
|
||||
server={server}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`text-center text-sm text-zinc-400 absolute w-full left-1/2 -translate-x-1/2`}
|
||||
>
|
||||
<p className='max-w-sm mx-auto mb-5'>
|
||||
{serverViewMode === 'admin-all'
|
||||
? 'There are no other servers to display.'
|
||||
: serverViewMode === 'all'
|
||||
? 'No Server Shared With your Account'
|
||||
: 'There are no servers associated with your account.'}
|
||||
</p>
|
||||
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
|
||||
{serverViewMode === 'admin-all' ? 'No other servers found' : 'No servers found'}
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Pagination>
|
||||
)}
|
||||
</PageContentBlock>
|
||||
);
|
||||
return (
|
||||
<PageContentBlock title={"Dashboard"} showFlashKey={"dashboard"}>
|
||||
{!servers ? (
|
||||
<></>
|
||||
) : (
|
||||
<Pagination data={servers} onPageSelect={setPage}>
|
||||
{({ items }) =>
|
||||
items.length > 0 ? (
|
||||
<div
|
||||
className={
|
||||
dashboardDisplayOption === "grid"
|
||||
? "flex flex-wrap gap-4 max-lg:flex-col max-lg:gap-0"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{items.map((server, index) => (
|
||||
<div
|
||||
key={`${server.uuid}-${dashboardDisplayOption}`}
|
||||
className={`transform-gpu skeleton-anim-2 ${
|
||||
dashboardDisplayOption === "grid"
|
||||
? items.length === 1
|
||||
? "w-[calc(50%-0.5rem)] max-lg:w-full"
|
||||
: "w-[calc(50%-0.5rem)] max-lg:w-full"
|
||||
: "mb-4"
|
||||
} max-lg:mb-4`}
|
||||
style={{
|
||||
animationDelay: `${index * 50 + 50}ms`,
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<ServerRow
|
||||
className={
|
||||
dashboardDisplayOption === "list"
|
||||
? "flex-row"
|
||||
: "items-start! flex-col w-full gap-4 [&>div~div]:w-full max-lg:flex-row max-lg:items-center max-lg:gap-0 max-lg:[&>div~div]:w-auto"
|
||||
}
|
||||
key={server.uuid}
|
||||
server={server}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`text-center text-sm text-zinc-400 absolute w-full left-1/2 -translate-x-1/2`}
|
||||
>
|
||||
<p className="max-w-sm mx-auto mb-5">
|
||||
{serverViewMode === "admin-all"
|
||||
? "There are no other servers to display."
|
||||
: serverViewMode === "all"
|
||||
? "No Server Shared With your Account"
|
||||
: "There are no servers associated with your account."}
|
||||
</p>
|
||||
<h3 className="text-lg font-medium text-zinc-200 mb-2">
|
||||
{serverViewMode === "admin-all"
|
||||
? "No other servers found"
|
||||
: "No servers found"}
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Pagination>
|
||||
)}
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardContainer;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { bytesToString, ip } from '@/lib/formatters';
|
||||
import { bytesToString, ip } from "@/lib/formatters";
|
||||
|
||||
import { Server, getGlobalDaemonType } from '@/api/server/getServer';
|
||||
import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/server/getServerResourceUsage';
|
||||
import { Server, getGlobalDaemonType } from "@/api/server/getServer";
|
||||
import getServerResourceUsage, {
|
||||
ServerPowerState,
|
||||
ServerStats,
|
||||
} from "@/api/server/getServerResourceUsage";
|
||||
|
||||
// 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 isAlarmState = (current: number, limit: number): boolean =>
|
||||
limit > 0 && current / (limit * 1024 * 1024) >= 0.9;
|
||||
|
||||
const StatusIndicatorBox = styled.div<{ $status: ServerPowerState }>`
|
||||
background: #ffffff11;
|
||||
@@ -38,159 +42,188 @@ position: relative;
|
||||
transition: all 250ms ease-in-out;
|
||||
|
||||
box-shadow: ${({ $status }) => {
|
||||
if (!$status || $status === 'offline') {
|
||||
return '0 0 12px 1px #C74343';
|
||||
} else if ($status === 'running') {
|
||||
return '0 0 12px 1px #43C760';
|
||||
} else if ($status === 'installing') {
|
||||
return '0 0 12px 1px #4381c7';
|
||||
} else {
|
||||
return '0 0 12px 1px #c7aa43';
|
||||
}
|
||||
}};
|
||||
if (!$status || $status === "offline") {
|
||||
return "0 0 12px 1px #C74343";
|
||||
} else if ($status === "running") {
|
||||
return "0 0 12px 1px #43C760";
|
||||
} else if ($status === "installing") {
|
||||
return "0 0 12px 1px #4381c7";
|
||||
} else {
|
||||
return "0 0 12px 1px #c7aa43";
|
||||
}
|
||||
}};
|
||||
|
||||
background: ${({ $status }) => {
|
||||
if (!$status || $status === 'offline') {
|
||||
return 'linear-gradient(180deg, #C74343 0%, #C74343 100%)';
|
||||
} else if ($status === 'running') {
|
||||
return 'linear-gradient(180deg, #91FFA9 0%, #43C760 100%)';
|
||||
} else if ($status === 'installing') {
|
||||
return 'linear-gradient(180deg, #91c7ff 0%, #4381c7 100%)';
|
||||
} else {
|
||||
return 'linear-gradient(180deg, #c7aa43 0%, #c7aa43 100%)';
|
||||
}
|
||||
}}
|
||||
if (!$status || $status === "offline") {
|
||||
return "linear-gradient(180deg, #C74343 0%, #C74343 100%)";
|
||||
} else if ($status === "running") {
|
||||
return "linear-gradient(180deg, #91FFA9 0%, #43C760 100%)";
|
||||
} else if ($status === "installing") {
|
||||
return "linear-gradient(180deg, #91c7ff 0%, #4381c7 100%)";
|
||||
} else {
|
||||
return "linear-gradient(180deg, #c7aa43 0%, #c7aa43 100%)";
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
type Timer = ReturnType<typeof setInterval>;
|
||||
|
||||
const ServerRow = ({ server, className }: { server: Server; className?: string }) => {
|
||||
const interval = useRef<Timer>(null) as React.MutableRefObject<Timer>;
|
||||
const [isSuspended, setIsSuspended] = useState(server.status === 'suspended');
|
||||
const [isInstalling, setIsInstalling] = useState(server.status === 'installing');
|
||||
const [stats, setStats] = useState<ServerStats | null>(null);
|
||||
const ServerRow = ({
|
||||
server,
|
||||
className,
|
||||
}: {
|
||||
server: Server;
|
||||
className?: string;
|
||||
}) => {
|
||||
const interval = useRef<Timer>(null) as React.MutableRefObject<Timer>;
|
||||
const [isSuspended, setIsSuspended] = useState(server.status === "suspended");
|
||||
const [isInstalling, setIsInstalling] = useState(
|
||||
server.status === "installing",
|
||||
);
|
||||
const [stats, setStats] = useState<ServerStats | null>(null);
|
||||
|
||||
const getStats = () =>
|
||||
getServerResourceUsage(server.uuid)
|
||||
.then((data) => setStats(data))
|
||||
.catch((error) => console.error(error));
|
||||
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(() => {
|
||||
setIsSuspended(stats?.isSuspended || server.status === "suspended");
|
||||
}, [stats?.isSuspended, server.status]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsInstalling(stats?.isInstalling || server.status === 'installing');
|
||||
}, [stats?.isInstalling, server.status]);
|
||||
useEffect(() => {
|
||||
setIsInstalling(stats?.isInstalling || server.status === "installing");
|
||||
}, [stats?.isInstalling, 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;
|
||||
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);
|
||||
});
|
||||
getStats().then(() => {
|
||||
interval.current = setInterval(() => getStats(), 30000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (interval.current) clearInterval(interval.current);
|
||||
};
|
||||
}, [isSuspended]);
|
||||
return () => {
|
||||
if (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 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';
|
||||
// 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 || 'offline'}
|
||||
>
|
||||
<div className={`flex items - center`}>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className={`text - xl tracking - tight font - bold truncate max - w - [20vw]`}>
|
||||
{server.name}
|
||||
</p>{' '}
|
||||
<div className={'status-bar'} />
|
||||
</div>
|
||||
<p className={`text - sm text - [#ffffff66]`}>
|
||||
{server.allocations
|
||||
.filter((alloc) => alloc.isDefault)
|
||||
.map((allocation) => (
|
||||
<Fragment key={allocation.ip + allocation.port.toString()}>
|
||||
{allocation.alias || ip(allocation.ip)}:{allocation.port}
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`h - full hidden sm:flex items - center justify - center bg - [#ffffff09] border - [1px] border - [#ffffff11] shadow - xs rounded - md w - fit whitespace - nowrap px - 4 py - 2 text - sm gap - 4`}
|
||||
>
|
||||
{!stats || isSuspended || isInstalling ? (
|
||||
isSuspended ? (
|
||||
<div className={`flex - 1 text - center`}>
|
||||
<span className={`text - red - 100 text - xs`}>
|
||||
{server.status === 'suspended' ? 'Suspended' : 'Connection Error'}
|
||||
</span>
|
||||
</div>
|
||||
) : server.isTransferring || server.status ? (
|
||||
<div className={`flex - 1 text - center`}>
|
||||
<span className={`text - zinc - 100 text - xs`}>
|
||||
{server.isTransferring
|
||||
? 'Transferring'
|
||||
: server.status === 'installing'
|
||||
? 'Installing'
|
||||
: server.status === 'restoring_backup'
|
||||
? 'Restoring Backup'
|
||||
: 'Unavailable'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-xs opacity-25'>Sit tight!</div>
|
||||
)
|
||||
) : (
|
||||
<Fragment>
|
||||
<div className={`sm:flex hidden`}>
|
||||
<div className={`flex justify - center gap - 2 w - fit`}>
|
||||
<p className='text-sm text-[#ffffff66] font-bold w-fit whitespace-nowrap'>CPU</p>
|
||||
<p className='font-bold w-fit whitespace-nowrap'>{stats.cpuUsagePercent.toFixed(2)}%</p>
|
||||
</div>
|
||||
{/* <p className={`text - xs text - zinc - 600 text - center mt - 1`}>of {cpuLimit}</p> */}
|
||||
</div>
|
||||
<div className={`sm:flex hidden`}>
|
||||
{/* <p className={`text - xs text - zinc - 600 text - center mt - 1`}>of {memoryLimit}</p> */}
|
||||
<div className={`flex justify - center gap - 2 w - fit`}>
|
||||
<p className='text-sm text-[#ffffff66] font-bold w-fit whitespace-nowrap'>RAM</p>
|
||||
<p className='font-bold w-fit whitespace-nowrap'>
|
||||
{bytesToString(stats.memoryUsageInBytes, 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`sm:flex hidden`}>
|
||||
<div className={`flex justify - center gap - 2 w - fit`}>
|
||||
<p className='text-sm text-[#ffffff66] font-bold w-fit whitespace-nowrap'>Storage</p>
|
||||
<p className='font-bold w-fit whitespace-nowrap'>
|
||||
{bytesToString(stats.diskUsageInBytes, 0)}
|
||||
</p>
|
||||
</div>
|
||||
{/* <p className={`text - xs text - zinc - 600 text - center mt - 1`}>of {diskLimit}</p> */}
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</StatusIndicatorBox>
|
||||
);
|
||||
return (
|
||||
<StatusIndicatorBox
|
||||
as={Link}
|
||||
to={`/server/${server.id}`}
|
||||
className={className}
|
||||
$status={stats?.status || "offline"}
|
||||
>
|
||||
<div className={`flex items - center`}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={`text - xl tracking - tight font - bold truncate max - w - [20vw]`}
|
||||
>
|
||||
{server.name}
|
||||
</p>{" "}
|
||||
<div className={"status-bar"} />
|
||||
</div>
|
||||
<p className={`text - sm text - [#ffffff66]`}>
|
||||
{server.allocations
|
||||
.filter((alloc) => alloc.isDefault)
|
||||
.map((allocation) => (
|
||||
<Fragment key={allocation.ip + allocation.port.toString()}>
|
||||
{allocation.alias || ip(allocation.ip)}:{allocation.port}
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`h - full hidden sm:flex items - center justify - center bg - [#ffffff09] border - [1px] border - [#ffffff11] shadow - xs rounded - md w - fit whitespace - nowrap px - 4 py - 2 text - sm gap - 4`}
|
||||
>
|
||||
{!stats || isSuspended || isInstalling ? (
|
||||
isSuspended ? (
|
||||
<div className={`flex - 1 text - center`}>
|
||||
<span className={`text - red - 100 text - xs`}>
|
||||
{server.status === "suspended"
|
||||
? "Suspended"
|
||||
: "Connection Error"}
|
||||
</span>
|
||||
</div>
|
||||
) : server.isTransferring || server.status ? (
|
||||
<div className={`flex - 1 text - center`}>
|
||||
<span className={`text - zinc - 100 text - xs`}>
|
||||
{server.isTransferring
|
||||
? "Transferring"
|
||||
: server.status === "installing"
|
||||
? "Installing"
|
||||
: server.status === "restoring_backup"
|
||||
? "Restoring Backup"
|
||||
: "Unavailable"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs opacity-25">Sit tight!</div>
|
||||
)
|
||||
) : (
|
||||
<Fragment>
|
||||
<div className={`sm:flex hidden`}>
|
||||
<div className={`flex justify - center gap - 2 w - fit`}>
|
||||
<p className="text-sm text-[#ffffff66] font-bold w-fit whitespace-nowrap">
|
||||
CPU
|
||||
</p>
|
||||
<p className="font-bold w-fit whitespace-nowrap">
|
||||
{stats.cpuUsagePercent.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
{/* <p className={`text - xs text - zinc - 600 text - center mt - 1`}>of {cpuLimit}</p> */}
|
||||
</div>
|
||||
<div className={`sm:flex hidden`}>
|
||||
{/* <p className={`text - xs text - zinc - 600 text - center mt - 1`}>of {memoryLimit}</p> */}
|
||||
<div className={`flex justify - center gap - 2 w - fit`}>
|
||||
<p className="text-sm text-[#ffffff66] font-bold w-fit whitespace-nowrap">
|
||||
RAM
|
||||
</p>
|
||||
<p className="font-bold w-fit whitespace-nowrap">
|
||||
{bytesToString(stats.memoryUsageInBytes, 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`sm:flex hidden`}>
|
||||
<div className={`flex justify - center gap - 2 w - fit`}>
|
||||
<p className="text-sm text-[#ffffff66] font-bold w-fit whitespace-nowrap">
|
||||
Storage
|
||||
</p>
|
||||
<p className="font-bold w-fit whitespace-nowrap">
|
||||
{bytesToString(stats.diskUsageInBytes, 0)}
|
||||
</p>
|
||||
</div>
|
||||
{/* <p className={`text - xs text - zinc - 600 text - center mt - 1`}>of {diskLimit}</p> */}
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</StatusIndicatorBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerRow;
|
||||
|
||||
@@ -1,386 +1,456 @@
|
||||
import { ArrowDownToLine, ArrowRotateLeft, Funnel, Magnifier, Xmark } from '@gravity-ui/icons';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowRotateLeft,
|
||||
Funnel,
|
||||
Magnifier,
|
||||
Xmark,
|
||||
} from "@gravity-ui/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import Select from '@/components/elements/Select';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import ActivityLogEntry from '@/components/elements/activity/ActivityLogEntry';
|
||||
import { Input } from '@/components/elements/inputs';
|
||||
import PaginationFooter from '@/components/elements/table/PaginationFooter';
|
||||
import FlashMessageRender from "@/components/FlashMessageRender";
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
import { MainPageHeader } from "@/components/elements/MainPageHeader";
|
||||
import PageContentBlock from "@/components/elements/PageContentBlock";
|
||||
import Select from "@/components/elements/Select";
|
||||
import Spinner from "@/components/elements/Spinner";
|
||||
import ActivityLogEntry from "@/components/elements/activity/ActivityLogEntry";
|
||||
import { Input } from "@/components/elements/inputs";
|
||||
import PaginationFooter from "@/components/elements/table/PaginationFooter";
|
||||
|
||||
import { ActivityLogFilters, useActivityLogs } from '@/api/account/activity';
|
||||
import { ActivityLogFilters, useActivityLogs } from "@/api/account/activity";
|
||||
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
import useLocationHash from '@/plugins/useLocationHash';
|
||||
import { useFlashKey } from "@/plugins/useFlash";
|
||||
import useLocationHash from "@/plugins/useLocationHash";
|
||||
|
||||
const ActivityLogContainer = () => {
|
||||
const { hash } = useLocationHash();
|
||||
const { clearAndAddHttpError } = useFlashKey('account');
|
||||
const [filters, setFilters] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedEventType, setSelectedEventType] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [dateRange, setDateRange] = useState('all');
|
||||
const { hash } = useLocationHash();
|
||||
const { clearAndAddHttpError } = useFlashKey("account");
|
||||
const [filters, setFilters] = useState<ActivityLogFilters>({
|
||||
page: 1,
|
||||
sorts: { timestamp: -1 },
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedEventType, setSelectedEventType] = useState("");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [dateRange, setDateRange] = useState("all");
|
||||
|
||||
const { data, isValidating, error } = useActivityLogs(filters, {
|
||||
revalidateOnMount: true,
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: autoRefresh ? 30000 : 0, // Auto-refresh every 30 seconds
|
||||
});
|
||||
const { data, isValidating, error } = useActivityLogs(filters, {
|
||||
revalidateOnMount: true,
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: autoRefresh ? 30000 : 0, // Auto-refresh every 30 seconds
|
||||
});
|
||||
|
||||
// Extract unique event types for filter dropdown
|
||||
const eventTypes = useMemo(() => {
|
||||
if (!data?.items) return [];
|
||||
const types = [...new Set(data.items.map((item) => item.event))];
|
||||
return types.sort();
|
||||
}, [data?.items]);
|
||||
// Extract unique event types for filter dropdown
|
||||
const eventTypes = useMemo(() => {
|
||||
if (!data?.items) return [];
|
||||
const types = [...new Set(data.items.map((item) => item.event))];
|
||||
return types.sort();
|
||||
}, [data?.items]);
|
||||
|
||||
// Filter data based on search term and event type
|
||||
const filteredData = useMemo(() => {
|
||||
if (!data?.items) return data;
|
||||
// Filter data based on search term and event type
|
||||
const filteredData = useMemo(() => {
|
||||
if (!data?.items) return data;
|
||||
|
||||
let filtered = data.items;
|
||||
let filtered = data.items;
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(
|
||||
(item) =>
|
||||
item.event.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.relationships.actor?.username?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
JSON.stringify(item.properties).toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(
|
||||
(item) =>
|
||||
item.event.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.relationships.actor?.username
|
||||
?.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
JSON.stringify(item.properties)
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedEventType) {
|
||||
filtered = filtered.filter((item) => item.event === selectedEventType);
|
||||
}
|
||||
if (selectedEventType) {
|
||||
filtered = filtered.filter((item) => item.event === selectedEventType);
|
||||
}
|
||||
|
||||
// Apply date range filtering
|
||||
if (dateRange !== 'all') {
|
||||
const now = new Date();
|
||||
const cutoff = new Date();
|
||||
// Apply date range filtering
|
||||
if (dateRange !== "all") {
|
||||
const now = new Date();
|
||||
const cutoff = new Date();
|
||||
|
||||
switch (dateRange) {
|
||||
case '1h':
|
||||
cutoff.setHours(now.getHours() - 1);
|
||||
break;
|
||||
case '24h':
|
||||
cutoff.setDate(now.getDate() - 1);
|
||||
break;
|
||||
case '7d':
|
||||
cutoff.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case '30d':
|
||||
cutoff.setDate(now.getDate() - 30);
|
||||
break;
|
||||
}
|
||||
switch (dateRange) {
|
||||
case "1h":
|
||||
cutoff.setHours(now.getHours() - 1);
|
||||
break;
|
||||
case "24h":
|
||||
cutoff.setDate(now.getDate() - 1);
|
||||
break;
|
||||
case "7d":
|
||||
cutoff.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case "30d":
|
||||
cutoff.setDate(now.getDate() - 30);
|
||||
break;
|
||||
}
|
||||
|
||||
filtered = filtered.filter((item) => new Date(item.timestamp) >= cutoff);
|
||||
}
|
||||
filtered = filtered.filter((item) => new Date(item.timestamp) >= cutoff);
|
||||
}
|
||||
|
||||
return { ...data, items: filtered };
|
||||
}, [data, searchTerm, selectedEventType, dateRange]);
|
||||
return { ...data, items: filtered };
|
||||
}, [data, searchTerm, selectedEventType, dateRange]);
|
||||
|
||||
const exportLogs = () => {
|
||||
if (!filteredData?.items) return;
|
||||
const exportLogs = () => {
|
||||
if (!filteredData?.items) return;
|
||||
|
||||
const csvContent = [
|
||||
['Timestamp', 'Event', 'Actor', 'IP Address', 'Properties'].join(','),
|
||||
...filteredData.items.map((item) =>
|
||||
[
|
||||
new Date(item.timestamp).toISOString(),
|
||||
item.event,
|
||||
item.relationships.actor?.username || 'System',
|
||||
item.ip || '',
|
||||
JSON.stringify(item.properties).replace(/"/g, '""'),
|
||||
]
|
||||
.map((field) => `"${field}"`)
|
||||
.join(','),
|
||||
),
|
||||
].join('\n');
|
||||
const csvContent = [
|
||||
["Timestamp", "Event", "Actor", "IP Address", "Properties"].join(","),
|
||||
...filteredData.items.map((item) =>
|
||||
[
|
||||
new Date(item.timestamp).toISOString(),
|
||||
item.event,
|
||||
item.relationships.actor?.username || "System",
|
||||
item.ip || "",
|
||||
JSON.stringify(item.properties).replace(/"/g, '""'),
|
||||
]
|
||||
.map((field) => `"${field}"`)
|
||||
.join(","),
|
||||
),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `activity-log-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
const blob = new Blob([csvContent], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `activity-log-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setFilters((value) => ({ ...value, filters: {} }));
|
||||
setSearchTerm('');
|
||||
setSelectedEventType('');
|
||||
setDateRange('all');
|
||||
};
|
||||
const clearAllFilters = () => {
|
||||
setFilters((value) => ({ ...value, filters: {} }));
|
||||
setSearchTerm("");
|
||||
setSelectedEventType("");
|
||||
setDateRange("all");
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.filters?.event || filters.filters?.ip || searchTerm || selectedEventType || dateRange !== 'all';
|
||||
const hasActiveFilters =
|
||||
filters.filters?.event ||
|
||||
filters.filters?.ip ||
|
||||
searchTerm ||
|
||||
selectedEventType ||
|
||||
dateRange !== "all";
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case 'f':
|
||||
e.preventDefault();
|
||||
setShowFilters(!showFilters);
|
||||
break;
|
||||
case 'r':
|
||||
e.preventDefault();
|
||||
setAutoRefresh(!autoRefresh);
|
||||
break;
|
||||
case 'e':
|
||||
e.preventDefault();
|
||||
exportLogs();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case "f":
|
||||
e.preventDefault();
|
||||
setShowFilters(!showFilters);
|
||||
break;
|
||||
case "r":
|
||||
e.preventDefault();
|
||||
setAutoRefresh(!autoRefresh);
|
||||
break;
|
||||
case "e":
|
||||
e.preventDefault();
|
||||
exportLogs();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showFilters, autoRefresh]);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [showFilters, autoRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters((value) => ({ ...value, filters: { ip: hash.ip, event: hash.event } }));
|
||||
}, [hash]);
|
||||
useEffect(() => {
|
||||
setFilters((value) => ({
|
||||
...value,
|
||||
filters: { ip: hash.ip, event: hash.event },
|
||||
}));
|
||||
}, [hash]);
|
||||
|
||||
useEffect(() => {
|
||||
clearAndAddHttpError(error);
|
||||
}, [error]);
|
||||
useEffect(() => {
|
||||
clearAndAddHttpError(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Account Activity Log'}>
|
||||
<div className='w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0'>
|
||||
<FlashMessageRender byKey={'account'} />
|
||||
return (
|
||||
<PageContentBlock title={"Account Activity Log"}>
|
||||
<div className="w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0">
|
||||
<FlashMessageRender byKey={"account"} />
|
||||
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
|
||||
style={{
|
||||
animationDelay: '75ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<MainPageHeader title={'Activity Log'}>
|
||||
<div className='flex gap-2 items-center flex-wrap'>
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className='flex items-center gap-2'
|
||||
title='Toggle Filters (Ctrl+F)'
|
||||
>
|
||||
<Funnel width={22} height={22} fill='currentColor' />
|
||||
Filters
|
||||
{hasActiveFilters && <span className='w-2 h-2 bg-blue-500 rounded-full'></span>}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant={autoRefresh ? 'primary' : 'secondary'}
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className='flex items-center gap-2'
|
||||
title='Auto Refresh (Ctrl+R)'
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<Xmark width={22} height={22} fill='currentColor' />
|
||||
) : (
|
||||
<Magnifier width={22} height={22} fill='currentColor' />
|
||||
)}
|
||||
{autoRefresh ? 'Live' : 'Refresh'}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
onClick={exportLogs}
|
||||
disabled={!filteredData?.items?.length}
|
||||
className='flex items-center gap-2'
|
||||
title='Export CSV (Ctrl+E)'
|
||||
>
|
||||
<ArrowDownToLine width={22} height={22} fill='currentColor' />
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
</MainPageHeader>
|
||||
</div>
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2 mb-3 sm:mb-4"
|
||||
style={{
|
||||
animationDelay: "75ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<MainPageHeader title={"Activity Log"}>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2"
|
||||
title="Toggle Filters (Ctrl+F)"
|
||||
>
|
||||
<Funnel width={22} height={22} fill="currentColor" />
|
||||
Filters
|
||||
{hasActiveFilters && (
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
)}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant={autoRefresh ? "primary" : "secondary"}
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="flex items-center gap-2"
|
||||
title="Auto Refresh (Ctrl+R)"
|
||||
>
|
||||
{autoRefresh ? (
|
||||
<Xmark width={22} height={22} fill="currentColor" />
|
||||
) : (
|
||||
<Magnifier width={22} height={22} fill="currentColor" />
|
||||
)}
|
||||
{autoRefresh ? "Live" : "Refresh"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={exportLogs}
|
||||
disabled={!filteredData?.items?.length}
|
||||
className="flex items-center gap-2"
|
||||
title="Export CSV (Ctrl+E)"
|
||||
>
|
||||
<ArrowDownToLine width={22} height={22} fill="currentColor" />
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
</MainPageHeader>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
|
||||
style={{
|
||||
animationDelay: '100ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm'>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<div className='w-5 h-5 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
|
||||
<Funnel width={22} height={22} className='text-zinc-400' fill='currentColor' />
|
||||
</div>
|
||||
<h3 className='text-base font-semibold text-zinc-100'>Filters</h3>
|
||||
</div>
|
||||
{showFilters && (
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2 mb-3 sm:mb-4"
|
||||
style={{
|
||||
animationDelay: "100ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<div className="bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-5 h-5 rounded-lg bg-[#ffffff11] flex items-center justify-center">
|
||||
<Funnel
|
||||
width={22}
|
||||
height={22}
|
||||
className="text-zinc-400"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-zinc-100">
|
||||
Filters
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-zinc-300 mb-2'>Search</label>
|
||||
<div className='relative'>
|
||||
<Magnifier
|
||||
width={22}
|
||||
height={22}
|
||||
className='absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-zinc-400 pointer-events-none z-10'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<Input.Text
|
||||
type='text'
|
||||
placeholder='Search events, IPs, users...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ paddingLeft: '2.5rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-2">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Magnifier
|
||||
width={22}
|
||||
height={22}
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-zinc-400 pointer-events-none z-10"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Input.Text
|
||||
type="text"
|
||||
placeholder="Search events, IPs, users..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ paddingLeft: "2.5rem" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-zinc-300 mb-2'>Event Type</label>
|
||||
<Select
|
||||
value={selectedEventType}
|
||||
onChange={(e) => setSelectedEventType(e.target.value)}
|
||||
className='w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 hover:border-zinc-500 transition-colors duration-150'
|
||||
>
|
||||
<option value='' style={{ backgroundColor: '#27272a', color: '#f4f4f5' }}>
|
||||
All Events
|
||||
</option>
|
||||
{eventTypes.map((type) => (
|
||||
<option
|
||||
key={type}
|
||||
value={type}
|
||||
style={{ backgroundColor: '#27272a', color: '#f4f4f5' }}
|
||||
>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-2">
|
||||
Event Type
|
||||
</label>
|
||||
<Select
|
||||
value={selectedEventType}
|
||||
onChange={(e) => setSelectedEventType(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 hover:border-zinc-500 transition-colors duration-150"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
style={{ backgroundColor: "#27272a", color: "#f4f4f5" }}
|
||||
>
|
||||
All Events
|
||||
</option>
|
||||
{eventTypes.map((type) => (
|
||||
<option
|
||||
key={type}
|
||||
value={type}
|
||||
style={{ backgroundColor: "#27272a", color: "#f4f4f5" }}
|
||||
>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-zinc-300 mb-2'>Time Range</label>
|
||||
<Select
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(e.target.value)}
|
||||
className='w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 hover:border-zinc-500 transition-colors duration-150'
|
||||
>
|
||||
<option value='all' style={{ backgroundColor: '#27272a', color: '#f4f4f5' }}>
|
||||
All Time
|
||||
</option>
|
||||
<option value='1h' style={{ backgroundColor: '#27272a', color: '#f4f4f5' }}>
|
||||
Last Hour
|
||||
</option>
|
||||
<option value='24h' style={{ backgroundColor: '#27272a', color: '#f4f4f5' }}>
|
||||
Last 24 Hours
|
||||
</option>
|
||||
<option value='7d' style={{ backgroundColor: '#27272a', color: '#f4f4f5' }}>
|
||||
Last 7 Days
|
||||
</option>
|
||||
<option value='30d' style={{ backgroundColor: '#27272a', color: '#f4f4f5' }}>
|
||||
Last 30 Days
|
||||
</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-2">
|
||||
Time Range
|
||||
</label>
|
||||
<Select
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 hover:border-zinc-500 transition-colors duration-150"
|
||||
>
|
||||
<option
|
||||
value="all"
|
||||
style={{ backgroundColor: "#27272a", color: "#f4f4f5" }}
|
||||
>
|
||||
All Time
|
||||
</option>
|
||||
<option
|
||||
value="1h"
|
||||
style={{ backgroundColor: "#27272a", color: "#f4f4f5" }}
|
||||
>
|
||||
Last Hour
|
||||
</option>
|
||||
<option
|
||||
value="24h"
|
||||
style={{ backgroundColor: "#27272a", color: "#f4f4f5" }}
|
||||
>
|
||||
Last 24 Hours
|
||||
</option>
|
||||
<option
|
||||
value="7d"
|
||||
style={{ backgroundColor: "#27272a", color: "#f4f4f5" }}
|
||||
>
|
||||
Last 7 Days
|
||||
</option>
|
||||
<option
|
||||
value="30d"
|
||||
style={{ backgroundColor: "#27272a", color: "#f4f4f5" }}
|
||||
>
|
||||
Last 30 Days
|
||||
</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='flex items-end'>
|
||||
{hasActiveFilters && (
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
onClick={clearAllFilters}
|
||||
className='flex items-center gap-2 w-full'
|
||||
>
|
||||
<Xmark width={22} height={22} fill='currentColor' />
|
||||
Clear All Filters
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end">
|
||||
{hasActiveFilters && (
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={clearAllFilters}
|
||||
className="flex items-center gap-2 w-full"
|
||||
>
|
||||
<Xmark width={22} height={22} fill="currentColor" />
|
||||
Clear All Filters
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '125ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm'>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<div className='w-5 h-5 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
|
||||
<Magnifier width={22} height={22} className=' text-zinc-400' fill='currentColor' />
|
||||
</div>
|
||||
<h3 className='text-base font-semibold text-zinc-100'>Activity Events</h3>
|
||||
{filteredData?.items && (
|
||||
<span className='text-sm text-zinc-400'>
|
||||
({filteredData.items.length} {filteredData.items.length === 1 ? 'event' : 'events'})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2"
|
||||
style={{
|
||||
animationDelay: "125ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<div className="bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-5 h-5 rounded-lg bg-[#ffffff11] flex items-center justify-center">
|
||||
<Magnifier
|
||||
width={22}
|
||||
height={22}
|
||||
className=" text-zinc-400"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-zinc-100">
|
||||
Activity Events
|
||||
</h3>
|
||||
{filteredData?.items && (
|
||||
<span className="text-sm text-zinc-400">
|
||||
({filteredData.items.length}{" "}
|
||||
{filteredData.items.length === 1 ? "event" : "events"})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!data && isValidating ? (
|
||||
<Spinner centered />
|
||||
) : !filteredData?.items?.length ? (
|
||||
<div className='text-center py-12'>
|
||||
<ArrowRotateLeft
|
||||
width={22}
|
||||
height={22}
|
||||
className=' text-zinc-600 mb-4'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<h3 className='text-lg font-semibold text-zinc-300 mb-2'>
|
||||
{hasActiveFilters ? 'No Matching Activity' : 'No Activity Yet'}
|
||||
</h3>
|
||||
<p className='text-sm text-zinc-400 mb-4 max-w-lg mx-auto leading-relaxed'>
|
||||
{hasActiveFilters
|
||||
? "Try adjusting your filters or search terms to find the activity you're looking for."
|
||||
: 'Activity logs will appear here as you use your account. Check back later or perform some actions to see them here.'}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<div className='flex gap-2 justify-center'>
|
||||
<ActionButton variant='secondary' onClick={clearAllFilters}>
|
||||
Clear All Filters
|
||||
</ActionButton>
|
||||
<ActionButton variant='secondary' onClick={() => setShowFilters(true)}>
|
||||
Adjust Filters
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='divide-y divide-zinc-800/30'>
|
||||
{filteredData.items.map((activity) => (
|
||||
<ActivityLogEntry key={activity.id} activity={activity}>
|
||||
{typeof activity.properties.useragent === 'string' && <span></span>}
|
||||
</ActivityLogEntry>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!data && isValidating ? (
|
||||
<Spinner centered />
|
||||
) : !filteredData?.items?.length ? (
|
||||
<div className="text-center py-12">
|
||||
<ArrowRotateLeft
|
||||
width={22}
|
||||
height={22}
|
||||
className=" text-zinc-600 mb-4"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<h3 className="text-lg font-semibold text-zinc-300 mb-2">
|
||||
{hasActiveFilters
|
||||
? "No Matching Activity"
|
||||
: "No Activity Yet"}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400 mb-4 max-w-lg mx-auto leading-relaxed">
|
||||
{hasActiveFilters
|
||||
? "Try adjusting your filters or search terms to find the activity you're looking for."
|
||||
: "Activity logs will appear here as you use your account. Check back later or perform some actions to see them here."}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<div className="flex gap-2 justify-center">
|
||||
<ActionButton variant="secondary" onClick={clearAllFilters}>
|
||||
Clear All Filters
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(true)}
|
||||
>
|
||||
Adjust Filters
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800/30">
|
||||
{filteredData.items.map((activity) => (
|
||||
<ActivityLogEntry key={activity.id} activity={activity}>
|
||||
{typeof activity.properties.useragent === "string" && (
|
||||
<span></span>
|
||||
)}
|
||||
</ActivityLogEntry>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className='mt-4'>
|
||||
<PaginationFooter
|
||||
pagination={data.pagination}
|
||||
onPageSelect={(page) => setFilters((value) => ({ ...value, page }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
{data && (
|
||||
<div className="mt-4">
|
||||
<PaginationFooter
|
||||
pagination={data.pagination}
|
||||
onPageSelect={(page) =>
|
||||
setFilters((value) => ({ ...value, page }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityLogContainer;
|
||||
|
||||
@@ -1,55 +1,68 @@
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStoreState } from "easy-peasy";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import DisableTOTPDialog from '@/components/dashboard/forms/DisableTOTPDialog';
|
||||
import RecoveryTokensDialog from '@/components/dashboard/forms/RecoveryTokensDialog';
|
||||
import SetupTOTPDialog from '@/components/dashboard/forms/SetupTOTPDialog';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import DisableTOTPDialog from "@/components/dashboard/forms/DisableTOTPDialog";
|
||||
import RecoveryTokensDialog from "@/components/dashboard/forms/RecoveryTokensDialog";
|
||||
import SetupTOTPDialog from "@/components/dashboard/forms/SetupTOTPDialog";
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ApplicationStore } from "@/state";
|
||||
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import useFlash from "@/plugins/useFlash";
|
||||
|
||||
const ConfigureTwoFactorForm = () => {
|
||||
const [tokens, setTokens] = useState<string[]>([]);
|
||||
const [visible, setVisible] = useState<'enable' | 'disable' | null>(null);
|
||||
const isEnabled = useStoreState((state: ApplicationStore) => state.user.data!.useTotp);
|
||||
const { clearFlashes } = useFlash();
|
||||
const [tokens, setTokens] = useState<string[]>([]);
|
||||
const [visible, setVisible] = useState<"enable" | "disable" | null>(null);
|
||||
const isEnabled = useStoreState(
|
||||
(state: ApplicationStore) => state.user.data!.useTotp,
|
||||
);
|
||||
const { clearFlashes } = useFlash();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearFlashes('account:two-step');
|
||||
};
|
||||
}, [visible]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearFlashes("account:two-step");
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
const onTokens = (tokens: string[]) => {
|
||||
setTokens(tokens);
|
||||
setVisible(null);
|
||||
};
|
||||
const onTokens = (tokens: string[]) => {
|
||||
setTokens(tokens);
|
||||
setVisible(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='contents'>
|
||||
<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 className={`text-sm`}>
|
||||
{isEnabled
|
||||
? 'Your account is protected by an authenticator app.'
|
||||
: 'You have not configured an authenticator app.'}
|
||||
</p>
|
||||
<div className={`mt-6`}>
|
||||
{isEnabled ? (
|
||||
<ActionButton variant='danger' onClick={() => setVisible('disable')}>
|
||||
Remove Authenticator App
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton variant='primary' onClick={() => setVisible('enable')}>
|
||||
Enable Authenticator App
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="contents">
|
||||
<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 className={`text-sm`}>
|
||||
{isEnabled
|
||||
? "Your account is protected by an authenticator app."
|
||||
: "You have not configured an authenticator app."}
|
||||
</p>
|
||||
<div className={`mt-6`}>
|
||||
{isEnabled ? (
|
||||
<ActionButton variant="danger" onClick={() => setVisible("disable")}>
|
||||
Remove Authenticator App
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton variant="primary" onClick={() => setVisible("enable")}>
|
||||
Enable Authenticator App
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigureTwoFactorForm;
|
||||
|
||||
@@ -1,104 +1,118 @@
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||
import { useState } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { object, string } from 'yup';
|
||||
import { Activity02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Actions, useStoreActions } from "easy-peasy";
|
||||
import { Field, Form, Formik, FormikHelpers } from "formik";
|
||||
import { useState } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { object, string } from "yup";
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import ContentBox from '@/components/elements/ContentBox';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import Input from '@/components/elements/Input';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import FlashMessageRender from "@/components/FlashMessageRender";
|
||||
import ApiKeyModal from "@/components/dashboard/ApiKeyModal";
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
import ContentBox from "@/components/elements/ContentBox";
|
||||
import FormikFieldWrapper from "@/components/elements/FormikFieldWrapper";
|
||||
import Input from "@/components/elements/Input";
|
||||
import PageContentBlock from "@/components/elements/PageContentBlock";
|
||||
import SpinnerOverlay from "@/components/elements/SpinnerOverlay";
|
||||
|
||||
import createApiKey from '@/api/account/createApiKey';
|
||||
import { ApiKey } from '@/api/account/getApiKeys';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import createApiKey from "@/api/account/createApiKey";
|
||||
import { ApiKey } from "@/api/account/getApiKeys";
|
||||
import { httpErrorToHuman } from "@/api/http";
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ApplicationStore } from "@/state";
|
||||
|
||||
interface Values {
|
||||
description: string;
|
||||
allowedIps: string;
|
||||
description: string;
|
||||
allowedIps: string;
|
||||
}
|
||||
|
||||
const CreateApiKeyForm = ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const CreateApiKeyForm = ({
|
||||
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);
|
||||
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);
|
||||
});
|
||||
};
|
||||
addError({ key: "account", message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Flash Messages */}
|
||||
<FlashMessageRender byKey='account' />
|
||||
return (
|
||||
<>
|
||||
{/* Flash Messages */}
|
||||
<FlashMessageRender byKey="account" />
|
||||
|
||||
{/* Modal for API Key */}
|
||||
<ApiKeyModal visible={apiKey.length > 0} onModalDismissed={() => setApiKey('')} apiKey={apiKey} />
|
||||
{/* Modal for API Key */}
|
||||
<ApiKeyModal
|
||||
visible={apiKey.length > 0}
|
||||
onModalDismissed={() => setApiKey("")}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
|
||||
{/* Form for creating API key */}
|
||||
<ContentBox>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ description: '', allowedIps: '' }}
|
||||
validationSchema={object().shape({
|
||||
allowedIps: string(),
|
||||
description: string().required().min(4),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form className='space-y-6'>
|
||||
{/* Show spinner overlay when submitting */}
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
{/* Form for creating API key */}
|
||||
<ContentBox>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ description: "", allowedIps: "" }}
|
||||
validationSchema={object().shape({
|
||||
allowedIps: string(),
|
||||
description: string().required().min(4),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form className="space-y-6">
|
||||
{/* Show spinner overlay when submitting */}
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
|
||||
{/* Description Field */}
|
||||
<FormikFieldWrapper
|
||||
label='Description'
|
||||
name='description'
|
||||
description='A description of this API key.'
|
||||
>
|
||||
<Field name='description' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
{/* Description Field */}
|
||||
<FormikFieldWrapper
|
||||
label="Description"
|
||||
name="description"
|
||||
description="A description of this API key."
|
||||
>
|
||||
<Field name="description" as={Input} className="w-full" />
|
||||
</FormikFieldWrapper>
|
||||
|
||||
{/* Allowed IPs Field */}
|
||||
<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={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
{/* Allowed IPs Field */}
|
||||
<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={Input} className="w-full" />
|
||||
</FormikFieldWrapper>
|
||||
|
||||
{/* Submit Button below form fields */}
|
||||
<div className='flex justify-end mt-6'>
|
||||
<ActionButton type='submit' disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create API Key'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</ContentBox>
|
||||
</>
|
||||
);
|
||||
{/* Submit Button below form fields */}
|
||||
<div className="flex justify-end mt-6">
|
||||
<ActionButton type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create API Key"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</ContentBox>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CreateApiKeyForm.displayName = 'CreateApiKeyForm';
|
||||
CreateApiKeyForm.displayName = "CreateApiKeyForm";
|
||||
export default CreateApiKeyForm;
|
||||
|
||||
@@ -1,85 +1,88 @@
|
||||
// FIXME: replace with radix tooltip
|
||||
// import Tooltip from '@/components/elements/tooltip/Tooltip';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
|
||||
import { Input } from '@/components/elements/inputs';
|
||||
import FlashMessageRender from "@/components/FlashMessageRender";
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
import { Dialog, DialogWrapperContext } from "@/components/elements/dialog";
|
||||
import { Input } from "@/components/elements/inputs";
|
||||
|
||||
import asDialog from '@/hoc/asDialog';
|
||||
import asDialog from "@/hoc/asDialog";
|
||||
|
||||
import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor';
|
||||
import disableAccountTwoFactor from "@/api/account/disableAccountTwoFactor";
|
||||
|
||||
import { useStoreActions } from '@/state/hooks';
|
||||
import { useStoreActions } from "@/state/hooks";
|
||||
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
import { useFlashKey } from "@/plugins/useFlash";
|
||||
|
||||
const DisableTOTPDialog = () => {
|
||||
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);
|
||||
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]);
|
||||
useEffect(() => {
|
||||
setProps((state) => ({ ...state, preventExternalClose: submitting }));
|
||||
}, [submitting]);
|
||||
|
||||
const submit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const submit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (submitting) return;
|
||||
if (submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
clearAndAddHttpError();
|
||||
disableAccountTwoFactor(password)
|
||||
.then(() => {
|
||||
updateUserData({ useTotp: false });
|
||||
close();
|
||||
})
|
||||
.catch(clearAndAddHttpError)
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
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'} />
|
||||
<label className={'block pb-1'} htmlFor={'totp-password'}>
|
||||
Password
|
||||
</label>
|
||||
<Input.Text
|
||||
id={'totp-password'}
|
||||
type={'password'}
|
||||
variant={Input.Text.Variants.Loose}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<Dialog.Footer>
|
||||
<ActionButton variant='secondary' onClick={close}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
{/* <Tooltip
|
||||
return (
|
||||
<form id={"disable-totp-form"} className={"mt-6"} onSubmit={submit}>
|
||||
<FlashMessageRender byKey={"account:two-step"} />
|
||||
<label className={"block pb-1"} htmlFor={"totp-password"}>
|
||||
Password
|
||||
</label>
|
||||
<Input.Text
|
||||
id={"totp-password"}
|
||||
type={"password"}
|
||||
variant={Input.Text.Variants.Loose}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<Dialog.Footer>
|
||||
<ActionButton variant="secondary" onClick={close}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
{/* <Tooltip
|
||||
delay={100}
|
||||
disabled={password.length > 0}
|
||||
content={'You must enter your account password to continue.'}
|
||||
> */}
|
||||
<ActionButton
|
||||
variant='danger'
|
||||
type={'submit'}
|
||||
form={'disable-totp-form'}
|
||||
disabled={submitting || !password.length}
|
||||
>
|
||||
Disable
|
||||
</ActionButton>
|
||||
{/* </Tooltip> */}
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
);
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
type={"submit"}
|
||||
form={"disable-totp-form"}
|
||||
disabled={submitting || !password.length}
|
||||
>
|
||||
Disable
|
||||
</ActionButton>
|
||||
{/* </Tooltip> */}
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default asDialog({
|
||||
title: 'Remove Authenticator App',
|
||||
description: 'Removing your authenticator app will make your account less secure.',
|
||||
title: "Remove Authenticator App",
|
||||
description:
|
||||
"Removing your authenticator app will make your account less secure.",
|
||||
})(DisableTOTPDialog);
|
||||
|
||||
@@ -1,54 +1,58 @@
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import { Alert } from '@/components/elements/alert';
|
||||
import { Dialog, DialogProps } from '@/components/elements/dialog';
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
import CopyOnClick from "@/components/elements/CopyOnClick";
|
||||
import { Alert } from "@/components/elements/alert";
|
||||
import { Dialog, DialogProps } from "@/components/elements/dialog";
|
||||
|
||||
interface RecoveryTokenDialogProps extends DialogProps {
|
||||
tokens: string[];
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
const RecoveryTokensDialog = ({ tokens, open, onClose }: RecoveryTokenDialogProps) => {
|
||||
const grouped = [] as [string, string][];
|
||||
tokens.forEach((token, index) => {
|
||||
if (index % 2 === 0) {
|
||||
grouped.push([token, tokens[index + 1] || '']);
|
||||
}
|
||||
});
|
||||
const RecoveryTokensDialog = ({
|
||||
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={'Authenticator App Enabled'}
|
||||
description={
|
||||
'Store the codes below somewhere safe. If you lose access to your authenticator app 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-zinc-800 rounded-sm p-2 mt-6'}>
|
||||
{grouped.map((value) => (
|
||||
<span key={value.join('_')} className={'block'}>
|
||||
{value[0]}
|
||||
<span className={'mx-2 selection:bg-zinc-800'}> </span>
|
||||
{value[1]}
|
||||
<span className={'selection:bg-zinc-800'}> </span>
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
</CopyOnClick>
|
||||
<Alert type={'danger'} className={'mt-3'}>
|
||||
These codes will not be shown again.
|
||||
</Alert>
|
||||
<Dialog.Footer>
|
||||
<ActionButton variant='primary' onClick={onClose}>
|
||||
Done
|
||||
</ActionButton>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={"Authenticator App Enabled"}
|
||||
description={
|
||||
"Store the codes below somewhere safe. If you lose access to your authenticator app 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-zinc-800 rounded-sm p-2 mt-6"}>
|
||||
{grouped.map((value) => (
|
||||
<span key={value.join("_")} className={"block"}>
|
||||
{value[0]}
|
||||
<span className={"mx-2 selection:bg-zinc-800"}> </span>
|
||||
{value[1]}
|
||||
<span className={"selection:bg-zinc-800"}> </span>
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
</CopyOnClick>
|
||||
<Alert type={"danger"} className={"mt-3"}>
|
||||
These codes will not be shown again.
|
||||
</Alert>
|
||||
<Dialog.Footer>
|
||||
<ActionButton variant="primary" onClick={onClose}>
|
||||
Done
|
||||
</ActionButton>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecoveryTokensDialog;
|
||||
|
||||
@@ -1,114 +1,125 @@
|
||||
// FIXME: replace with radix tooltip
|
||||
// import Tooltip from '@/components/elements/tooltip/Tooltip';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { Actions, useStoreActions } from "easy-peasy";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
|
||||
import { Input } from '@/components/elements/inputs';
|
||||
import FlashMessageRender from "@/components/FlashMessageRender";
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
import CopyOnClick from "@/components/elements/CopyOnClick";
|
||||
import Spinner from "@/components/elements/Spinner";
|
||||
import { Dialog, DialogWrapperContext } from "@/components/elements/dialog";
|
||||
import { Input } from "@/components/elements/inputs";
|
||||
|
||||
import asDialog from '@/hoc/asDialog';
|
||||
import asDialog from "@/hoc/asDialog";
|
||||
|
||||
import enableAccountTwoFactor from '@/api/account/enableAccountTwoFactor';
|
||||
import getTwoFactorTokenData, { TwoFactorTokenData } from '@/api/account/getTwoFactorTokenData';
|
||||
import enableAccountTwoFactor from "@/api/account/enableAccountTwoFactor";
|
||||
import getTwoFactorTokenData, {
|
||||
TwoFactorTokenData,
|
||||
} from "@/api/account/getTwoFactorTokenData";
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ApplicationStore } from "@/state";
|
||||
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
import { useFlashKey } from "@/plugins/useFlash";
|
||||
|
||||
interface Props {
|
||||
onTokens: (tokens: string[]) => void;
|
||||
onTokens: (tokens: string[]) => void;
|
||||
}
|
||||
|
||||
const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
|
||||
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 [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);
|
||||
const { close, setProps } = useContext(DialogWrapperContext);
|
||||
|
||||
useEffect(() => {
|
||||
getTwoFactorTokenData()
|
||||
.then(setToken)
|
||||
.catch((error) => clearAndAddHttpError(error));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
getTwoFactorTokenData()
|
||||
.then(setToken)
|
||||
.catch((error) => clearAndAddHttpError(error));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setProps((state) => ({ ...state, preventExternalClose: submitting }));
|
||||
}, [submitting]);
|
||||
useEffect(() => {
|
||||
setProps((state) => ({ ...state, preventExternalClose: submitting }));
|
||||
}, [submitting]);
|
||||
|
||||
const submit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const submit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (submitting) return;
|
||||
if (submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
clearAndAddHttpError();
|
||||
enableAccountTwoFactor(value, password)
|
||||
.then((tokens) => {
|
||||
updateUserData({ useTotp: true });
|
||||
onTokens(tokens);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearAndAddHttpError(error);
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
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'} />
|
||||
<div className={'flex items-center justify-center w-56 h-56 p-2 bg-zinc-50 shadow-sm mx-auto mt-6'}>
|
||||
{!token ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<QRCodeSVG value={token.image_url_data} className={`w-full h-full shadow-none`} />
|
||||
)}
|
||||
</div>
|
||||
<CopyOnClick text={token?.secret}>
|
||||
<p className={'font-mono text-sm text-zinc-100 text-center mt-2'}>
|
||||
{token?.secret.match(/.{1,4}/g)!.join(' ') || 'Loading...'}
|
||||
</p>
|
||||
</CopyOnClick>
|
||||
<p id={'totp-code-description'} className={'mt-6'}>
|
||||
Scan the QR code above using an authenticator app, or enter the secret code above. Then, enter the
|
||||
6-digit code it generates below.
|
||||
</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'}>
|
||||
Account Password
|
||||
</label>
|
||||
<Input.Text
|
||||
variant={Input.Text.Variants.Loose}
|
||||
className={'mt-1'}
|
||||
type={'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<Dialog.Footer>
|
||||
<ActionButton variant='secondary' onClick={close}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
{/* <Tooltip
|
||||
return (
|
||||
<form id={"enable-totp-form"} onSubmit={submit}>
|
||||
<FlashMessageRender byKey={"account:two-step"} />
|
||||
<div
|
||||
className={
|
||||
"flex items-center justify-center w-56 h-56 p-2 bg-zinc-50 shadow-sm mx-auto mt-6"
|
||||
}
|
||||
>
|
||||
{!token ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<QRCodeSVG
|
||||
value={token.image_url_data}
|
||||
className={`w-full h-full shadow-none`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<CopyOnClick text={token?.secret}>
|
||||
<p className={"font-mono text-sm text-zinc-100 text-center mt-2"}>
|
||||
{token?.secret.match(/.{1,4}/g)!.join(" ") || "Loading..."}
|
||||
</p>
|
||||
</CopyOnClick>
|
||||
<p id={"totp-code-description"} className={"mt-6"}>
|
||||
Scan the QR code above using an authenticator app, or enter the secret
|
||||
code above. Then, enter the 6-digit code it generates below.
|
||||
</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"}>
|
||||
Account Password
|
||||
</label>
|
||||
<Input.Text
|
||||
variant={Input.Text.Variants.Loose}
|
||||
className={"mt-1"}
|
||||
type={"password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
<Dialog.Footer>
|
||||
<ActionButton variant="secondary" onClick={close}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
{/* <Tooltip
|
||||
disabled={password.length > 0 && value.length === 6}
|
||||
content={
|
||||
!token
|
||||
@@ -117,21 +128,22 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
|
||||
}
|
||||
delay={100}
|
||||
> */}
|
||||
<ActionButton
|
||||
variant='primary'
|
||||
disabled={!token || value.length !== 6 || !password.length}
|
||||
type={'submit'}
|
||||
form={'enable-totp-form'}
|
||||
>
|
||||
Enable
|
||||
</ActionButton>
|
||||
{/* </Tooltip> */}
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
);
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
disabled={!token || value.length !== 6 || !password.length}
|
||||
type={"submit"}
|
||||
form={"enable-totp-form"}
|
||||
>
|
||||
Enable
|
||||
</ActionButton>
|
||||
{/* </Tooltip> */}
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default asDialog({
|
||||
title: 'Enable Authenticator App',
|
||||
description: "You'll be required to enter a verification code each time you sign in.",
|
||||
title: "Enable Authenticator App",
|
||||
description:
|
||||
"You'll be required to enter a verification code each time you sign in.",
|
||||
})(ConfigureTwoFactorForm);
|
||||
|
||||
@@ -1,77 +1,105 @@
|
||||
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { Fragment } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import { Actions, State, useStoreActions, useStoreState } from "easy-peasy";
|
||||
import { Form, Formik, FormikHelpers } from "formik";
|
||||
import { Fragment } from "react";
|
||||
import * as Yup from "yup";
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Field from '@/components/elements/Field';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
import Field from "@/components/elements/Field";
|
||||
import SpinnerOverlay from "@/components/elements/SpinnerOverlay";
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import { httpErrorToHuman } from "@/api/http";
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ApplicationStore } from "@/state";
|
||||
|
||||
interface Values {
|
||||
email: string;
|
||||
password: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const schema = Yup.object().shape({
|
||||
email: Yup.string().email().required(),
|
||||
password: Yup.string().required('You must provide your current account password.'),
|
||||
email: Yup.string().email().required(),
|
||||
password: Yup.string().required(
|
||||
"You must provide your current account password.",
|
||||
),
|
||||
});
|
||||
|
||||
const UpdateEmailAddressForm = () => {
|
||||
const user = useStoreState((state: State<ApplicationStore>) => state.user.data);
|
||||
const updateEmail = useStoreActions((state: Actions<ApplicationStore>) => state.user.updateUserEmail);
|
||||
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 { clearFlashes, addFlash } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||
);
|
||||
|
||||
const submit = (values: Values, { resetForm, setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('account:email');
|
||||
const submit = (
|
||||
values: Values,
|
||||
{ resetForm, setSubmitting }: FormikHelpers<Values>,
|
||||
) => {
|
||||
clearFlashes("account:email");
|
||||
|
||||
updateEmail({ ...values })
|
||||
.then(() =>
|
||||
addFlash({
|
||||
type: 'success',
|
||||
key: 'account:email',
|
||||
message: 'Your primary email has been updated.',
|
||||
}),
|
||||
)
|
||||
.catch((error) =>
|
||||
addFlash({
|
||||
type: 'error',
|
||||
key: 'account:email',
|
||||
title: 'Error',
|
||||
message: httpErrorToHuman(error),
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
updateEmail({ ...values })
|
||||
.then(() =>
|
||||
addFlash({
|
||||
type: "success",
|
||||
key: "account:email",
|
||||
message: "Your primary email has been updated.",
|
||||
}),
|
||||
)
|
||||
.catch((error) =>
|
||||
addFlash({
|
||||
type: "error",
|
||||
key: "account:email",
|
||||
title: "Error",
|
||||
message: httpErrorToHuman(error),
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik onSubmit={submit} validationSchema={schema} initialValues={{ email: user!.email, password: '' }}>
|
||||
{({ isSubmitting, isValid }) => (
|
||||
<Fragment>
|
||||
<SpinnerOverlay size={'large'} visible={isSubmitting} />
|
||||
<Form className={`m-0`}>
|
||||
<Field id={'current_email'} type={'email'} name={'email'} label={'Email'} />
|
||||
<div className={`mt-6`}>
|
||||
<Field id={'confirm_password'} type={'password'} name={'password'} label={'Password'} />
|
||||
</div>
|
||||
<div className={`mt-6`}>
|
||||
<ActionButton variant='primary' disabled={isSubmitting || !isValid}>
|
||||
Update Email
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
validationSchema={schema}
|
||||
initialValues={{ email: user!.email, password: "" }}
|
||||
>
|
||||
{({ isSubmitting, isValid }) => (
|
||||
<Fragment>
|
||||
<SpinnerOverlay size={"large"} visible={isSubmitting} />
|
||||
<Form className={`m-0`}>
|
||||
<Field
|
||||
id={"current_email"}
|
||||
type={"email"}
|
||||
name={"email"}
|
||||
label={"Email"}
|
||||
/>
|
||||
<div className={`mt-6`}>
|
||||
<Field
|
||||
id={"confirm_password"}
|
||||
type={"password"}
|
||||
name={"password"}
|
||||
label={"Password"}
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6`}>
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
Update Email
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateEmailAddressForm;
|
||||
|
||||
@@ -1,110 +1,119 @@
|
||||
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { Fragment } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import { Actions, State, useStoreActions, useStoreState } from "easy-peasy";
|
||||
import { Form, Formik, FormikHelpers } from "formik";
|
||||
import { Fragment } from "react";
|
||||
import * as Yup from "yup";
|
||||
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Field from '@/components/elements/Field';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
import Field from "@/components/elements/Field";
|
||||
import Spinner from "@/components/elements/Spinner";
|
||||
import SpinnerOverlay from "@/components/elements/SpinnerOverlay";
|
||||
|
||||
import updateAccountPassword from '@/api/account/updateAccountPassword';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import updateAccountPassword from "@/api/account/updateAccountPassword";
|
||||
import { httpErrorToHuman } from "@/api/http";
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ApplicationStore } from "@/state";
|
||||
|
||||
interface Values {
|
||||
current: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
current: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
const schema = Yup.object().shape({
|
||||
current: Yup.string().min(1).required('You must provide your current account password.'),
|
||||
password: Yup.string().min(8).required(),
|
||||
confirmPassword: Yup.string().test(
|
||||
'password',
|
||||
'Password confirmation does not match the password you entered.',
|
||||
function (value) {
|
||||
return value === this.parent.password;
|
||||
},
|
||||
),
|
||||
current: Yup.string()
|
||||
.min(1)
|
||||
.required("You must provide your current account password."),
|
||||
password: Yup.string().min(8).required(),
|
||||
confirmPassword: Yup.string().test(
|
||||
"password",
|
||||
"Password confirmation does not match the password you entered.",
|
||||
function (value) {
|
||||
return value === this.parent.password;
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const UpdatePasswordForm = () => {
|
||||
const user = useStoreState((state: State<ApplicationStore>) => state.user.data);
|
||||
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const user = useStoreState(
|
||||
(state: State<ApplicationStore>) => state.user.data,
|
||||
);
|
||||
const { clearFlashes, addFlash } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('account:password');
|
||||
updateAccountPassword({ ...values })
|
||||
.then(() => {
|
||||
// @ts-expect-error this is valid
|
||||
window.location = '/auth/login';
|
||||
})
|
||||
.catch((error) =>
|
||||
addFlash({
|
||||
key: 'account:password',
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: httpErrorToHuman(error),
|
||||
}),
|
||||
)
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes("account:password");
|
||||
updateAccountPassword({ ...values })
|
||||
.then(() => {
|
||||
// @ts-expect-error this is valid
|
||||
window.location = "/auth/login";
|
||||
})
|
||||
.catch((error) =>
|
||||
addFlash({
|
||||
key: "account:password",
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: httpErrorToHuman(error),
|
||||
}),
|
||||
)
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
validationSchema={schema}
|
||||
initialValues={{ current: '', password: '', confirmPassword: '' }}
|
||||
>
|
||||
{({ isSubmitting, isValid }) => (
|
||||
<Fragment>
|
||||
<SpinnerOverlay size={'large'} visible={isSubmitting} />
|
||||
<Form className={`m-0`}>
|
||||
<Field
|
||||
id={'current_password'}
|
||||
type={'password'}
|
||||
name={'current'}
|
||||
label={'Current Password'}
|
||||
/>
|
||||
<div className={`mt-6`}>
|
||||
<Field
|
||||
id={'new_password'}
|
||||
type={'password'}
|
||||
name={'password'}
|
||||
label={'New Password'}
|
||||
description={
|
||||
'Your new password should be at least 8 characters in length and unique to this website.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6`}>
|
||||
<Field
|
||||
id={'confirm_new_password'}
|
||||
type={'password'}
|
||||
name={'confirmPassword'}
|
||||
label={'Confirm New Password'}
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6`}>
|
||||
<ActionButton variant='primary' disabled={isSubmitting || !isValid}>
|
||||
{isSubmitting && <Spinner size='small' />}
|
||||
{isSubmitting ? 'Updating...' : 'Update Password'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</Fragment>
|
||||
);
|
||||
return (
|
||||
<Fragment>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
validationSchema={schema}
|
||||
initialValues={{ current: "", password: "", confirmPassword: "" }}
|
||||
>
|
||||
{({ isSubmitting, isValid }) => (
|
||||
<Fragment>
|
||||
<SpinnerOverlay size={"large"} visible={isSubmitting} />
|
||||
<Form className={`m-0`}>
|
||||
<Field
|
||||
id={"current_password"}
|
||||
type={"password"}
|
||||
name={"current"}
|
||||
label={"Current Password"}
|
||||
/>
|
||||
<div className={`mt-6`}>
|
||||
<Field
|
||||
id={"new_password"}
|
||||
type={"password"}
|
||||
name={"password"}
|
||||
label={"New Password"}
|
||||
description={
|
||||
"Your new password should be at least 8 characters in length and unique to this website."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6`}>
|
||||
<Field
|
||||
id={"confirm_new_password"}
|
||||
type={"password"}
|
||||
name={"confirmPassword"}
|
||||
label={"Confirm New Password"}
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6`}>
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting && <Spinner size="small" />}
|
||||
{isSubmitting ? "Updating..." : "Update Password"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Fragment>
|
||||
)}
|
||||
</Formik>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatePasswordForm;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HeaderCentered = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className='xl:absolute xl:right-0 xl:translate-x-1/2 xl:left-0 xl:w-auto w-full'>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full w-full xl:w-fit xl:absolute xl:-translate-x-1/2 xl:-translate-y-1/2 xl:top-1/2 flex items-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const HeaderCentered = ({ children, className = "" }) => {
|
||||
return (
|
||||
<div className="xl:absolute xl:right-0 xl:translate-x-1/2 xl:left-0 xl:w-auto w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full xl:w-fit xl:absolute xl:-translate-x-1/2 xl:-translate-y-1/2 xl:top-1/2 flex items-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderCentered;
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import { Search01Icon } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { memo, useState } from 'react';
|
||||
import { Search01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { KeyboardShortcut } from '@/components/ui/keyboard-shortcut';
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { KeyboardShortcut } from "@/components/ui/keyboard-shortcut";
|
||||
|
||||
const SearchIcon = memo(() => (
|
||||
<HugeiconsIcon
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
icon={Search01Icon}
|
||||
className='absolute top-1/2 left-4 -translate-y-1/2 transform text-cream-500/30'
|
||||
/>
|
||||
<HugeiconsIcon
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
icon={Search01Icon}
|
||||
className="absolute top-1/2 left-4 -translate-y-1/2 transform text-cream-500/30"
|
||||
/>
|
||||
));
|
||||
SearchIcon.displayName = 'SearchIcon';
|
||||
SearchIcon.displayName = "SearchIcon";
|
||||
|
||||
interface SearchSectionProps {
|
||||
className?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SearchSection = memo(({ className }: SearchSectionProps) => {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 h-full ${className || ''}`}>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
id='header-search'
|
||||
type='text'
|
||||
placeholder='Search servers...'
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
setSearchValue(e.target.value);
|
||||
}}
|
||||
className='pl-10 pr-16'
|
||||
/>
|
||||
<SearchIcon />
|
||||
{!searchValue && (
|
||||
<div className='absolute top-1/2 right-4 -translate-y-1/2 transform flex align-middle pointer-events-none'>
|
||||
<KeyboardShortcut keys={['cmd', 'k']} variant='faded' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={`flex items-center gap-2 h-full ${className || ""}`}>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
id="header-search"
|
||||
type="text"
|
||||
placeholder="Search servers..."
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
setSearchValue(e.target.value);
|
||||
}}
|
||||
className="pl-10 pr-16"
|
||||
/>
|
||||
<SearchIcon />
|
||||
{!searchValue && (
|
||||
<div className="absolute top-1/2 right-4 -translate-y-1/2 transform flex align-middle pointer-events-none">
|
||||
<KeyboardShortcut keys={["cmd", "k"]} variant="faded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SearchSection.displayName = 'SearchSection';
|
||||
SearchSection.displayName = "SearchSection";
|
||||
|
||||
export default SearchSection;
|
||||
|
||||
@@ -1,268 +1,307 @@
|
||||
import { Eye, EyeSlash, Key, Plus, TrashBin } from '@gravity-ui/icons';
|
||||
import { format } from 'date-fns';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { object, string } from 'yup';
|
||||
import { Eye, EyeSlash, Key, Plus, TrashBin } from "@gravity-ui/icons";
|
||||
import { format } from "date-fns";
|
||||
import { Actions, useStoreActions } from "easy-peasy";
|
||||
import { Field, Form, Formik, FormikHelpers } from "formik";
|
||||
import { useEffect, useState } from "react";
|
||||
import { object, string } from "yup";
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import Code from '@/components/elements/Code';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import Input from '@/components/elements/Input';
|
||||
import { MainPageHeader } from '@/components/elements/MainPageHeader';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
import FlashMessageRender from "@/components/FlashMessageRender";
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
import Code from "@/components/elements/Code";
|
||||
import FormikFieldWrapper from "@/components/elements/FormikFieldWrapper";
|
||||
import Input from "@/components/elements/Input";
|
||||
import { MainPageHeader } from "@/components/elements/MainPageHeader";
|
||||
import PageContentBlock from "@/components/elements/PageContentBlock";
|
||||
import SpinnerOverlay from "@/components/elements/SpinnerOverlay";
|
||||
import { Dialog } from "@/components/elements/dialog";
|
||||
|
||||
import { createSSHKey, deleteSSHKey, useSSHKeys } from '@/api/account/ssh-keys';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import { createSSHKey, deleteSSHKey, useSSHKeys } from "@/api/account/ssh-keys";
|
||||
import { httpErrorToHuman } from "@/api/http";
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ApplicationStore } from "@/state";
|
||||
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
import { useFlashKey } from "@/plugins/useFlash";
|
||||
|
||||
interface CreateValues {
|
||||
name: string;
|
||||
publicKey: string;
|
||||
name: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
const AccountSSHContainer = () => {
|
||||
const [deleteKey, setDeleteKey] = useState<{ name: string; fingerprint: string } | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
const [deleteKey, setDeleteKey] = useState<{
|
||||
name: string;
|
||||
fingerprint: string;
|
||||
} | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { clearAndAddHttpError } = useFlashKey('account:ssh-keys');
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const { data, isValidating, error, mutate } = useSSHKeys({
|
||||
revalidateOnMount: true,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { clearAndAddHttpError } = useFlashKey("account:ssh-keys");
|
||||
const { addError, clearFlashes } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||
);
|
||||
const { data, isValidating, error, mutate } = useSSHKeys({
|
||||
revalidateOnMount: true,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
clearAndAddHttpError(error);
|
||||
}, [error]);
|
||||
useEffect(() => {
|
||||
clearAndAddHttpError(error);
|
||||
}, [error]);
|
||||
|
||||
const doDeletion = () => {
|
||||
if (!deleteKey) return;
|
||||
const doDeletion = () => {
|
||||
if (!deleteKey) return;
|
||||
|
||||
clearAndAddHttpError();
|
||||
Promise.all([
|
||||
mutate((data) => data?.filter((value) => value.fingerprint !== deleteKey.fingerprint), false),
|
||||
deleteSSHKey(deleteKey.fingerprint),
|
||||
])
|
||||
.catch((error) => {
|
||||
mutate(undefined, true).catch(console.error);
|
||||
clearAndAddHttpError(error);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeleteKey(null);
|
||||
});
|
||||
};
|
||||
clearAndAddHttpError();
|
||||
Promise.all([
|
||||
mutate(
|
||||
(data) =>
|
||||
data?.filter((value) => value.fingerprint !== deleteKey.fingerprint),
|
||||
false,
|
||||
),
|
||||
deleteSSHKey(deleteKey.fingerprint),
|
||||
])
|
||||
.catch((error) => {
|
||||
mutate(undefined, true).catch(console.error);
|
||||
clearAndAddHttpError(error);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeleteKey(null);
|
||||
});
|
||||
};
|
||||
|
||||
const submitCreate = (values: CreateValues, { setSubmitting, resetForm }: FormikHelpers<CreateValues>) => {
|
||||
clearFlashes('account:ssh-keys');
|
||||
createSSHKey(values.name, values.publicKey)
|
||||
.then((key) => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
mutate((data) => (data || []).concat(key));
|
||||
setShowCreateModal(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: 'account:ssh-keys', message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
const submitCreate = (
|
||||
values: CreateValues,
|
||||
{ setSubmitting, resetForm }: FormikHelpers<CreateValues>,
|
||||
) => {
|
||||
clearFlashes("account:ssh-keys");
|
||||
createSSHKey(values.name, values.publicKey)
|
||||
.then((key) => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
mutate((data) => (data || []).concat(key));
|
||||
setShowCreateModal(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: "account:ssh-keys", message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKeyVisibility = (fingerprint: string) => {
|
||||
setShowKeys((prev) => ({
|
||||
...prev,
|
||||
[fingerprint]: !prev[fingerprint],
|
||||
}));
|
||||
};
|
||||
const toggleKeyVisibility = (fingerprint: string) => {
|
||||
setShowKeys((prev) => ({
|
||||
...prev,
|
||||
[fingerprint]: !prev[fingerprint],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'SSH Keys'}>
|
||||
<FlashMessageRender byKey='account:ssh-keys' />
|
||||
return (
|
||||
<PageContentBlock title={"SSH Keys"}>
|
||||
<FlashMessageRender byKey="account:ssh-keys" />
|
||||
|
||||
{/* Create SSH Key Modal */}
|
||||
{showCreateModal && (
|
||||
<Dialog.Confirm
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title='Add SSH Key'
|
||||
confirm='Add Key'
|
||||
onConfirmed={() => {
|
||||
const form = document.getElementById('create-ssh-form') as HTMLFormElement;
|
||||
if (form) {
|
||||
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
if (submitButton) submitButton.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Formik
|
||||
onSubmit={submitCreate}
|
||||
initialValues={{ name: '', publicKey: '' }}
|
||||
validationSchema={object().shape({
|
||||
name: string().required('SSH Key Name is required'),
|
||||
publicKey: string().required('Public Key is required'),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form id='create-ssh-form' className='space-y-4'>
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
{/* Create SSH Key Modal */}
|
||||
{showCreateModal && (
|
||||
<Dialog.Confirm
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
title="Add SSH Key"
|
||||
confirm="Add Key"
|
||||
onConfirmed={() => {
|
||||
const form = document.getElementById(
|
||||
"create-ssh-form",
|
||||
) as HTMLFormElement;
|
||||
if (form) {
|
||||
const submitButton = form.querySelector(
|
||||
'button[type="submit"]',
|
||||
) as HTMLButtonElement;
|
||||
if (submitButton) submitButton.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Formik
|
||||
onSubmit={submitCreate}
|
||||
initialValues={{ name: "", publicKey: "" }}
|
||||
validationSchema={object().shape({
|
||||
name: string().required("SSH Key Name is required"),
|
||||
publicKey: string().required("Public Key is required"),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form id="create-ssh-form" className="space-y-4">
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
|
||||
<FormikFieldWrapper
|
||||
label='SSH Key Name'
|
||||
name='name'
|
||||
description='A name to identify this SSH key.'
|
||||
>
|
||||
<Field name='name' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
<FormikFieldWrapper
|
||||
label="SSH Key Name"
|
||||
name="name"
|
||||
description="A name to identify this SSH key."
|
||||
>
|
||||
<Field name="name" as={Input} className="w-full" />
|
||||
</FormikFieldWrapper>
|
||||
|
||||
<FormikFieldWrapper
|
||||
label='Public Key'
|
||||
name='publicKey'
|
||||
description='Enter your public SSH key.'
|
||||
>
|
||||
<Field name='publicKey' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
<FormikFieldWrapper
|
||||
label="Public Key"
|
||||
name="publicKey"
|
||||
description="Enter your public SSH key."
|
||||
>
|
||||
<Field name="publicKey" as={Input} className="w-full" />
|
||||
</FormikFieldWrapper>
|
||||
|
||||
<button type='submit' className='hidden' />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog.Confirm>
|
||||
)}
|
||||
<button type="submit" className="hidden" />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Dialog.Confirm>
|
||||
)}
|
||||
|
||||
<div className='w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0'>
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
|
||||
style={{
|
||||
animationDelay: '50ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<MainPageHeader
|
||||
title='SSH Keys'
|
||||
titleChildren={
|
||||
<ActionButton
|
||||
variant='primary'
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<Plus width={22} height={22} fill='currentColor' />
|
||||
Add SSH Key
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0">
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2 mb-3 sm:mb-4"
|
||||
style={{
|
||||
animationDelay: "50ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<MainPageHeader
|
||||
title="SSH Keys"
|
||||
titleChildren={
|
||||
<ActionButton
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus width={22} height={22} fill="currentColor" />
|
||||
Add SSH Key
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: '75ms',
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-4 sm:p-6 shadow-sm'>
|
||||
<SpinnerOverlay visible={!data && isValidating} />
|
||||
<Dialog.Confirm
|
||||
title={'Delete SSH Key'}
|
||||
confirm={'Delete Key'}
|
||||
open={!!deleteKey}
|
||||
onClose={() => setDeleteKey(null)}
|
||||
onConfirmed={doDeletion}
|
||||
>
|
||||
Removing the <Code>{deleteKey?.name}</Code> SSH key will invalidate its usage across the
|
||||
Panel.
|
||||
</Dialog.Confirm>
|
||||
<div
|
||||
className="transform-gpu skeleton-anim-2"
|
||||
style={{
|
||||
animationDelay: "75ms",
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<div className="bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-4 sm:p-6 shadow-sm">
|
||||
<SpinnerOverlay visible={!data && isValidating} />
|
||||
<Dialog.Confirm
|
||||
title={"Delete SSH Key"}
|
||||
confirm={"Delete Key"}
|
||||
open={!!deleteKey}
|
||||
onClose={() => setDeleteKey(null)}
|
||||
onConfirmed={doDeletion}
|
||||
>
|
||||
Removing the <Code>{deleteKey?.name}</Code> SSH key will
|
||||
invalidate its usage across the Panel.
|
||||
</Dialog.Confirm>
|
||||
|
||||
{!data || data.length === 0 ? (
|
||||
<div className='text-center py-12'>
|
||||
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
|
||||
<Key width={22} height={22} className='text-zinc-400' fill='currentColor' />
|
||||
</div>
|
||||
<h3 className='text-lg font-medium text-zinc-200 mb-2'>No SSH Keys</h3>
|
||||
<p className='text-sm text-zinc-400 max-w-sm mx-auto'>
|
||||
{!data
|
||||
? 'Loading your SSH keys...'
|
||||
: "You haven't added any SSH keys yet. Add one to securely access your servers."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{data.map((key, index) => (
|
||||
<div
|
||||
key={key.fingerprint}
|
||||
className='transform-gpu skeleton-anim-2'
|
||||
style={{
|
||||
animationDelay: `${index * 25 + 100}ms`,
|
||||
animationTimingFunction:
|
||||
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
|
||||
}}
|
||||
>
|
||||
<div className='bg-[#ffffff05] border-[1px] border-[#ffffff08] rounded-lg p-4 hover:border-[#ffffff15] transition-all duration-150'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-3 mb-2'>
|
||||
<h4 className='text-sm font-medium text-zinc-100 truncate'>
|
||||
{key.name}
|
||||
</h4>
|
||||
</div>
|
||||
<div className='flex items-center gap-4 text-xs text-zinc-400'>
|
||||
<span>Added: {format(key.createdAt, 'MMM d, yyyy HH:mm')}</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>Fingerprint:</span>
|
||||
<code className='font-mono px-2 py-1 bg-[#ffffff08] border border-[#ffffff08] rounded text-zinc-300'>
|
||||
{showKeys[key.fingerprint]
|
||||
? `SHA256:${key.fingerprint}`
|
||||
: 'SHA256:••••••••••••••••'}
|
||||
</code>
|
||||
<ActionButton
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={() => toggleKeyVisibility(key.fingerprint)}
|
||||
className='p-1 text-zinc-400 hover:text-zinc-300'
|
||||
>
|
||||
{showKeys[key.fingerprint] ? (
|
||||
<EyeSlash
|
||||
width={18}
|
||||
height={18}
|
||||
fill='currentColor'
|
||||
/>
|
||||
) : (
|
||||
<Eye width={18} height={18} fill='currentColor' />
|
||||
)}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant='danger'
|
||||
size='sm'
|
||||
className='ml-4'
|
||||
onClick={() =>
|
||||
setDeleteKey({ name: key.name, fingerprint: key.fingerprint })
|
||||
}
|
||||
>
|
||||
<TrashBin width={20} height={20} fill='currentColor' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
{!data || data.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center">
|
||||
<Key
|
||||
width={22}
|
||||
height={22}
|
||||
className="text-zinc-400"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-200 mb-2">
|
||||
No SSH Keys
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400 max-w-sm mx-auto">
|
||||
{!data
|
||||
? "Loading your SSH keys..."
|
||||
: "You haven't added any SSH keys yet. Add one to securely access your servers."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.map((key, index) => (
|
||||
<div
|
||||
key={key.fingerprint}
|
||||
className="transform-gpu skeleton-anim-2"
|
||||
style={{
|
||||
animationDelay: `${index * 25 + 100}ms`,
|
||||
animationTimingFunction:
|
||||
"linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)",
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ffffff05] border-[1px] border-[#ffffff08] rounded-lg p-4 hover:border-[#ffffff15] transition-all duration-150">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="text-sm font-medium text-zinc-100 truncate">
|
||||
{key.name}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-zinc-400">
|
||||
<span>
|
||||
Added:{" "}
|
||||
{format(key.createdAt, "MMM d, yyyy HH:mm")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Fingerprint:</span>
|
||||
<code className="font-mono px-2 py-1 bg-[#ffffff08] border border-[#ffffff08] rounded text-zinc-300">
|
||||
{showKeys[key.fingerprint]
|
||||
? `SHA256:${key.fingerprint}`
|
||||
: "SHA256:••••••••••••••••"}
|
||||
</code>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
toggleKeyVisibility(key.fingerprint)
|
||||
}
|
||||
className="p-1 text-zinc-400 hover:text-zinc-300"
|
||||
>
|
||||
{showKeys[key.fingerprint] ? (
|
||||
<EyeSlash
|
||||
width={18}
|
||||
height={18}
|
||||
fill="currentColor"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
width={18}
|
||||
height={18}
|
||||
fill="currentColor"
|
||||
/>
|
||||
)}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="ml-4"
|
||||
onClick={() =>
|
||||
setDeleteKey({
|
||||
name: key.name,
|
||||
fingerprint: key.fingerprint,
|
||||
})
|
||||
}
|
||||
>
|
||||
<TrashBin
|
||||
width={20}
|
||||
height={20}
|
||||
fill="currentColor"
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSSHContainer;
|
||||
|
||||
@@ -1,100 +1,105 @@
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||
import { useState } from 'react';
|
||||
import { object, string } from 'yup';
|
||||
import { Actions, useStoreActions } from "easy-peasy";
|
||||
import { Field, Form, Formik, FormikHelpers } from "formik";
|
||||
import { useState } from "react";
|
||||
import { object, string } from "yup";
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ActionButton from '@/components/elements/ActionButton';
|
||||
import ContentBox from '@/components/elements/ContentBox';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import Input from '@/components/elements/Input';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import FlashMessageRender from "@/components/FlashMessageRender";
|
||||
import ActionButton from "@/components/elements/ActionButton";
|
||||
import ContentBox from "@/components/elements/ContentBox";
|
||||
import FormikFieldWrapper from "@/components/elements/FormikFieldWrapper";
|
||||
import Input from "@/components/elements/Input";
|
||||
import SpinnerOverlay from "@/components/elements/SpinnerOverlay";
|
||||
|
||||
import { createSSHKey } from '@/api/account/ssh-keys';
|
||||
import { useSSHKeys } from '@/api/account/ssh-keys';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import { createSSHKey } from "@/api/account/ssh-keys";
|
||||
import { useSSHKeys } from "@/api/account/ssh-keys";
|
||||
import { httpErrorToHuman } from "@/api/http";
|
||||
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { ApplicationStore } from "@/state";
|
||||
|
||||
interface Values {
|
||||
name: string;
|
||||
publicKey: string;
|
||||
name: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
const CreateSSHKeyForm = () => {
|
||||
const [sshKey, setSshKey] = useState('');
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const { mutate } = useSSHKeys();
|
||||
const [sshKey, setSshKey] = useState("");
|
||||
const { addError, clearFlashes } = useStoreActions(
|
||||
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||
);
|
||||
const { mutate } = useSSHKeys();
|
||||
|
||||
const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
|
||||
clearFlashes('ssh-keys');
|
||||
createSSHKey(values.name, values.publicKey)
|
||||
.then((key) => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
setSshKey(`${key.name}`);
|
||||
mutate((data) => (data || []).concat(key)); // Update the list of SSH keys after creation
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: 'ssh-keys', message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
const submit = (
|
||||
values: Values,
|
||||
{ setSubmitting, resetForm }: FormikHelpers<Values>,
|
||||
) => {
|
||||
clearFlashes("ssh-keys");
|
||||
createSSHKey(values.name, values.publicKey)
|
||||
.then((key) => {
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
setSshKey(`${key.name}`);
|
||||
mutate((data) => (data || []).concat(key)); // Update the list of SSH keys after creation
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
addError({ key: "ssh-keys", message: httpErrorToHuman(error) });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Flash Messages */}
|
||||
<FlashMessageRender byKey='account' />
|
||||
return (
|
||||
<>
|
||||
{/* Flash Messages */}
|
||||
<FlashMessageRender byKey="account" />
|
||||
|
||||
{/* Modal for SSH Key */}
|
||||
{/* Add your modal logic here to display the SSH key details after creation */}
|
||||
{/* Modal for SSH Key */}
|
||||
{/* Add your modal logic here to display the SSH key details after creation */}
|
||||
|
||||
{/* Form for creating SSH key */}
|
||||
<ContentBox>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '', publicKey: '' }}
|
||||
validationSchema={object().shape({
|
||||
name: string().required('SSH Key Name is required'),
|
||||
publicKey: string().required('Public Key is required'),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form className='space-y-6'>
|
||||
{/* Show spinner overlay when submitting */}
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
{/* Form for creating SSH key */}
|
||||
<ContentBox>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: "", publicKey: "" }}
|
||||
validationSchema={object().shape({
|
||||
name: string().required("SSH Key Name is required"),
|
||||
publicKey: string().required("Public Key is required"),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form className="space-y-6">
|
||||
{/* Show spinner overlay when submitting */}
|
||||
<SpinnerOverlay visible={isSubmitting} />
|
||||
|
||||
{/* SSH Key Name Field */}
|
||||
<FormikFieldWrapper
|
||||
label='SSH Key Name'
|
||||
name='name'
|
||||
description='A name to identify this SSH key.'
|
||||
>
|
||||
<Field name='name' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
{/* SSH Key Name Field */}
|
||||
<FormikFieldWrapper
|
||||
label="SSH Key Name"
|
||||
name="name"
|
||||
description="A name to identify this SSH key."
|
||||
>
|
||||
<Field name="name" as={Input} className="w-full" />
|
||||
</FormikFieldWrapper>
|
||||
|
||||
{/* Public Key Field */}
|
||||
<FormikFieldWrapper
|
||||
label='Public Key'
|
||||
name='publicKey'
|
||||
description='Enter your public SSH key.'
|
||||
>
|
||||
<Field name='publicKey' as={Input} className='w-full' />
|
||||
</FormikFieldWrapper>
|
||||
{/* Public Key Field */}
|
||||
<FormikFieldWrapper
|
||||
label="Public Key"
|
||||
name="publicKey"
|
||||
description="Enter your public SSH key."
|
||||
>
|
||||
<Field name="publicKey" as={Input} className="w-full" />
|
||||
</FormikFieldWrapper>
|
||||
|
||||
{/* Submit Button below form fields */}
|
||||
<div className='flex justify-end mt-6'>
|
||||
<ActionButton type='submit' disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create SSH Key'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</ContentBox>
|
||||
</>
|
||||
);
|
||||
{/* Submit Button below form fields */}
|
||||
<div className="flex justify-end mt-6">
|
||||
<ActionButton type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create SSH Key"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</ContentBox>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateSSHKeyForm;
|
||||
|
||||
@@ -1,50 +1,60 @@
|
||||
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useState } from 'react';
|
||||
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useState } from "react";
|
||||
|
||||
import Code from '@/components/elements/Code';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
import Code from "@/components/elements/Code";
|
||||
import { Dialog } from "@/components/elements/dialog";
|
||||
|
||||
import { deleteSSHKey, useSSHKeys } from '@/api/account/ssh-keys';
|
||||
import { deleteSSHKey, useSSHKeys } from "@/api/account/ssh-keys";
|
||||
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
import { useFlashKey } from "@/plugins/useFlash";
|
||||
|
||||
const DeleteSSHKeyButton = ({ name, fingerprint }: { name: string; fingerprint: string }) => {
|
||||
const { clearAndAddHttpError } = useFlashKey('ssh-keys');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { mutate } = useSSHKeys();
|
||||
const DeleteSSHKeyButton = ({
|
||||
name,
|
||||
fingerprint,
|
||||
}: {
|
||||
name: string;
|
||||
fingerprint: string;
|
||||
}) => {
|
||||
const { clearAndAddHttpError } = useFlashKey("ssh-keys");
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { mutate } = useSSHKeys();
|
||||
|
||||
const onClick = () => {
|
||||
clearAndAddHttpError();
|
||||
const onClick = () => {
|
||||
clearAndAddHttpError();
|
||||
|
||||
Promise.all([
|
||||
mutate((data) => data?.filter((value) => value.fingerprint !== fingerprint), false),
|
||||
deleteSSHKey(fingerprint),
|
||||
]).catch((error) => {
|
||||
mutate(undefined, true).catch(console.error);
|
||||
clearAndAddHttpError(error);
|
||||
});
|
||||
};
|
||||
Promise.all([
|
||||
mutate(
|
||||
(data) => data?.filter((value) => value.fingerprint !== fingerprint),
|
||||
false,
|
||||
),
|
||||
deleteSSHKey(fingerprint),
|
||||
]).catch((error) => {
|
||||
mutate(undefined, true).catch(console.error);
|
||||
clearAndAddHttpError(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Confirm
|
||||
open={visible}
|
||||
title={'Delete SSH Key'}
|
||||
confirm={'Delete Key'}
|
||||
onConfirmed={onClick}
|
||||
onClose={() => setVisible(false)}
|
||||
>
|
||||
Removing the <Code>{name}</Code> SSH key will invalidate its usage across the Panel.
|
||||
</Dialog.Confirm>
|
||||
<button
|
||||
className='p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-all duration-150'
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} size='lg' />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Dialog.Confirm
|
||||
open={visible}
|
||||
title={"Delete SSH Key"}
|
||||
confirm={"Delete Key"}
|
||||
onConfirmed={onClick}
|
||||
onClose={() => setVisible(false)}
|
||||
>
|
||||
Removing the <Code>{name}</Code> SSH key will invalidate its usage
|
||||
across the Panel.
|
||||
</Dialog.Confirm>
|
||||
<button
|
||||
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-all duration-150"
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} size="lg" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteSSHKeyButton;
|
||||
|
||||
Reference in New Issue
Block a user