fix: improve create api key modal

This commit is contained in:
Naterfute
2026-01-15 11:11:15 -08:00
parent d0938c6a5f
commit 8f4a253585
20 changed files with 2403 additions and 2009 deletions

View File

@@ -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"
]
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View 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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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'}>&nbsp;</span>
{value[1]}
<span className={'selection:bg-zinc-800'}>&nbsp;</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"}>&nbsp;</span>
{value[1]}
<span className={"selection:bg-zinc-800"}>&nbsp;</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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;