Skip to content

Commit 279dd92

Browse files
committed
feat: Add “SSH Keys” to Cluster/Instance > Config
This lets you perform basic CRUD on your SSH keys within your cluster or instances. With this in hand, private repositories can be accessed when importing an application. https://harperdb.atlassian.net/browse/STUDIO-493
1 parent 80a4d2c commit 279dd92

File tree

16 files changed

+727
-36
lines changed

16 files changed

+727
-36
lines changed

src/components/Loading.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ interface LoadingProps extends React.ComponentProps<'div'> {
44
}
55

66
export function Loading({ className, text, centered, ...props }: LoadingProps) {
7-
const additionalClassName = centered ? 'flex flex-col items-center justify-center h-full' : '';
7+
const additionalClassName = centered ? 'flex flex-col items-center justify-center' : '';
88
return (
99
<div className={`text-white h-full w-full ${className || ''} ${additionalClassName}`} {...props}>
1010
<img src="/HDBDogOnly.svg" width="100px" height="100px" alt="HDB Dog Logo Loading" className="inline-block" />

src/components/SimpleBrowseDataTable.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
'use client';
22

3+
import { Loading } from '@/components/Loading';
4+
5+
import { Table, TableBody, TableCell, TableHeader, TableHeadSortable, TableRow } from '@/components/ui/table';
36
import { cn } from '@/lib/cn';
47
import {
58
ColumnDef,
@@ -11,10 +14,7 @@ import {
1114
SortingState,
1215
useReactTable,
1316
} from '@tanstack/react-table';
14-
15-
import { Table, TableBody, TableCell, TableHeader, TableHeadSortable, TableRow } from '@/components/ui/table';
1617
import React, { Dispatch, SetStateAction } from 'react';
17-
import { Loading } from '@/components/Loading';
1818

1919
interface BrowseDataTableProps<TData, TValue> {
2020
columns: ColumnDef<TData, TValue>[];
@@ -75,7 +75,7 @@ export function SimpleBrowseDataTable<TData, TValue>({
7575
</TableCell>))}
7676
</TableRow>))) : (<TableRow>
7777
<TableCell colSpan={columns.length} className="h-24 text-center">
78-
{isFetching ? <div><Loading className="m-12" /></div> : <span>No results.</span>}
78+
{isFetching ? <div><Loading className="p-12" /></div> : <span>No results.</span>}
7979
</TableCell>
8080
</TableRow>)}
8181
</TableBody>

src/components/ui/dialog.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,14 @@ function DialogContent({ className, children, ...props }: React.ComponentProps<t
4040
<DialogPrimitive.Content
4141
data-slot="dialog-content"
4242
className={cn(
43-
'bg-black data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-md p-6 shadow-lg duration-200 sm:max-w-lg',
43+
'bg-black ' +
44+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 ' +
45+
'fixed top-[50%] left-[50%] z-50 grid ' +
46+
'w-full max-w-[calc(100%-2rem)] lg:max-w-2xl ' +
47+
'max-h-screen overflow-y-auto ' +
48+
'translate-x-[-50%] translate-y-[-50%] ' +
49+
'gap-4 rounded-md p-6 shadow-lg ' +
50+
'duration-200',
4451
className
4552
)}
4653
{...props}

src/components/ui/textarea.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { cn } from '@/lib/cn';
2+
import * as React from 'react';
3+
4+
export function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
5+
return (
6+
<textarea
7+
data-slot="input"
8+
className={cn(
9+
`border-input file:text-foreground placeholder:text-muted-foreground selection:bg-purple
10+
selection:text-primary-foreground
11+
dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10
12+
dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60
13+
aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive
14+
flex w-full min-w-0 rounded-md border bg-grey-700 px-3 py-1 text-base text-white
15+
file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium
16+
focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-purple disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
17+
aria-invalid:focus-visible:ring-[1px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-1`,
18+
className,
19+
)}
20+
{...props}
21+
/>
22+
);
23+
}

