Skip to content

Commit

Permalink
feat(frontend): Implement AWS profile loading and switching
Browse files Browse the repository at this point in the history
Updated the profile navigation to fetch and display AWS profiles.
Users can now select different AWS profiles from a dropdown menu.
Furthermore, added a profile context to manage the current profile
state at a global level. Additional dependencies were also included
to facilitate these changes.

Refs: #1
  • Loading branch information
maikbasel committed Jan 9, 2024
1 parent 16ae006 commit 1beefe6
Show file tree
Hide file tree
Showing 8 changed files with 221 additions and 41 deletions.
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,17 @@
"lucide-react": "^0.303.0",
"next": "14.0.4",
"next-themes": "^0.2.1",
"oxide.ts": "^1.1.0",
"postcss": "8.4.31",
"react": "18.2.0",
"react-dom": "18.2.0",
"secure-string": "^1.2.1",
"swr": "^2.2.4",
"tailwind-merge": "^2.2.0",
"tailwindcss": "3.3.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.2.2",
"zod": "^3.22.4",
"zustand": "^4.4.7"
},
"devDependencies": {
Expand Down
13 changes: 8 additions & 5 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { ReactNode } from 'react';
import Sidebar from '@/sections/dashboard/components/sidebar/sidebar';
import Header from '@/sections/dashboard/components/header/header';
import { ThemeProvider } from '@/sections/dashboard/components/header/theme-provider';
import { ProfileProvider } from '@/sections/dashboard/context/profile-context';

export const metadata: Metadata = {
title: 'Create Next App',
Expand All @@ -24,11 +25,13 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
enableSystem
disableTransitionOnChange
>
<Header />
<div className='flex min-h-screen bg-background'>
<Sidebar />
<main className='flex-1'>{children}</main>
</div>
<ProfileProvider>
<Header />
<div className='flex min-h-screen bg-background'>
<Sidebar />
<main className='flex-1'>{children}</main>
</div>
</ProfileProvider>
</ThemeProvider>
</body>
</html>
Expand Down
137 changes: 102 additions & 35 deletions src/sections/dashboard/components/header/profile-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,33 @@ import {
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import React from 'react';
import React, { useEffect, useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useProfileContext } from '@/sections/dashboard/context/profile-context';
import { z } from 'zod';

// TODO:
// - Load available profiles
// - Truncate profile names longer than 4 characters
// - Switch profile on click in dropdown
// - Pick default profile as default
// - Handle errors

interface ProfileNavItemProps {
profileName: string;
accessKeyId: string;
region: string;
onClick: () => void;
}

export const ProfileNavItem: React.FC<ProfileNavItemProps> = ({
profileName,
accessKeyId,
region,
onClick,
}) => (
<DropdownMenuItem>
<Button
variant='ghost'
className='relative w-full items-center justify-start gap-2 p-2'
onClick={onClick}
>
<Avatar className='h-9 w-9'>
<AvatarFallback>{profileName}</AvatarFallback>
Expand All @@ -47,48 +50,112 @@ export const ProfileNavItem: React.FC<ProfileNavItemProps> = ({
</DropdownMenuItem>
);

type Settings = {
credentials: {
access_key_id: string;
secret_access_key: string;
};
config: {
region: string;
output_format: string;
};
};

type ProfileSet = {
profiles: Record<string, Settings>;
errors: Record<string, string[]>;
};

const profileSetSchema = z.object({
profiles: z.record(
z.object({
credentials: z.object({
access_key_id: z.string(),
secret_access_key: z.string(),
}),
config: z.object({
region: z.string(),
output_format: z.string(),
}),
})
),
errors: z.record(z.string().array()),
});

export function ProfileNav() {
const [open, setOpen] = React.useState(false);
const [open, setOpen] = useState(false);
const { data, error, isLoading } = useProfileContext();
const [currentProfile, setCurrentProfile] = useState<string>();
const [profileSet, setProfileSet] = useState<ProfileSet>();

useEffect(() => {
console.info('0', data, error, isLoading);
if (!isLoading) {
const parsed: ProfileSet = profileSetSchema.parse(data);
setProfileSet(parsed);

const initialProfile = Object.keys(parsed.profiles)[0];
setCurrentProfile(initialProfile);
}
}, [data, error, isLoading]);

return (
<DropdownMenu onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
aria-expanded={open}
aria-haspopup='true'
className='flex items-center justify-start gap-2 p-2'
>
<Avatar className='h-9 w-9'>
<AvatarFallback>DEV</AvatarFallback>
</Avatar>
{isLoading ? (
<div>Loading...</div>
) : (
<Button
variant='outline'
aria-expanded={open}
aria-haspopup='true'
className='flex items-center justify-start gap-2 p-2'
// disabled={profileSet!.profiles.size < 2}
>
<Avatar className='h-9 w-9'>
<AvatarFallback>{currentProfile}</AvatarFallback>
</Avatar>

<div className='flex items-center justify-start gap-2 p-2'>
<div className='flex flex-col space-y-1 leading-none'>
<p className='truncate font-medium'>123456789</p>
<p className='truncate text-sm text-zinc-700'>eu-west-1</p>
<div className='flex items-center justify-start gap-2 p-2'>
<div className='flex flex-col space-y-1 leading-none'>
<p className='truncate font-medium'>
{profileSet?.profiles?.[currentProfile!].config.region ?? '?'}
</p>
<p className='truncate text-sm text-zinc-700'>
{profileSet?.profiles?.[currentProfile!].config
.output_format ?? '?'}
</p>
</div>
</div>
</div>

{open ? (
<ChevronUp className='ml-2 h-4 w-4 shrink-0 opacity-50' />
) : (
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
)}
</Button>
{open ? (
profileSet && Object.keys(profileSet.profiles).length > 1 ? (
<ChevronUp className='ml-2 h-4 w-4 shrink-0 opacity-50' />
) : (
<></>
)
) : profileSet && Object.keys(profileSet.profiles).length > 1 ? (
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
) : (
<></>
)}
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' forceMount>
<DropdownMenuGroup>
<ProfileNavItem
profileName='QA'
accessKeyId='124816321'
region='eu-west-1'
/>
<ProfileNavItem
profileName='PROD'
accessKeyId='987654321'
region='eu-west-1'
/>
{profileSet &&
Object.entries(profileSet?.profiles)
.filter(([profile]) => profile !== currentProfile)
.map(([profile, settings]) => (
<ProfileNavItem
key={profile}
profileName={profile}
accessKeyId={settings.config.region ?? '?'}
region={settings.config.output_format ?? '?'}
onClick={() => setCurrentProfile(profile)}
/>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
Expand Down
50 changes: 50 additions & 0 deletions src/sections/dashboard/context/profile-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client';

import React, {
createContext,
PropsWithChildren,
useContext,
useMemo,
} from 'react';
import useSWR, { Fetcher } from 'swr';
// see https://stackoverflow.com/a/77264549
import { invoke } from '@tauri-apps/api/tauri';

type ProfileContextType = {
data: Record<string, unknown> | undefined;
error: Error | undefined;
isLoading: boolean;
};

export const ProfileContext = createContext<ProfileContextType>({
data: undefined,
error: undefined,
isLoading: true,
});

export const ProfileProvider = ({ children }: PropsWithChildren) => {
const fetcher: Fetcher<Record<string, never>, string> = (cmd: string) =>
invoke<Record<string, never>>(cmd);
const { data, error, isLoading } = useSWR<Record<string, never>, Error>(
'get_profiles',
fetcher
);

const value = useMemo(
() => ({ data, error, isLoading }),
[data, error, isLoading]
);
return (
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
);
};

export const useProfileContext = () => {
const context = useContext(ProfileContext);

if (!context) {
throw new Error('useThemeContext must be used inside the ThemeProvider');
}

return context;
};
18 changes: 18 additions & 0 deletions src/sections/dashboard/hooks/use-profile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use client';

import useSWR, { Fetcher } from 'swr';
import { invoke } from '@tauri-apps/api/tauri'; // see https://stackoverflow.com/a/77264549

import { ProfileSet } from '@/modules/profiles/profile';
export const useProfile = () => {
const fetcher: Fetcher<ProfileSet, string> = (cmd: string) =>
invoke<ProfileSet>(cmd);

const {
data: profileSet,
error,
isLoading,
} = useSWR<ProfileSet>('get_profiles', fetcher);

return { profileSet, error, isLoading };
};
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
],
"paths": {
"@/*": ["./src/*"]
}
},
"typeRoots": ["./node_modules/@types", "./types"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "src-tauri"]
Expand Down
3 changes: 3 additions & 0 deletions types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module 'secure-string' {
export declare class SecureString {}
}

0 comments on commit 1beefe6

Please sign in to comment.