diff --git a/app/Http/Controllers/Admin/CotermController.php b/app/Http/Controllers/Admin/CotermController.php index 306894d4a3a..1748f10c554 100644 --- a/app/Http/Controllers/Admin/CotermController.php +++ b/app/Http/Controllers/Admin/CotermController.php @@ -3,12 +3,14 @@ namespace Convoy\Http\Controllers\Admin; use Convoy\Http\Controllers\ApiController; +use Convoy\Http\Requests\Admin\Coterms\DeleteCotermRequest; use Convoy\Http\Requests\Admin\Coterms\StoreCotermRequest; use Convoy\Http\Requests\Admin\Coterms\UpdateAttachedNodesRequest; use Convoy\Http\Requests\Admin\Coterms\UpdateCotermRequest; use Convoy\Models\Coterm; use Convoy\Models\Filters\FiltersCoterm; use Convoy\Models\Filters\FiltersNode; +use Convoy\Models\Node; use Convoy\Services\Coterm\CotermTokenCreationService; use Convoy\Transformers\Admin\CotermTransformer; use Convoy\Transformers\Admin\NodeTransformer; @@ -48,20 +50,31 @@ public function show(Coterm $coterm) public function store(StoreCotermRequest $request) { - $coterm = Coterm::create($request->safe()->except('node_ids')); + $creds = $this->cotermTokenCreator->handle(); + $coterm = Coterm::create([ + ...$request->safe()->except('node_ids'), + ...$creds, + ]); if ($request->node_ids !== null) { - $coterm->nodes()->attach($request->node_ids); + Node::whereIn('id', $request->node_ids)->whereNull('coterm_id')->update( + ['coterm_id' => $coterm->id], + ); } $coterm->loadCount(['nodes']); - return fractal($coterm, new CotermTransformer())->respond(); + return fractal($coterm, new CotermTransformer(includeToken: true))->respond(); } public function update(UpdateCotermRequest $request, Coterm $coterm) { $coterm->update($request->validated()); if ($request->node_ids !== null) { - $coterm->nodes()->sync($request->node_ids); + Node::whereIn('id', $request->node_ids)->whereNull('coterm_id')->update( + ['coterm_id' => $coterm->id], + ); + Node::where('coterm_id', $coterm->id)->whereNotIn('id', $request->node_ids)->update( + ['coterm_id' => null], + ); } $coterm->loadCount(['nodes']); @@ -86,7 +99,12 @@ public function getAttachedNodes(Request $request, Coterm $coterm) public function updateAttachedNodes(UpdateAttachedNodesRequest $request, Coterm $coterm) { - $coterm->nodes()->sync($request->node_ids); + Node::whereIn('id', $request->node_ids)->whereNull('coterm_id')->update( + ['coterm_id' => $coterm->id], + ); + Node::where('coterm_id', $coterm->id)->whereNotIn('id', $request->node_ids)->update( + ['coterm_id' => null], + ); $coterm->loadCount(['nodes']); return fractal($coterm, new CotermTransformer())->respond(); @@ -100,11 +118,14 @@ public function resetCotermToken(Coterm $coterm) 'token' => $creds['token'], ]); - return fractal($coterm, new CotermTransformer())->parseIncludes('token')->respond(); + return fractal($coterm, new CotermTransformer(includeToken: true))->parseIncludes('token') + ->respond(); } - public function destroy(Coterm $coterm) + public function destroy(DeleteCotermRequest $request, Coterm $coterm) { + $coterm->delete(); + return $this->returnNoContent(); } } diff --git a/app/Http/Controllers/Admin/Nodes/NodeController.php b/app/Http/Controllers/Admin/Nodes/NodeController.php index 55bc2a4f1f1..ba2dda068b1 100644 --- a/app/Http/Controllers/Admin/Nodes/NodeController.php +++ b/app/Http/Controllers/Admin/Nodes/NodeController.php @@ -29,7 +29,9 @@ public function index(Request $request) ->allowedFilters( [AllowedFilter::exact('id'), 'name', 'fqdn', AllowedFilter::exact( 'location_id', - ), AllowedFilter::custom( + ), AllowedFilter::exact( + 'coterm_id', + )->nullable(), AllowedFilter::custom( '*', new FiltersNode(), )], diff --git a/app/Http/Controllers/Client/Servers/ServerController.php b/app/Http/Controllers/Client/Servers/ServerController.php index 1e5641c91f7..331914ccea4 100644 --- a/app/Http/Controllers/Client/Servers/ServerController.php +++ b/app/Http/Controllers/Client/Servers/ServerController.php @@ -61,12 +61,14 @@ public function updateState(Server $server, SendPowerCommandRequest $request) public function createConsoleSession(CreateConsoleSessionRequest $request, Server $server) { - if ($server->node->coterm_enabled) { + $server->node->loadMissing('coterm'); + + if ($coterm = $server->node->coterm) { return new JsonResponse([ 'data' => [ - 'is_tls_enabled' => $server->node->coterm_tls_enabled, - 'fqdn' => $server->node->coterm_fqdn, - 'port' => $server->node->coterm_port, + 'is_tls_enabled' => $coterm->is_tls_enabled, + 'fqdn' => $coterm->fqdn, + 'port' => $coterm->port, 'token' => $this->cotermJWTService->handle( $server, $request->user(), $request->enum('type', ConsoleType::class), ) diff --git a/app/Http/Middleware/Coterm/CotermAuthenticate.php b/app/Http/Middleware/Coterm/CotermAuthenticate.php index 9b48efd1f92..263bb3629f2 100644 --- a/app/Http/Middleware/Coterm/CotermAuthenticate.php +++ b/app/Http/Middleware/Coterm/CotermAuthenticate.php @@ -2,11 +2,11 @@ namespace Convoy\Http\Middleware\Coterm; -use Convoy\Models\Node; -use Illuminate\Http\Request; +use Convoy\Models\Coterm; use Illuminate\Database\Eloquent\ModelNotFoundException; -use Symfony\Component\HttpKernel\Exception\HttpException; +use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\HttpException; class CotermAuthenticate { @@ -27,31 +27,30 @@ public function handle(Request $request, \Closure $next): mixed } if (is_null($bearer = $request->bearerToken())) { - throw new HttpException(401, 'Access to this endpoint must include an Authorization header.', null, ['WWW-Authenticate' => 'Bearer']); + throw new HttpException( + 401, 'Access to this endpoint must include an Authorization header.', null, + ['WWW-Authenticate' => 'Bearer'], + ); } $parts = explode('|', $bearer); // Ensure that all the correct parts are provided in the header. if (count($parts) !== 2 || empty($parts[0]) || empty($parts[1])) { - throw new BadRequestHttpException('The Authorization header provided was not in a valid format.'); + throw new BadRequestHttpException( + 'The Authorization header provided was not in a valid format.', + ); } try { - $node = Node::where('coterm_token_id', $parts[0])->firstOrFail(); - - if (!$node->coterm_enabled) { - throw new HttpException(401); - } - - if (hash_equals($node->coterm_token, $parts[1])) { - $request->attributes->set('node', $node); + $coterm = Coterm::where('token_id', $parts[0])->firstOrFail(); + if (hash_equals($coterm->token, $parts[1])) { return $next($request); } } catch (ModelNotFoundException) { // Do nothing, we don't want to expose a node not existing at all. } - throw new HttpException(401); + throw new HttpException(401); } } diff --git a/app/Http/Requests/Admin/Coterms/StoreCotermRequest.php b/app/Http/Requests/Admin/Coterms/StoreCotermRequest.php index f75c42ed6ce..5c292024698 100644 --- a/app/Http/Requests/Admin/Coterms/StoreCotermRequest.php +++ b/app/Http/Requests/Admin/Coterms/StoreCotermRequest.php @@ -2,15 +2,17 @@ namespace Convoy\Http\Requests\Admin\Coterms; -use Convoy\Http\Requests\BaseApiRequest; +use Convoy\Rules\Fqdn; use Convoy\Models\Coterm; use Illuminate\Support\Arr; +use Convoy\Http\Requests\BaseApiRequest; class StoreCotermRequest extends BaseApiRequest { public function rules(): array { $rules = Coterm::getRules(); + $rules['fqdn'][] = Fqdn::make(); return [ ...Arr::only($rules, ['name', 'is_tls_enabled', 'fqdn', 'port']), diff --git a/app/Models/Coterm.php b/app/Models/Coterm.php index 11e93f0a216..994b4a82ca2 100644 --- a/app/Models/Coterm.php +++ b/app/Models/Coterm.php @@ -4,7 +4,6 @@ use Convoy\Casts\NullableEncrypter; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Coterm extends Model { @@ -39,4 +38,9 @@ public function nodes(): HasMany { return $this->hasMany(Node::class); } + + public function getRouteKeyName(): string + { + return 'id'; + } } diff --git a/app/Transformers/Admin/CotermTransformer.php b/app/Transformers/Admin/CotermTransformer.php index aab776a3760..d366994226e 100644 --- a/app/Transformers/Admin/CotermTransformer.php +++ b/app/Transformers/Admin/CotermTransformer.php @@ -7,11 +7,13 @@ class CotermTransformer extends TransformerAbstract { - protected array $availableIncludes = ['token']; + public function __construct(private bool $includeToken = false) + { + } public function transform(Coterm $coterm): array { - return [ + $transformed = [ 'id' => (int)$coterm->id, 'name' => $coterm->name, 'is_tls_enabled' => (boolean)$coterm->is_tls_enabled, @@ -19,13 +21,12 @@ public function transform(Coterm $coterm): array 'port' => (int)$coterm->port, 'nodes_count' => (int)$coterm->nodes_count, ]; - } - public function includeToken(Coterm $coterm): array - { - return [ - 'token_id' => $coterm->token_id, - 'token' => $coterm->token, - ]; + if ($this->includeToken) { + $transformed['token_id'] = $coterm->token_id; + $transformed['token'] = $coterm->token; + } + + return $transformed; } } diff --git a/resources/scripts/api/admin/coterms/deleteCoterm.ts b/resources/scripts/api/admin/coterms/deleteCoterm.ts index 9f3bb32a7de..3846335d8ad 100644 --- a/resources/scripts/api/admin/coterms/deleteCoterm.ts +++ b/resources/scripts/api/admin/coterms/deleteCoterm.ts @@ -1,5 +1,5 @@ import http from '@/api/http' -const deleteCoterm = (id: string) => http.delete(`/admin/coterms/${id}`) +const deleteCoterm = (id: string) => http.delete(`/api/admin/coterms/${id}`) export default deleteCoterm \ No newline at end of file diff --git a/resources/scripts/api/admin/coterms/getCoterm.ts b/resources/scripts/api/admin/coterms/getCoterm.ts new file mode 100644 index 00000000000..673c173f049 --- /dev/null +++ b/resources/scripts/api/admin/coterms/getCoterm.ts @@ -0,0 +1,13 @@ +import { rawDataToCoterm } from '@/api/admin/coterms/getCoterms' +import http from '@/api/http' + + +const getCoterm = async (id: number) => { + const { + data: { data }, + } = await http.get(`/api/admin/coterms/${id}`) + + return rawDataToCoterm(data) +} + +export default getCoterm \ No newline at end of file diff --git a/resources/scripts/api/admin/coterms/resetCotermToken.ts b/resources/scripts/api/admin/coterms/resetCotermToken.ts index b1acbec209f..0cbd4a5cbc2 100644 --- a/resources/scripts/api/admin/coterms/resetCotermToken.ts +++ b/resources/scripts/api/admin/coterms/resetCotermToken.ts @@ -1,10 +1,11 @@ import { rawDataToCoterm } from '@/api/admin/coterms/getCoterms' import http from '@/api/http' + const resetCotermToken = async (id: number) => { const { data: { data }, - } = await http.post(`/api/admin/coterm/${id}/settings/reset-coterm-token`) + } = await http.post(`/api/admin/coterms/${id}/reset-coterm-token`) return rawDataToCoterm(data) } diff --git a/resources/scripts/api/admin/coterms/updateCoterm.ts b/resources/scripts/api/admin/coterms/updateCoterm.ts index 6f256e2e2a1..e0ad6f52915 100644 --- a/resources/scripts/api/admin/coterms/updateCoterm.ts +++ b/resources/scripts/api/admin/coterms/updateCoterm.ts @@ -15,7 +15,7 @@ const updateCoterm = async ( ) => { const { data: { data }, - } = await http.put(`/admin/coterms/${id}`, { + } = await http.put(`/api/admin/coterms/${id}`, { name, is_tls_enabled: isTlsEnabled, fqdn, diff --git a/resources/scripts/api/admin/coterms/useAttachedNodes.ts b/resources/scripts/api/admin/coterms/useAttachedNodes.ts new file mode 100644 index 00000000000..4c1ac33bae9 --- /dev/null +++ b/resources/scripts/api/admin/coterms/useAttachedNodes.ts @@ -0,0 +1,21 @@ +import useSWR from 'swr' + +import getAttachedNodes, { + QueryParams, +} from '@/api/admin/coterms/getAttachedNodes' + + +const useAttachedNodes = ( + id: number, + { page, query, ...params }: QueryParams +) => { + return useSWR(['admin.coterms.attachedNodes', id, page, query], () => + getAttachedNodes(id, { + page, + query, + ...params, + }) + ) +} + +export default useAttachedNodes \ No newline at end of file diff --git a/resources/scripts/api/admin/coterms/useCotermSWR.ts b/resources/scripts/api/admin/coterms/useCotermSWR.ts new file mode 100644 index 00000000000..93e0a9c4701 --- /dev/null +++ b/resources/scripts/api/admin/coterms/useCotermSWR.ts @@ -0,0 +1,20 @@ +import { Optimistic } from '@/lib/swr' +import { useParams } from 'react-router-dom' +import useSWR, { Key, SWRResponse } from 'swr' + +import getCoterm from '@/api/admin/coterms/getCoterm' +import { Coterm } from '@/api/admin/coterms/getCoterms' + + +export const getKey = (id: number): Key => ['admin.coterms', id] + +const useCotermSWR = () => { + const { cotermId } = useParams() + const id = parseInt(cotermId!) + + return useSWR(getKey(id), () => getCoterm(id), { + revalidateOnMount: false, + }) as Optimistic> +} + +export default useCotermSWR \ No newline at end of file diff --git a/resources/scripts/api/admin/nodes/getNodes.ts b/resources/scripts/api/admin/nodes/getNodes.ts index f66c6d919ba..1ca2b28e5c4 100644 --- a/resources/scripts/api/admin/nodes/getNodes.ts +++ b/resources/scripts/api/admin/nodes/getNodes.ts @@ -51,7 +51,8 @@ export const rawDataToNode = (data: any): Node => ({ export type NodeResponse = PaginatedResult export interface QueryParams { - query?: string + query?: string | null + cotermId?: number | null id?: number | number[] | string | string[] page?: number perPage?: number @@ -59,6 +60,7 @@ export interface QueryParams { const getNodes = async ({ query, + cotermId, id, perPage = 50, ...params @@ -66,6 +68,7 @@ const getNodes = async ({ const { data } = await http.get('/api/admin/nodes', { params: { 'filter[*]': query, + 'filter[coterm_id]': cotermId === null ? '' : cotermId, 'filter[id]': id ? Array.isArray(id) ? id.join(',') diff --git a/resources/scripts/api/admin/nodes/settings/resetCotermToken.ts b/resources/scripts/api/admin/nodes/settings/resetCotermToken.ts deleted file mode 100644 index 64846e71354..00000000000 --- a/resources/scripts/api/admin/nodes/settings/resetCotermToken.ts +++ /dev/null @@ -1,13 +0,0 @@ -import http from '@/api/http' - -const resetCotermToken = async (nodeId: number): Promise => { - const { - data: { data }, - } = await http.post( - `/api/admin/nodes/${nodeId}/settings/reset-coterm-token` - ) - - return data.token -} - -export default resetCotermToken \ No newline at end of file diff --git a/resources/scripts/api/admin/nodes/settings/updateCoterm.ts b/resources/scripts/api/admin/nodes/settings/updateCoterm.ts deleted file mode 100644 index 141ec1a1e91..00000000000 --- a/resources/scripts/api/admin/nodes/settings/updateCoterm.ts +++ /dev/null @@ -1,32 +0,0 @@ -import http from '@/api/http' - -interface UpdateCotermParameters { - isEnabled: boolean - fqdn: string | null - port: number - isTlsEnabled: boolean -} - -const updateCoterm = async ( - nodeId: number, - { isEnabled, fqdn, port, isTlsEnabled }: UpdateCotermParameters -) => { - const { - data: { data }, - } = await http.patch(`/api/admin/nodes/${nodeId}/settings/coterm`, { - is_enabled: isEnabled, - fqdn, - port, - is_tls_enabled: isTlsEnabled, - }) - - return { - isEnabled: data.is_enabled as boolean, - fqdn: data.fqdn as string, - port: data.port as number, - isTlsEnabled: data.is_tls_enabled as boolean, - token: data.token as string | null, - } -} - -export default updateCoterm \ No newline at end of file diff --git a/resources/scripts/api/admin/nodes/useNodesSWR.ts b/resources/scripts/api/admin/nodes/useNodesSWR.ts index 67a17dd6b1e..f70ae7d4be6 100644 --- a/resources/scripts/api/admin/nodes/useNodesSWR.ts +++ b/resources/scripts/api/admin/nodes/useNodesSWR.ts @@ -2,9 +2,11 @@ import useSWR from 'swr' import getNodes, { NodeResponse, QueryParams } from '@/api/admin/nodes/getNodes' -const useNodesSWR = ({ page, query, id, ...params }: QueryParams) => { - return useSWR(['admin:nodes', page, query, Boolean(id)], () => - getNodes({ page, query, id, ...params }) + +const useNodesSWR = ({ page, query, id, cotermId, ...params }: QueryParams) => { + return useSWR( + ['admin:nodes', page, query, Boolean(id), cotermId], + () => getNodes({ page, query, id, cotermId, ...params }) ) } diff --git a/resources/scripts/components/admin/coterms/CotermContainer.tsx b/resources/scripts/components/admin/coterms/CotermContainer.tsx new file mode 100644 index 00000000000..9efe570a858 --- /dev/null +++ b/resources/scripts/components/admin/coterms/CotermContainer.tsx @@ -0,0 +1,169 @@ +import usePagination from '@/util/usePagination' +import { LockClosedIcon, LockOpenIcon } from '@heroicons/react/20/solid' +import { useDebouncedValue } from '@mantine/hooks' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' + +import { Coterm } from '@/api/admin/coterms/getCoterms' +import useCotermsSWR from '@/api/admin/coterms/useCotermsSWR' + +import Menu from '@/components/elements/Menu' +import PageContentBlock from '@/components/elements/PageContentBlock' +import Pagination from '@/components/elements/Pagination' +import Spinner from '@/components/elements/Spinner' +import Table, { + Actions, + ColumnArray, + RowActionsProps, +} from '@/components/elements/displays/Table' + +import SearchBar from '@/components/admin/SearchBar' +import CotermResetModal from '@/components/admin/coterms/CotermResetModal' +import CotermTokenModal from '@/components/admin/coterms/CotermTokenModal' +import CreateCotermModal from '@/components/admin/coterms/CreateCotermModal' +import DeleteCotermModal from '@/components/admin/coterms/DeleteCotermModal' +import EditCotermModal from '@/components/admin/coterms/EditCotermModal' + + +const CotermContainer = () => { + const { t: tStrings } = useTranslation('strings') + const [isCreating, setIsCreating] = useState(false) + const [cotermToEdit, setCotermToEdit] = useState(null) + const [cotermToReset, setCotermToReset] = useState(null) + const [cotermToDelete, setCotermToDelete] = useState(null) + const [cotermToken, setCotermToken] = useState(null) + const [page, setPage] = usePagination() + + const [query, setQuery] = useState('') + const [debouncedQuery] = useDebouncedValue(query, 200) + const { data, mutate } = useCotermsSWR({ page, query: debouncedQuery }) + + const columns: ColumnArray = [ + { + accessor: 'name', + header: tStrings('name'), + cell: ({ value, row }) => ( + + {value} + + ), + }, + { + accessor: 'fqdn', + header: tStrings('fqdn'), + }, + { + accessor: 'port', + header: tStrings('port'), + align: 'center', + }, + { + accessor: 'isTlsEnabled', + header: 'TLS', + align: 'center', + cell: ({ value }) => ( +
+ {value ? ( + + ) : ( + + )} +
+ ), + }, + { + accessor: 'nodesCount', + header: tStrings('node_other'), + align: 'center', + }, + ] + + const rowActions = ({ row: coterm }: RowActionsProps) => { + return ( + + setCotermToEdit(coterm)}> + {tStrings('edit')} + + setCotermToReset(coterm)}> + Reset + + + setCotermToDelete(coterm)} + > + {tStrings('delete')} + + + ) + } + + const handleTokenModalClose = () => { + setCotermToken(null) + setCotermToReset(null) + } + + return ( +
+ + setIsCreating(false)} + mutate={mutate} + /> + setCotermToEdit(null)} + mutate={mutate} + /> + setCotermToken(token)} + onClose={() => setCotermToReset(null)} + /> + + setCotermToDelete(null)} + mutate={mutate} + /> + + setQuery(e.target.value)} + buttonText={'Create Instance'} + onClick={() => setIsCreating(true)} + /> + + {!data ? ( + + ) : ( + + {({ items }) => ( + + )} + + )} + + + ) +} + +export default CotermContainer \ No newline at end of file diff --git a/resources/scripts/components/admin/coterms/CotermContextBlock.tsx b/resources/scripts/components/admin/coterms/CotermContextBlock.tsx new file mode 100644 index 00000000000..45f314ddb5a --- /dev/null +++ b/resources/scripts/components/admin/coterms/CotermContextBlock.tsx @@ -0,0 +1,21 @@ +import useCotermSWR from '@/api/admin/coterms/useCotermSWR' + +import PageContentBlock, { + PageContentBlockProps, +} from '@/components/elements/PageContentBlock' + +interface Props extends PageContentBlockProps { + title: string +} + +const CotermContentBlock: React.FC = ({ title, children, ...props }) => { + const { data: coterm } = useCotermSWR() + + return ( + + {children} + + ) +} + +export default CotermContentBlock \ No newline at end of file diff --git a/resources/scripts/components/admin/coterms/CotermNodesMultiSelect.tsx b/resources/scripts/components/admin/coterms/CotermNodesMultiSelect.tsx new file mode 100644 index 00000000000..56370bc02fe --- /dev/null +++ b/resources/scripts/components/admin/coterms/CotermNodesMultiSelect.tsx @@ -0,0 +1,79 @@ +import { useDebouncedValue } from '@mantine/hooks' +import { useMemo, useState } from 'react' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import useNodesSWR from '@/api/admin/nodes/useNodesSWR' + +import DescriptiveItemComponent from '@/components/elements/DescriptiveItemComponent' +import MultiSelectForm from '@/components/elements/forms/MultiSelectForm' + + +interface Props { + disabled?: boolean + loading?: boolean +} + +const CotermNodesMultiSelectForm = ({ disabled, loading }: Props) => { + const { t } = useTranslation('admin.addressPools.index') + const { t: tStrings } = useTranslation('strings') + const { watch } = useFormContext() + const nodeIds: string[] = watch('nodeIds') + + const [query, setQuery] = useState('') + const [debouncedQuery] = useDebouncedValue(query, 200) + + const { data, isValidating, isLoading } = useNodesSWR({ + query: debouncedQuery, + cotermId: null, + perPage: 20, + }) + const { data: selectedNodes } = useNodesSWR({ + id: nodeIds.length > 0 ? nodeIds : [-1], + }) + + const nodes = useMemo(() => { + const available = + data && selectedNodes + ? data.items + .filter( + node => + !selectedNodes.items.find( + selectedNode => selectedNode.id === node.id + ) + ) + .map(node => ({ + value: node.id.toString(), + label: node.name, + description: node.fqdn, + })) + : [] + + const selected = selectedNodes + ? selectedNodes.items.map(node => ({ + value: node.id.toString(), + label: node.name, + description: node.fqdn, + })) + : [] + + return [...selected, ...available] + }, [data, selectedNodes]) + + return ( + setQuery(val)} + loading={isValidating || isLoading || loading} + label={'Attached Nodes'} + nothingFound={t('nodes_nothing_found')} + name={'nodeIds'} + disabled={disabled} + /> + ) +} + +export default CotermNodesMultiSelectForm \ No newline at end of file diff --git a/resources/scripts/components/admin/nodes/settings/partials/general/CotermResetModal.tsx b/resources/scripts/components/admin/coterms/CotermResetModal.tsx similarity index 72% rename from resources/scripts/components/admin/nodes/settings/partials/general/CotermResetModal.tsx rename to resources/scripts/components/admin/coterms/CotermResetModal.tsx index ad112f34d41..09da45c55cf 100644 --- a/resources/scripts/components/admin/nodes/settings/partials/general/CotermResetModal.tsx +++ b/resources/scripts/components/admin/coterms/CotermResetModal.tsx @@ -2,24 +2,24 @@ import { useFlashKey } from '@/util/useFlash' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import resetCotermToken from '@/api/admin/nodes/settings/resetCotermToken' +import { Coterm } from '@/api/admin/coterms/getCoterms' +import resetCotermToken from '@/api/admin/coterms/resetCotermToken' import FlashMessageRender from '@/components/elements/FlashMessageRenderer' import Modal from '@/components/elements/Modal' interface Props { - nodeId: number - open: boolean + coterm: Coterm | null onReset: (token: string) => void onClose: () => void } -const CotermResetModal = ({ nodeId, open, onReset, onClose }: Props) => { +const CotermResetModal = ({ coterm, onReset, onClose }: Props) => { const { t } = useTranslation('admin.nodes.settings') const [loading, setLoading] = useState(false) const { clearFlashes, clearAndAddHttpError } = useFlashKey( - `admin.nodes.${nodeId}.settings.general.reset-coterm` + `admin.coterms.${coterm?.id}.reset` ) const reset = async () => { @@ -27,9 +27,9 @@ const CotermResetModal = ({ nodeId, open, onReset, onClose }: Props) => { try { setLoading(true) - const token = await resetCotermToken(nodeId) + const updatedCoterm = await resetCotermToken(coterm.id) - onReset(token) + onReset(`${updatedCoterm!.tokenId}|${updatedCoterm!.token}`) } catch (e) { clearAndAddHttpError(e as Error) } finally { @@ -38,7 +38,7 @@ const CotermResetModal = ({ nodeId, open, onReset, onClose }: Props) => { } return ( - + {t('coterm.reset.title')} @@ -46,7 +46,7 @@ const CotermResetModal = ({ nodeId, open, onReset, onClose }: Props) => { {t('coterm.reset.description')} diff --git a/resources/scripts/components/admin/nodes/settings/partials/general/CotermTokenModal.tsx b/resources/scripts/components/admin/coterms/CotermTokenModal.tsx similarity index 100% rename from resources/scripts/components/admin/nodes/settings/partials/general/CotermTokenModal.tsx rename to resources/scripts/components/admin/coterms/CotermTokenModal.tsx diff --git a/resources/scripts/components/admin/coterms/CreateCotermModal.tsx b/resources/scripts/components/admin/coterms/CreateCotermModal.tsx new file mode 100644 index 00000000000..e6cd75832d9 --- /dev/null +++ b/resources/scripts/components/admin/coterms/CreateCotermModal.tsx @@ -0,0 +1,137 @@ +import { useFlashKey } from '@/util/useFlash' +import { port } from '@/util/validation' +import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { KeyedMutator } from 'swr' +import { z } from 'zod' + +import createCoterm from '@/api/admin/coterms/createCoterm' +import { CotermResponse } from '@/api/admin/coterms/getCoterms' + +import FlashMessageRender from '@/components/elements/FlashMessageRenderer' +import Modal from '@/components/elements/Modal' +import CheckboxForm from '@/components/elements/forms/CheckboxForm' +import TextInputForm from '@/components/elements/forms/TextInputForm' + +import CotermNodesMultiSelectForm from '@/components/admin/coterms/CotermNodesMultiSelect' +import CotermTokenModal from '@/components/admin/coterms/CotermTokenModal' + + +interface Props { + open: boolean + onClose: () => void + mutate: KeyedMutator +} + +const CreateCotermModal = ({ open, onClose, mutate }: Props) => { + const { t: tStrings } = useTranslation('strings') + const { clearFlashes, clearAndAddHttpError } = + useFlashKey(`admin.coterms.create`) + + const [cotermToken, setCotermToken] = useState(null) + + const schema = z.object({ + name: z.string().min(1).max(191), + isTlsEnabled: z.boolean(), + fqdn: z.string().min(1).max(191), + port: port(z.coerce.number()), + nodeIds: z.array(z.coerce.number()), + }) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + isTlsEnabled: false, + fqdn: '', + port: 443, + nodeIds: [] as string[], + }, + }) + + const handleClose = () => { + form.reset() + setCotermToken(null) + onClose() + } + + const submit = async (_data: any) => { + const data = _data as z.infer + + clearFlashes() + try { + const coterm = await createCoterm(data) + + mutate(data => { + if (!data) return data + if (data.pagination.currentPage !== 1) return data + + return { + ...data, + items: [coterm, ...data.items], + } + }, false) + + setCotermToken(`${coterm!.tokenId}|${coterm!.token}`) + } catch (e) { + clearAndAddHttpError(e as Error) + } + } + + return ( + <> + + + + Create Instance + + + +
+ + + +
+ + +
+ + +
+ + + + {tStrings('cancel')} + + + {tStrings('create')} + + + +
+
+ + ) +} + +export default CreateCotermModal \ No newline at end of file diff --git a/resources/scripts/components/admin/coterms/DeleteCotermModal.tsx b/resources/scripts/components/admin/coterms/DeleteCotermModal.tsx new file mode 100644 index 00000000000..ba34c303412 --- /dev/null +++ b/resources/scripts/components/admin/coterms/DeleteCotermModal.tsx @@ -0,0 +1,105 @@ +import { useFlashKey } from '@/util/useFlash' +import { FormEvent } from 'react' +import { useTranslation } from 'react-i18next' +import { KeyedMutator } from 'swr' +import useSWRMutation from 'swr/mutation' + +import deleteCoterm from '@/api/admin/coterms/deleteCoterm' +import { Coterm, CotermResponse } from '@/api/admin/coterms/getCoterms' + +import FlashMessageRender from '@/components/elements/FlashMessageRenderer' +import MessageBox from '@/components/elements/MessageBox' +import Modal from '@/components/elements/Modal' + + +interface Props { + coterm: Coterm | null + onClose: () => void + mutate: KeyedMutator +} + +const DeleteCotermModal = ({ coterm, onClose, mutate }: Props) => { + const { t: tStrings } = useTranslation('strings') + const { clearFlashes, clearAndAddHttpError } = useFlashKey( + `admin.coterms.${coterm?.id}.delete` + ) + + const { trigger, isMutating } = useSWRMutation( + ['admin.coterms.delete', coterm?.id], + async () => { + clearFlashes() + try { + await deleteCoterm(coterm!.id) + + mutate(data => { + if (!data) return data + + return { + ...data, + items: data.items.filter( + item => item.id !== coterm!.id + ), + } + }, false) + + onClose() + } catch (e) { + clearAndAddHttpError(e as Error) + throw e + } + } + ) + + const submit = (e: FormEvent) => { + e.preventDefault() + trigger() + } + + return ( + + + Delete {coterm?.name}? + + +
+ + {coterm ? ( + coterm.nodesCount > 0 ? ( + + Cannot delete an instance of Coterm with nodes + attached to it. + + ) : null + ) : null} + + + Are you sure you want to delete this instance of Coterm? + This action is irreversible. + + + + + + {tStrings('cancel')} + + 0 : false} + loading={isMutating} + > + {tStrings('delete')} + + + +
+ ) +} + +export default DeleteCotermModal \ No newline at end of file diff --git a/resources/scripts/components/admin/coterms/EditCotermModal.tsx b/resources/scripts/components/admin/coterms/EditCotermModal.tsx new file mode 100644 index 00000000000..2e10600e172 --- /dev/null +++ b/resources/scripts/components/admin/coterms/EditCotermModal.tsx @@ -0,0 +1,146 @@ +import { useFlashKey } from '@/util/useFlash' +import { port } from '@/util/validation' +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { KeyedMutator } from 'swr' +import { z } from 'zod' + +import { Coterm, CotermResponse } from '@/api/admin/coterms/getCoterms' +import updateCoterm from '@/api/admin/coterms/updateCoterm' +import useAttachedNodes from '@/api/admin/coterms/useAttachedNodes' + +import FlashMessageRender from '@/components/elements/FlashMessageRenderer' +import Modal from '@/components/elements/Modal' +import CheckboxForm from '@/components/elements/forms/CheckboxForm' +import TextInputForm from '@/components/elements/forms/TextInputForm' + +import CotermNodesMultiSelectForm from '@/components/admin/coterms/CotermNodesMultiSelect' + + +interface Props { + coterm: Coterm | null + onClose: () => void + mutate: KeyedMutator +} + +const EditCotermModal = ({ coterm, onClose, mutate }: Props) => { + const { t: tStrings } = useTranslation('strings') + const { clearFlashes, clearAndAddHttpError } = useFlashKey( + `admin.coterms.${coterm?.id}.update` + ) + const { data: attachedNodes } = useAttachedNodes(coterm?.id ?? -1, {}) + + const schema = z.object({ + name: z.string().min(1).max(191), + isTlsEnabled: z.boolean(), + fqdn: z.string().min(1).max(191), + port: port(z.coerce.number()), + nodeIds: z.array(z.coerce.number()), + }) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + isTlsEnabled: false, + fqdn: '', + port: 443, + nodeIds: [] as string[], + }, + }) + + useEffect(() => { + form.reset(old => ({ + name: coterm?.name, + isTlsEnabled: coterm?.isTlsEnabled, + fqdn: coterm?.fqdn, + port: coterm?.port, + nodeIds: old.nodeIds, + })) + }, [coterm]) + + useEffect(() => { + form.reset(old => ({ + name: old.name, + isTlsEnabled: old.isTlsEnabled, + fqdn: old.fqdn, + port: old.port, + nodeIds: attachedNodes?.items.map(node => node.id.toString()) ?? [], + })) + }, [attachedNodes]) + + const handleClose = () => { + form.reset() + onClose() + } + + const submit = async (_data: any) => { + const data = _data as z.infer + + clearFlashes() + try { + const updatedCoterm = await updateCoterm(coterm.id, data) + mutate(data => { + if (!data) return data + + return { + ...data, + items: data.items.map(item => + item.id === updatedCoterm.id ? updatedCoterm : item + ), + } + }, false) + handleClose() + } catch (e) { + clearAndAddHttpError(e) + } + } + + return ( + + + Edit {coterm?.name} + + + +
+ + + +
+ + +
+ + +
+ + + + {tStrings('cancel')} + + + {tStrings('save')} + + + +
+
+ ) +} + +export default EditCotermModal \ No newline at end of file diff --git a/resources/scripts/components/admin/nodes/settings/GeneralContainer.tsx b/resources/scripts/components/admin/nodes/settings/GeneralContainer.tsx index 1f7f36dea64..745ab1f081e 100644 --- a/resources/scripts/components/admin/nodes/settings/GeneralContainer.tsx +++ b/resources/scripts/components/admin/nodes/settings/GeneralContainer.tsx @@ -1,4 +1,3 @@ -import CotermCard from '@/components/admin/nodes/settings/partials/general/CotermCard' import DeleteNodeCard from '@/components/admin/nodes/settings/partials/general/DeleteNodeCard' import NodeInformationCard from '@/components/admin/nodes/settings/partials/general/NodeInformationCard' @@ -6,7 +5,6 @@ const GeneralContainer = () => { return ( <> - ) diff --git a/resources/scripts/components/admin/nodes/settings/partials/general/CotermCard.tsx b/resources/scripts/components/admin/nodes/settings/partials/general/CotermCard.tsx deleted file mode 100644 index 3ab484525f0..00000000000 --- a/resources/scripts/components/admin/nodes/settings/partials/general/CotermCard.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { useFlashKey } from '@/util/useFlash' -import { hostname, port } from '@/util/validation' -import { zodResolver } from '@hookform/resolvers/zod' -import { useState } from 'react' -import { FormProvider, useForm } from 'react-hook-form' -import { useTranslation } from 'react-i18next' -import { z } from 'zod' - -import updateCoterm from '@/api/admin/nodes/settings/updateCoterm' -import useNodeSWR from '@/api/admin/nodes/useNodeSWR' - -import Button from '@/components/elements/Button' -import FlashMessageRender from '@/components/elements/FlashMessageRenderer' -import FormCard from '@/components/elements/FormCard' -import CheckboxForm from '@/components/elements/forms/CheckboxForm' -import SwitchForm from '@/components/elements/forms/SwitchForm' -import TextInputForm from '@/components/elements/forms/TextInputForm' - -import CotermResetModal from '@/components/admin/nodes/settings/partials/general/CotermResetModal' -import CotermTokenModal from '@/components/admin/nodes/settings/partials/general/CotermTokenModal' - - -const CotermCard = () => { - const { data: node, mutate } = useNodeSWR() - const { clearFlashes, clearAndAddHttpError } = useFlashKey( - `admin.nodes.${node.id}.settings.general.coterm` - ) - const { t: tStrings } = useTranslation('strings') - const { t } = useTranslation('admin.nodes.settings') - const [token, setToken] = useState(null) - const [showResetModal, setShowResetModal] = useState(false) - - const schemaIfEnabled = z.object({ - isEnabled: z.literal(true), - fqdn: hostname().max(191).nonempty(), - port: z.preprocess(Number, port()), - isTlsEnabled: z.boolean(), - }) - - const schemaIfDisabled = z.object({ - isEnabled: z.literal(false), - fqdn: hostname().max(191), - port: z.preprocess(Number, port()), - isTlsEnabled: z.boolean(), - }) - - const schema = z.discriminatedUnion('isEnabled', [ - schemaIfEnabled, - schemaIfDisabled, - ]) - - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: { - isEnabled: node.cotermEnabled, - fqdn: node.cotermFqdn ?? '', // fallback to empty string because null values are not allowed - port: node.cotermPort.toString(), - isTlsEnabled: node.cotermTlsEnabled, - }, - }) - - const isEnabled = form.watch('isEnabled') - - const submit = async (_data: any) => { - const { fqdn, ...data } = _data as z.infer - clearFlashes() - - try { - const details = await updateCoterm(node.id, { - fqdn: fqdn === '' ? null : fqdn, - ...data, - }) - - if (details.token) { - setToken(details.token) - } - - form.reset({ - isEnabled: details.isEnabled, - fqdn: details.fqdn ?? '', - port: details.port.toString(), - isTlsEnabled: details.isTlsEnabled, - }) - - mutate( - data => ({ - ...data!, - cotermEnabled: details.isEnabled, - cotermFqdn: details.fqdn, - cotermPort: details.port, - cotermTlsEnabled: details.isTlsEnabled, - }), - false - ) - } catch (e) { - clearAndAddHttpError(e as Error) - } - } - - const reset = (token: string) => { - setToken(token) - setShowResetModal(false) - } - - return ( - <> - setShowResetModal(false)} - /> - setToken(null)} /> - - -
- - {t('coterm.title')} -

- {t('coterm.description')} -

- -
- - -
- - -
-
- - -
-
-
- - - - -
-
- - ) -} - -export default CotermCard \ No newline at end of file diff --git a/resources/scripts/routers/AdminCotermRouter.tsx b/resources/scripts/routers/AdminCotermRouter.tsx new file mode 100644 index 00000000000..611070fa545 --- /dev/null +++ b/resources/scripts/routers/AdminCotermRouter.tsx @@ -0,0 +1,21 @@ +import { lazyLoad } from '@/routers/helpers' +import { Route } from '@/routers/router' +import { lazy } from 'react' + + +export const routes: Route[] = [ + { + path: 'coterms', + children: [ + { + index: true, + element: lazyLoad( + lazy( + () => + import('@/components/admin/coterms/CotermContainer') + ) + ), + }, + ], + }, +] \ No newline at end of file diff --git a/resources/scripts/routers/AdminDashboardRouter.tsx b/resources/scripts/routers/AdminDashboardRouter.tsx index 9d0d0622fba..d0e5712d126 100644 --- a/resources/scripts/routers/AdminDashboardRouter.tsx +++ b/resources/scripts/routers/AdminDashboardRouter.tsx @@ -1,3 +1,4 @@ +import { routes as adminCotermRoutes } from '@/routers/AdminCotermRouter' import { routes as adminIpamRoutes } from '@/routers/AdminIpamRouter' import { routes as adminNodeRoutes } from '@/routers/AdminNodeRouter' import { routes as adminServerRoutes } from '@/routers/AdminServerRouter' @@ -61,6 +62,7 @@ export const routes: Route[] = [ ...adminServerRoutes, ...adminIpamRoutes, ...adminUserRoutes, + ...adminCotermRoutes, { path: 'tokens', element: lazyLoad( @@ -118,6 +120,10 @@ const AdminDashboardRouter = () => { name: tStrings('user_other'), path: '/admin/users', }, + { + name: 'Coterms', + path: '/admin/coterms', + }, { name: tStrings('token_other'), path: '/admin/tokens', diff --git a/routes/api-admin.php b/routes/api-admin.php index 80c0ef42818..c44ed3708f1 100644 --- a/routes/api-admin.php +++ b/routes/api-admin.php @@ -35,6 +35,7 @@ Route::post('/', [Admin\Nodes\NodeController::class, 'store']); Route::prefix('/{node}')->group(function () { + Route::get('/', [Admin\Nodes\NodeController::class, 'show']); Route::put('/', [Admin\Nodes\NodeController::class, 'update']); Route::delete('/', [Admin\Nodes\NodeController::class, 'destroy']);