src/features/instance/applications/components/NewApplication/ImportInstructions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function ImportInstructions({
6969
<FormLabel className="pb-1">Git Repository URL</FormLabel>
7070
<FormControl>
7171
<Input
72-
type="url"
72+
type="text"
7373
autoCapitalize="none"
7474
autoComplete="off"
7575
autoFocus={true}

src/features/instance/config/index.tsx

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Loading } from '@/components/Loading';
2+
import { useInstanceManagePermission } from '@/hooks/usePermissions';
23
import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage';
34
import { Link, Outlet, useParams } from '@tanstack/react-router';
4-
import { Handshake, PieChartIcon, Users } from 'lucide-react';
5+
import { HandshakeIcon, KeyIcon, PieChartIcon, UsersIcon } from 'lucide-react';
56
import { Suspense } from 'react';
67

78
const sharedClasses = 'flex items-center p-2 rounded-lg group';
@@ -27,6 +28,8 @@ export function ConfigIndex() {
2728

2829
function DesktopConfigNavBar() {
2930
const params = useParams({ strict: false });
31+
const canManage = useInstanceManagePermission();
32+
3033
return (
3134
<div className="hidden md:block pl-4 pt-4">
3235
<Link
@@ -39,24 +42,31 @@ function DesktopConfigNavBar() {
3942
<PieChartIcon className="inline-block" /> <span className="ms-3">Overview</span>
4043
</Link>
4144

42-
<ul className="border-t border-gray-700 pt-4 mt-4 space-y-2">
45+
{canManage && <ul className="border-t border-gray-700 pt-4 mt-4 space-y-2">
4346
<li>
4447
<Link to={buildAbsoluteLinkToPage(params, 'config/users')} className={sharedClasses} inactiveProps={inactiveProps} activeProps={activeProps}>
45-
<Users className="inline-block" /> <span className="ms-3">Users</span>
48+
<UsersIcon className="inline-block" /> <span className="ms-3">Users</span>
4649
</Link>
4750
</li>
4851
<li>
4952
<Link to={buildAbsoluteLinkToPage(params, 'config/roles')} className={sharedClasses} inactiveProps={inactiveProps} activeProps={activeProps}>
50-
<Handshake className="inline-block" /> <span className="ms-3">Roles</span>
53+
<HandshakeIcon className="inline-block" /> <span className="ms-3">Roles</span>
54+
</Link>
55+
</li>
56+
<li>
57+
<Link to={buildAbsoluteLinkToPage(params, 'config/ssh-keys')} className={sharedClasses} inactiveProps={inactiveProps} activeProps={activeProps}>
58+
<KeyIcon className="inline-block" /> <span className="ms-3">SSH Keys</span>
5159
</Link>
5260
</li>
53-
</ul>
61+
</ul>}
5462
</div>
5563
);
5664
}
5765

5866
function MobileConfigNavBar() {
5967
const params = useParams({ strict: false });
68+
const canManage = useInstanceManagePermission();
69+
6070
return (
6171
<ul className="flex space-x-4 md:hidden py-2 px-4">
6272
<li>
@@ -70,16 +80,23 @@ function MobileConfigNavBar() {
7080
<PieChartIcon className="inline-block" /> <span className="ms-3">Overview</span>
7181
</Link>
7282
</li>
73-
<li>
74-
<Link to={buildAbsoluteLinkToPage(params, 'config/users')} className={sharedClasses} inactiveProps={inactiveProps} activeProps={activeProps}>
75-
<Users className="inline-block" /> <span className="ms-3">Users</span>
76-
</Link>
77-
</li>
78-
<li>
79-
<Link to={buildAbsoluteLinkToPage(params, 'config/roles')} className={sharedClasses} inactiveProps={inactiveProps} activeProps={activeProps}>
80-
<Handshake className="inline-block" /> <span className="ms-3">Roles</span>
81-
</Link>
82-
</li>
83+
{canManage && (<>
84+
<li>
85+
<Link to={buildAbsoluteLinkToPage(params, 'config/users')} className={sharedClasses} inactiveProps={inactiveProps} activeProps={activeProps}>
86+
<UsersIcon className="inline-block" /> <span className="ms-3">Users</span>
87+
</Link>
88+
</li>
89+
<li>
90+
<Link to={buildAbsoluteLinkToPage(params, 'config/roles')} className={sharedClasses} inactiveProps={inactiveProps} activeProps={activeProps}>
91+
<HandshakeIcon className="inline-block" /> <span className="ms-3">Roles</span>
92+
</Link>
93+
</li>
94+
<li>
95+
<Link to={buildAbsoluteLinkToPage(params, 'config/ssh-keys')} className={sharedClasses} inactiveProps={inactiveProps} activeProps={activeProps}>
96+
<KeyIcon className="inline-block" /> <span className="ms-3">SSH Keys</span>
97+
</Link>
98+
</li>
99+
</>)}
83100
</ul>
84101
);
85102
}

src/features/instance/config/routes.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { createRoute } from '@tanstack/react-router';
21
import { ConfigIndex } from '@/features/instance/config/index';
32
import { ConfigOverviewIndex } from '@/features/instance/config/overview';
43
import { ConfigRolesIndex } from '@/features/instance/config/roles';
4+
import { ConfigSSHKeysIndex } from '@/features/instance/config/sshKeys';
55
import { ConfigUsersIndex } from '@/features/instance/config/users';
66
import { createInstanceLayoutRoute } from '@/features/instance/instanceLayoutRoute';
7+
import { createRoute } from '@tanstack/react-router';
78

89
export function createConfigRouteTree(instanceLayoutRoute: ReturnType<typeof createInstanceLayoutRoute>) {
910
const instanceConfigRoute = createRoute({
@@ -16,6 +17,7 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType<typeof cre
1617
path: '/',
1718
component: ConfigOverviewIndex,
1819
});
20+
1921
const instanceConfigRolesRoute = createRoute({
2022
getParentRoute: () => instanceConfigRoute,
2123
path: 'roles',
@@ -26,6 +28,7 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType<typeof cre
2628
path: 'roles/$roleId',
2729
component: ConfigRolesIndex,
2830
});
31+
2932
const instanceConfigUsersRoute = createRoute({
3033
getParentRoute: () => instanceConfigRoute,
3134
path: 'users',
@@ -37,11 +40,27 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType<typeof cre
3740
component: ConfigUsersIndex,
3841
});
3942

43+
const instanceConfigSSHKeysRoute = createRoute({
44+
getParentRoute: () => instanceConfigRoute,
45+
path: 'ssh-keys',
46+
component: ConfigSSHKeysIndex,
47+
});
48+
const instanceConfigSSHKeyRoute = createRoute({
49+
getParentRoute: () => instanceConfigRoute,
50+
path: 'ssh-keys/$keyName',
51+
component: ConfigSSHKeysIndex,
52+
});
53+
4054
return instanceConfigRoute.addChildren([
4155
instanceOverviewRoute,
56+
4257
instanceConfigRolesRoute,
4358
instanceConfigRoleRoute,
59+
4460
instanceConfigUsersRoute,
4561
instanceConfigUserRoute,
62+
63+
instanceConfigSSHKeysRoute,
64+
instanceConfigSSHKeyRoute,
4665
]);
4766
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Button } from '@/components/ui/button';
2+
import { Form } from '@/components/ui/form/Form';
3+
import { FormControl } from '@/components/ui/form/FormControl';
4+
import { FormDescription } from '@/components/ui/form/FormDescription';
5+
import { FormField } from '@/components/ui/form/FormField';
6+
import { FormItem } from '@/components/ui/form/FormItem';
7+
import { FormLabel } from '@/components/ui/form/FormLabel';
8+
import { FormMessage } from '@/components/ui/form/FormMessage';
9+
import { Textarea } from '@/components/ui/textarea';
10+
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
11+
import { getSSHKnownHostsQueryOptions } from '@/integrations/api/instance/ssh/getSSHKnownHosts';
12+
import { SSHKnownHostsSchema, useSetSSHKnownHosts } from '@/integrations/api/instance/ssh/setSSHKnownHosts';
13+
import { zodResolver } from '@hookform/resolvers/zod';
14+
import { useSuspenseQuery } from '@tanstack/react-query';
15+
import { Save } from 'lucide-react';
16+
import { useCallback } from 'react';
17+
import { useForm } from 'react-hook-form';
18+
import { toast } from 'sonner';
19+
import { z } from 'zod';
20+
21+
export function KnownHosts() {
22+
const instanceParams = useInstanceClientIdParams();
23+
const { data } = useSuspenseQuery(getSSHKnownHostsQueryOptions(instanceParams));
24+
const form = useForm({
25+
resolver: zodResolver(SSHKnownHostsSchema),
26+
defaultValues: {
27+
known_hosts: data?.known_hosts || '',
28+
},
29+
});
30+
const { mutate: setSSHKnownHosts, isPending } = useSetSSHKnownHosts();
31+
32+
const onSubmitClick = useCallback(
33+
async (formData: z.infer<typeof SSHKnownHostsSchema>) => {
34+
const { known_hosts } = formData;
35+
if (known_hosts) {
36+
setSSHKnownHosts(
37+
{
38+
known_hosts: known_hosts.trim() + '\n',
39+
...instanceParams,
40+
},
41+
{
42+
onSuccess: () => {
43+
form.reset(formData);
44+
toast.success('Known hosts saved!');
45+
},
46+
},
47+
);
48+
}
49+
},
50+
[setSSHKnownHosts, form, instanceParams],
51+
);
52+
53+
return (
54+
<Form {...form}>
55+
<form onSubmit={form.handleSubmit(onSubmitClick)} className="grid my-4 md:grid-cols-2 overflow-x-auto rounded-md">
56+
57+
<FormField
58+
control={form.control}
59+
name="known_hosts"
60+
render={({ field }) => (
61+
<FormItem className="md:col-span-2 gap-0">
62+
<FormLabel className="bg-black-dark py-3 px-2 m-0 text-left font-medium whitespace-nowrap">
63+
Known Hosts</FormLabel>
64+
<div className="border border-grey-700 p-2 pb-3">
65+
66+
<FormDescription>
67+
Manage your known hosts here. When you add a SSH Key with a hostname of "github.com", we'll
68+
automatically attempt to resolve GitHub's known hosts for you.
69+
</FormDescription>
70+
<FormControl>
71+
<Textarea
72+
autoComplete="off"
73+
autoCapitalize="off"
74+
className="whitespace-nowrap"
75+
rows={10}
76+
{...field}
77+
/>
78+
</FormControl>
79+
<FormMessage />
80+
81+
<Button type="submit" variant="submit" className="rounded-full mt-2" disabled={isPending || !form.formState.isDirty || !form.formState.isValid}>
82+
<Save /> {isPending ? 'Saving' : 'Save'} Known Hosts{isPending ? '...' : ''}
83+
</Button>
84+
</div>
85+
</FormItem>
86+
)}
87+
/>
88+
</form>
89+
</Form>
90+
);
91+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { SSHKeyName } from '@/integrations/api/instance/ssh/listSSHKeys';
2+
import { ColumnDef } from '@tanstack/react-table';
3+
4+
export const dataTableColumns: Array<ColumnDef<SSHKeyName>> = [
5+
{
6+
header: 'SSH Key Name',
7+
accessorKey: 'name',
8+
enableSorting: false,
9+
},
10+
];

0 commit comments

Comments
 (0)