Skip to content
Snippets Groups Projects
Commit 25861597 authored by 한동현's avatar 한동현
Browse files

feat: 로그인 API 연동 및 상태 관리 추가

parent 71a6eef1
No related branches found
No related tags found
No related merge requests found
...@@ -22,12 +22,15 @@ ...@@ -22,12 +22,15 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.476.0", "lucide-react": "^0.476.0",
"next-themes": "^0.4.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router": "^7.2.0", "react-router": "^7.2.0",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.9", "tailwindcss": "^4.0.9",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.21.0", "@eslint/js": "^9.21.0",
......
...@@ -44,6 +44,9 @@ importers: ...@@ -44,6 +44,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.476.0 specifier: ^0.476.0
version: 0.476.0(react@19.0.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: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.0.0 version: 19.0.0
...@@ -53,6 +56,9 @@ importers: ...@@ -53,6 +56,9 @@ importers:
react-router: react-router:
specifier: ^7.2.0 specifier: ^7.2.0
version: 7.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.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: tailwind-merge:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.2 version: 3.0.2
...@@ -62,6 +68,9 @@ importers: ...@@ -62,6 +68,9 @@ importers:
tailwindcss-animate: tailwindcss-animate:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.0.9) 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: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.21.0 specifier: ^9.21.0
...@@ -1383,6 +1392,12 @@ packages: ...@@ -1383,6 +1392,12 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} 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: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
...@@ -1513,6 +1528,12 @@ packages: ...@@ -1513,6 +1528,12 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'} 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: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
...@@ -1651,6 +1672,24 @@ packages: ...@@ -1651,6 +1672,24 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} 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: snapshots:
'@esbuild/aix-ppc64@0.25.0': '@esbuild/aix-ppc64@0.25.0':
...@@ -2780,6 +2819,11 @@ snapshots: ...@@ -2780,6 +2819,11 @@ snapshots:
natural-compare@1.4.0: {} 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: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
...@@ -2910,6 +2954,11 @@ snapshots: ...@@ -2910,6 +2954,11 @@ snapshots:
shebang-regex@3.0.0: {} 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: {} source-map-js@1.2.1: {}
strip-json-comments@3.1.1: {} strip-json-comments@3.1.1: {}
...@@ -2995,3 +3044,8 @@ snapshots: ...@@ -2995,3 +3044,8 @@ snapshots:
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
yocto-queue@0.1.0: {} 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
import * as React from 'react'; import * as React from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useAuthStore } from '@/stores/authStore';
import { Router, ShieldCheck, HardDrive, CircleHelp } from 'lucide-react'; import { Router, ShieldCheck, HardDrive, CircleHelp } from 'lucide-react';
import { import {
Sidebar, Sidebar,
...@@ -50,11 +51,15 @@ const data = { ...@@ -50,11 +51,15 @@ const data = {
}; };
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { token } = useAuthStore();
return ( return (
<Sidebar className="top-(--header-height) h-[calc(100svh-var(--header-height))]!" {...props}> <Sidebar className="top-(--header-height) h-[calc(100svh-var(--header-height))]!" {...props}>
{token ? (
<SidebarHeader> <SidebarHeader>
<ProjectSwitcher projects={data.projects} defaultProject={data.projects[0]} /> <ProjectSwitcher projects={data.projects} defaultProject={data.projects[0]} />
</SidebarHeader> </SidebarHeader>
) : null}
<SidebarContent> <SidebarContent>
{data.menus.map((item) => ( {data.menus.map((item) => (
<SidebarGroup key={item.title}> <SidebarGroup key={item.title}>
...@@ -63,7 +68,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { ...@@ -63,7 +68,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenu> <SidebarMenu>
{item.items.map((item) => ( {item.items.map((item) => (
<SidebarMenuItem key={item.title}> <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}> <Link to={item.url}>
<item.icon /> <item.icon />
{item.title} {item.title}
......
import React from 'react';
import { useNavigate } from 'react-router';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/authStore';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { 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 ( return (
<div className={cn('flex flex-col gap-6', className)} {...props}> <div className={cn('flex flex-col gap-6', className)} {...props}>
<Card> <Card>
...@@ -13,15 +35,15 @@ export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRe ...@@ -13,15 +35,15 @@ export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRe
<CardDescription>아올다 통합 계정을 사용하여 로그인합니다</CardDescription> <CardDescription>아올다 통합 계정을 사용하여 로그인합니다</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form> <form onSubmit={handleSubmit}>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="username">사용자 이름</Label> <Label htmlFor="username">사용자 이름</Label>
<Input id="username" type="text" placeholder="사용자 이름" required /> <Input id="username" name="username" type="text" placeholder="사용자 이름" required />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="password">비밀번호</Label> <Label htmlFor="password">비밀번호</Label>
<Input id="password" type="password" placeholder="비밀번호" required /> <Input id="password" name="password" type="password" placeholder="비밀번호" required />
</div> </div>
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">
로그인 로그인
......
import { Link } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { MenuIcon } from 'lucide-react'; 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 { Button } from '@/components/ui/button';
import { useSidebar } from '@/components/ui/sidebar'; import { useSidebar } from '@/components/ui/sidebar';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu';
export function SiteHeader() { export function SiteHeader() {
const navigate = useNavigate();
const { toggleSidebar } = useSidebar(); const { toggleSidebar } = useSidebar();
const { token, logout } = useAuthStore();
const handleLogout = () => {
logout();
navigate('/');
};
return ( return (
<header className="flex bg-background sticky top-0 z-50 w-full items-center border-b"> <header className="flex bg-background sticky top-0 z-50 w-full items-center border-b">
...@@ -15,6 +32,29 @@ export function SiteHeader() { ...@@ -15,6 +32,29 @@ export function SiteHeader() {
<Link to="/" className="flex items-center gap-2 text-lg font-semibold md:text-base"> <Link to="/" className="flex items-center gap-2 text-lg font-semibold md:text-base">
<span className="whitespace-nowrap">Aolda Cloud</span> <span className="whitespace-nowrap">Aolda Cloud</span>
</Link> </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> </div>
</header> </header>
); );
......
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 };
...@@ -12,7 +12,6 @@ ...@@ -12,7 +12,6 @@
} }
:root { :root {
font-family: 'Pretendard', sans-serif;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695); --foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0); --card: oklch(1 0 0);
...@@ -125,9 +124,20 @@ ...@@ -125,9 +124,20 @@
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
font-family: 'Pretendard', sans-serif;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
button,
[role='button'] {
cursor: pointer;
}
button:disabled,
[role='button']:disabled {
cursor: default;
}
} }
import { Link, Outlet } from 'react-router'; import { Outlet } from 'react-router';
import { Toaster } from '@/components/ui/sonner';
import { AppSidebar } from '@/components/app-sidebar'; import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header'; import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
...@@ -15,6 +16,7 @@ export default function Root() { ...@@ -15,6 +16,7 @@ export default function Root() {
</SidebarInset> </SidebarInset>
</div> </div>
</SidebarProvider> </SidebarProvider>
<Toaster />
</div> </div>
); );
} }
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' }
)
);
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment