diff --git a/package.json b/package.json index 506e4acebb0789e46a4b5e862ddd37db572e30c8..f097ac4d864f9284e1fed0df74b8b29c84042335 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,15 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.476.0", + "next-themes": "^0.4.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.2.0", + "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.9", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^5.0.3" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6402b2a3eb9b68c381020d067ce4d625bb434203..bb572731c8c633724351ec44a7756804e08e587c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: lucide-react: specifier: ^0.476.0 version: 0.476.0(react@19.0.0) + next-themes: + specifier: ^0.4.4 + version: 0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: specifier: ^19.0.0 version: 19.0.0 @@ -53,6 +56,9 @@ importers: react-router: specifier: ^7.2.0 version: 7.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + sonner: + specifier: ^2.0.1 + version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) tailwind-merge: specifier: ^3.0.2 version: 3.0.2 @@ -62,6 +68,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.0.9) + zustand: + specifier: ^5.0.3 + version: 5.0.3(@types/react@19.0.10)(react@19.0.0) devDependencies: '@eslint/js': specifier: ^9.21.0 @@ -1383,6 +1392,12 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-themes@0.4.4: + resolution: {integrity: sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1513,6 +1528,12 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + sonner@2.0.1: + resolution: {integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1651,6 +1672,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@esbuild/aix-ppc64@0.25.0': @@ -2780,6 +2819,11 @@ snapshots: natural-compare@1.4.0: {} + next-themes@0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2910,6 +2954,11 @@ snapshots: shebang-regex@3.0.0: {} + sonner@2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + source-map-js@1.2.1: {} strip-json-comments@3.1.1: {} @@ -2995,3 +3044,8 @@ snapshots: word-wrap@1.2.5: {} yocto-queue@0.1.0: {} + + zustand@5.0.3(@types/react@19.0.10)(react@19.0.0): + optionalDependencies: + '@types/react': 19.0.10 + react: 19.0.0 diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index d781efd81cbcf6a990dc79394e8b62a5e20e8d62..e641857b9f16bec46204e2a6a585c3e5c6a0d152 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Link } from 'react-router'; +import { useAuthStore } from '@/stores/authStore'; import { Router, ShieldCheck, HardDrive, CircleHelp } from 'lucide-react'; import { Sidebar, @@ -50,11 +51,15 @@ const data = { }; export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { + const { token } = useAuthStore(); + return ( <Sidebar className="top-(--header-height) h-[calc(100svh-var(--header-height))]!" {...props}> - <SidebarHeader> - <ProjectSwitcher projects={data.projects} defaultProject={data.projects[0]} /> - </SidebarHeader> + {token ? ( + <SidebarHeader> + <ProjectSwitcher projects={data.projects} defaultProject={data.projects[0]} /> + </SidebarHeader> + ) : null} <SidebarContent> {data.menus.map((item) => ( <SidebarGroup key={item.title}> @@ -63,7 +68,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { <SidebarMenu> {item.items.map((item) => ( <SidebarMenuItem key={item.title}> - <SidebarMenuButton asChild isActive={item.isActive}> + <SidebarMenuButton + asChild + isActive={item.isActive} + className={token ? '' : 'cursor-not-allowed opacity-50'} + > <Link to={item.url}> <item.icon /> {item.title} diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx index dc6244a255972a2def800e574bd4be23735ab401..104389e11cb487ab0b683400861bd6d7ddcf09a1 100644 --- a/src/components/login-form.tsx +++ b/src/components/login-form.tsx @@ -1,10 +1,32 @@ +import React from 'react'; +import { useNavigate } from 'react-router'; import { cn } from '@/lib/utils'; +import { useAuthStore } from '@/stores/authStore'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { + const { login } = useAuthStore(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const username = formData.get('username') as string; + const password = formData.get('password') as string; + + try { + await login(username, password); + navigate('/'); + } catch (err) { + console.error(err); + toast.error('로그인에 실패했습니다'); + } + }; + return ( <div className={cn('flex flex-col gap-6', className)} {...props}> <Card> @@ -13,15 +35,15 @@ export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRe <CardDescription>아올다 통합 계정을 사용하여 로그인합니다</CardDescription> </CardHeader> <CardContent> - <form> + <form onSubmit={handleSubmit}> <div className="flex flex-col gap-6"> <div className="grid gap-2"> <Label htmlFor="username">사용자 이름</Label> - <Input id="username" type="text" placeholder="사용자 이름" required /> + <Input id="username" name="username" type="text" placeholder="사용자 이름" required /> </div> <div className="grid gap-2"> <Label htmlFor="password">비밀번호</Label> - <Input id="password" type="password" placeholder="비밀번호" required /> + <Input id="password" name="password" type="password" placeholder="비밀번호" required /> </div> <Button type="submit" className="w-full"> 로그인 diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index ed855e94abe7f27f663a7e1c00bdc58fc3f147bf..ef6ec73708bf715616f6f585e07791d72b29ce93 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -1,10 +1,27 @@ -import { Link } from 'react-router'; -import { MenuIcon } from 'lucide-react'; +import { Link, useNavigate } from 'react-router'; +import { UserRound, LogOut, MenuIcon } from 'lucide-react'; +import { useAuthStore } from '@/stores/authStore'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { useSidebar } from '@/components/ui/sidebar'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, +} from '@/components/ui/dropdown-menu'; export function SiteHeader() { + const navigate = useNavigate(); const { toggleSidebar } = useSidebar(); + const { token, logout } = useAuthStore(); + + const handleLogout = () => { + logout(); + navigate('/'); + }; return ( <header className="flex bg-background sticky top-0 z-50 w-full items-center border-b"> @@ -15,6 +32,29 @@ export function SiteHeader() { <Link to="/" className="flex items-center gap-2 text-lg font-semibold md:text-base"> <span className="whitespace-nowrap">Aolda Cloud</span> </Link> + {token ? ( + <DropdownMenu> + <DropdownMenuTrigger asChild className="ml-auto cursor-pointer"> + <Avatar className="w-10 h-10 border"> + <AvatarFallback>{'admin'.charAt(0)}</AvatarFallback> + </Avatar> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuLabel>닉네임</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={handleLogout} className="cursor-pointer"> + <LogOut /> + 로그아웃 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) : ( + <Button className="w-auto ml-auto" asChild> + <Link to="/login"> + <UserRound /> 로그인 + </Link> + </Button> + )} </div> </header> ); diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..afc29d12dc2d2cd288365243c67b9d5f51f9294d --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,26 @@ +import { useTheme } from 'next-themes'; +import { Toaster as Sonner, ToasterProps } from 'sonner'; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme(); + + return ( + <Sonner + theme={theme as ToasterProps['theme']} + className="toaster group" + toastOptions={{ + classNames: { + toast: + 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg', + description: 'group-[.toast]:text-muted-foreground', + actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium', + cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium', + icon: 'group-data-[type=error]:text-red-500 group-data-[type=success]:text-green-500 group-data-[type=warning]:text-amber-500 group-data-[type=info]:text-blue-500', + }, + }} + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/src/index.css b/src/index.css index c2b3587aa531c4185c1ca6a209783c22a6a60165..172b2f1d5fe0c600c2b7098648c8e6d995dc324e 100644 --- a/src/index.css +++ b/src/index.css @@ -12,7 +12,6 @@ } :root { - font-family: 'Pretendard', sans-serif; --background: oklch(1 0 0); --foreground: oklch(0.129 0.042 264.695); --card: oklch(1 0 0); @@ -125,9 +124,20 @@ @layer base { * { @apply border-border outline-ring/50; + font-family: 'Pretendard', sans-serif; } body { @apply bg-background text-foreground; } + + button, + [role='button'] { + cursor: pointer; + } + + button:disabled, + [role='button']:disabled { + cursor: default; + } } diff --git a/src/pages/Root.tsx b/src/pages/Root.tsx index c90f8392ec69eb76f5f75ca2dc52051831a1a919..dc8b7a0a21bf98fce4ff6e9f02393b0c58f7040f 100644 --- a/src/pages/Root.tsx +++ b/src/pages/Root.tsx @@ -1,4 +1,5 @@ -import { Link, Outlet } from 'react-router'; +import { Outlet } from 'react-router'; +import { Toaster } from '@/components/ui/sonner'; import { AppSidebar } from '@/components/app-sidebar'; import { SiteHeader } from '@/components/site-header'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; @@ -15,6 +16,7 @@ export default function Root() { </SidebarInset> </div> </SidebarProvider> + <Toaster /> </div> ); } diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..28d337e6874565d7af2ff8df6b595d862c11e393 --- /dev/null +++ b/src/stores/authStore.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface AuthStore { + token: string | null; + login: (username: string, password: string) => void; + logout: () => void; +} + +export const useAuthStore = create<AuthStore>()( + persist( + (set) => ({ + token: null, + login: async (username, password) => { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: username, password }), + }); + + if (!response.ok) { + throw new Error('로그인 실패'); + } + + const token = response.headers.get('X-Subject-Token')!; + console.log(token); + + set({ token }); + }, + logout: () => set({ token: null }), + }), + { name: 'authStorage' } + ) +);