From f782f7937ffeba631cd817cf90096ba3178adfa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=B8=EB=B9=88=20=EA=B6=8C?= Date: Mon, 20 Apr 2026 16:22:12 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EB=B0=8F=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 6 - .env.example | 13 ++ .env.production | 11 ++ .gitignore | 3 + docs/CONTRIBUTING.md | 61 +++---- firebase.json | 11 +- src/App.css | 2 - src/api/auth/auth-api.ts | 63 +++++++ src/api/auth/google-profile-api.ts | 90 ++++++++++ src/api/auth/index.ts | 12 ++ src/app/(auth)/login/page.tsx | 2 +- src/app/home/page.tsx | 5 + src/assets/react.svg | 1 - src/entities/index.ts | 2 - src/entities/user/index.ts | 2 - src/features/auth/index.ts | 2 - src/features/index.ts | 2 - src/lib/auth/auth-error-message.ts | 44 +++++ src/lib/auth/auth-service.ts | 80 +++++++++ src/lib/auth/index.ts | 3 + src/screens/home/home.stories.tsx | 17 ++ src/screens/home/home.tsx | 13 ++ src/{views => screens}/home/index.ts | 0 src/{views => screens}/index.ts | 2 +- src/screens/login/company-login-form.tsx | 74 +++++++++ src/screens/login/index.tsx | 151 +++++++++++++++++ src/{views => screens}/login/login.module.css | 0 src/screens/login/student-login-panel.tsx | 32 ++++ src/shared/ui/avatars/avatars.stories.tsx | 3 +- src/shared/ui/checkbox/checkbox.stories.tsx | 12 +- .../empty-states.stories.tsx | 0 .../empty-states.tsx | 0 .../file-uploader/file-uploader.stories.tsx | 3 +- src/shared/ui/form/form.stories.tsx | 2 +- src/shared/ui/index.ts | 3 +- src/shared/ui/input/index.ts | 2 + src/shared/ui/input/index.tsx | 4 - .../input.stories.tsx} | 4 +- .../inputBox.tsx => input/input.tsx} | 0 src/shared/ui/lists/lists.stories.tsx | 4 +- src/shared/ui/modal/modal.stories.tsx | 2 +- src/views/home/home.stories.tsx | 33 ---- src/views/home/home.tsx | 66 -------- src/views/login/index.tsx | 156 ------------------ src/widgets/index.ts | 1 - tsconfig.app.json | 7 +- tsconfig.json | 15 +- 47 files changed, 682 insertions(+), 339 deletions(-) delete mode 100644 .env create mode 100644 .env.example create mode 100644 .env.production delete mode 100644 src/App.css create mode 100644 src/api/auth/auth-api.ts create mode 100644 src/api/auth/google-profile-api.ts create mode 100644 src/api/auth/index.ts create mode 100644 src/app/home/page.tsx delete mode 100644 src/assets/react.svg delete mode 100644 src/entities/index.ts delete mode 100644 src/entities/user/index.ts delete mode 100644 src/features/auth/index.ts delete mode 100644 src/features/index.ts create mode 100644 src/lib/auth/auth-error-message.ts create mode 100644 src/lib/auth/auth-service.ts create mode 100644 src/lib/auth/index.ts create mode 100644 src/screens/home/home.stories.tsx create mode 100644 src/screens/home/home.tsx rename src/{views => screens}/home/index.ts (100%) rename src/{views => screens}/index.ts (75%) create mode 100644 src/screens/login/company-login-form.tsx create mode 100644 src/screens/login/index.tsx rename src/{views => screens}/login/login.module.css (100%) create mode 100644 src/screens/login/student-login-panel.tsx rename src/shared/ui/{empty_states => empty-states}/empty-states.stories.tsx (100%) rename src/shared/ui/{empty_states => empty-states}/empty-states.tsx (100%) create mode 100644 src/shared/ui/input/index.ts delete mode 100644 src/shared/ui/input/index.tsx rename src/shared/ui/{inputBox/inputBox.stories.tsx => input/input.stories.tsx} (98%) rename src/shared/ui/{inputBox/inputBox.tsx => input/input.tsx} (100%) delete mode 100644 src/views/home/home.stories.tsx delete mode 100644 src/views/home/home.tsx delete mode 100644 src/views/login/index.tsx delete mode 100644 src/widgets/index.ts diff --git a/.env b/.env deleted file mode 100644 index 09d8b5f..0000000 --- a/.env +++ /dev/null @@ -1,6 +0,0 @@ -VITE_FIREBASE_API_KEY=AIzaSyABxF3nO6fVtgk-RQEwE5eYK5GKEt8diEI -VITE_FIREBASE_AUTH_DOMAIN=ajou-project-cafd9.firebaseapp.com -VITE_FIREBASE_PROJECT_ID=ajou-project-cafd9 -VITE_FIREBASE_STORAGE_BUCKET=ajou-project-cafd9.firebasestorage.app -VITE_FIREBASE_MESSAGING_SENDER_ID=926871086070 -VITE_FIREBASE_APP_ID=1:926871086070:web:9a01bad8170cb172f15dd7 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..42fd851 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Firebase Client SDK +# These values are exposed to the browser by Next.js. +NEXT_PUBLIC_FIREBASE_API_KEY= +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= +NEXT_PUBLIC_FIREBASE_PROJECT_ID= +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= +NEXT_PUBLIC_FIREBASE_APP_ID= + +# Spring Backend +# Leave empty in Firebase Hosting builds so /api/** uses firebase.json rewrites. +# Set this only for local direct-to-Cloud-Run testing. +NEXT_PUBLIC_BACKEND_API_BASE_URL= diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..2ca15e5 --- /dev/null +++ b/.env.production @@ -0,0 +1,11 @@ +# Firebase Client SDK +# These values are exposed to the browser by Next.js. +NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyABxF3nO6fVtgk-RQEwE5eYK5GKEt8diEI +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=ajou-project-cafd9.firebaseapp.com +NEXT_PUBLIC_FIREBASE_PROJECT_ID=ajou-project-cafd9 +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=ajou-project-cafd9.firebasestorage.app +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=926871086070 +NEXT_PUBLIC_FIREBASE_APP_ID=1:926871086070:web:9a01bad8170cb172f15dd7 + +# Keep empty in Firebase Hosting builds so /api/** uses firebase.json rewrites. +NEXT_PUBLIC_BACKEND_API_BASE_URL= diff --git a/.gitignore b/.gitignore index e5218f3..1ef639e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,10 @@ out .vercel dist dist-ssr +.env +.env.*.local *.local +*.tsbuildinfo # Editor directories and files .vscode/* diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 533677b..7740c38 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -51,7 +51,7 @@ pnpm lint - 앱 개발 기준은 `Next.js App Router`다. - Firebase Hosting은 유지하되, 배포는 `out/` 정적 산출물을 기준으로 한다. - 공개 페이지는 `SSG`, 로그인 이후 내부 페이지는 `CSR`을 기본 전략으로 삼는다. -- `src/app`는 라우팅과 레이아웃을 담당하고, 실제 화면 구현은 `src/views`에 둔다. +- `src/app`는 라우팅과 레이아웃을 담당하고, 실제 화면 구현은 `src/screens`에 둔다. - Vite 앱 엔트리(`src/main.tsx`, `index.html`)와 레거시 App 엔트리(`src/app/index.tsx`)는 더 이상 사용하지 않는다. 새로 합류한 팀원은 "Next.js로 실행하지만 모든 페이지를 SSR로 만드는 프로젝트는 아니다"라고 이해하면 가장 덜 헷갈린다. @@ -90,16 +90,13 @@ aim-frontend/ │ │ ├─ page.tsx # 기본 라우트 │ │ └─ globals.css # 전역 스타일 진입점 │ │ - │ ├─ views/ # 화면 레이어 (기존 pages 레이어 대체) - │ ├─ widgets/ # 위젯 레이어 (큰 UI 블록) - │ ├─ features/ # 기능 레이어 - │ ├─ entities/ # 엔티티 레이어 + │ ├─ api/ # 백엔드/외부 API 클라이언트 + │ ├─ lib/ # 앱 유스케이스와 공통 로직 + │ ├─ screens/ # 화면 구현 (기존 pages 레이어 대체) │ └─ shared/ # 공유 레이어 │ ├─ ui/ # 재사용 가능한 UI 컴포넌트 │ ├─ assets/ # 전역 에셋 (images, icons, fonts) │ ├─ config/ # 설정 (firebase.ts) - │ ├─ api/ # API 클라이언트 - │ ├─ lib/ # 유틸리티 함수 │ └─ types/ # 공통 타입 정의 │ ├─ docs/ # 문서 @@ -138,9 +135,11 @@ aim-frontend/ - 라우트 공통 레이아웃/metadata: - `src/app/**/layout.tsx` - 실제 화면 구현: - - `src/views/**` -- 큰 화면 블록 조합: - - `src/widgets/**` + - `src/screens/**` +- 백엔드/외부 API 호출: + - `src/api/**` +- Firebase 로그인 흐름, 세션 생성, 화면과 API 사이의 유스케이스: + - `src/lib/**` - 재사용 UI: - `src/shared/ui/**` - 전역 스타일 진입: @@ -153,31 +152,36 @@ aim-frontend/ - `/login` 라우트 파일: - `src/app/(auth)/login/page.tsx` - 로그인 화면 구현: - - `src/views/login/index.tsx` + - `src/screens/login/index.tsx` --- ## 아키텍처 -### FSD (Feature-Sliced Design) +### 레이어 기준 -레이어 의존 방향은 **단방향**입니다. 하위 레이어는 상위 레이어를 import할 수 없습니다. +현재 구조는 FSD를 엄격히 적용하기보다, Next.js App Router에 맞춘 컴팩트한 레이어 구조를 사용합니다. 의존 방향은 **단방향**입니다. ``` -app → views → widgets → features → entities → shared +app → screens → lib → api → shared ``` - `@/` alias를 사용해 절대 경로로 import -- Next.js App Router와 충돌하지 않도록 화면 레이어는 `src/views`를 사용 -- 각 슬라이스는 `index.ts`(public API)를 통해서만 export +- `src/app`은 URL, layout, metadata 같은 라우팅 책임만 갖는다. +- `src/screens`는 라우트가 렌더링하는 화면 조합을 담당한다. +- `src/lib`는 로그인, 세션 생성처럼 화면과 API 사이의 앱 로직을 담당한다. +- `src/api`는 fetch, 요청/응답 타입, 외부 API 호출만 담당한다. +- `src/shared`는 UI, config, asset처럼 도메인과 무관한 공통 자원만 둔다. ```ts // 올바른 import import { Button } from '@/shared/ui/button' -import { AuthFeature } from '@/features/auth' +import { signInWithGoogle } from '@/lib/auth' +import { loginWithBackend } from '@/api/auth' -// 잘못된 import (하위에서 상위 참조 금지) -// shared에서 features import 불가 +// 잘못된 import +// api에서 screens/lib import 금지 +// shared에서 api/lib/screens import 금지 ``` ### 인프라 구조 @@ -224,7 +228,7 @@ git checkout -b fix/header-layout Next.js 전환 작업이나 구조 변경이 포함되면 아래 항목을 함께 확인합니다. - 새 라우트가 `src/app` 기준으로 추가되었는가 -- 라우트 파일이 화면 구현까지 전부 떠안지 않고, 필요한 경우 `src/views`를 조합하는가 +- 라우트 파일이 화면 구현까지 전부 떠안지 않고, 필요한 경우 `src/screens`를 조합하는가 - SEO 대상 페이지인지, CSR 유지 페이지인지 분류했는가 - 공개 SEO 페이지라면 `SSG` 가능한 구조인지 먼저 검토했는가 - `use client`가 꼭 필요한 곳에만 선언되었는가 @@ -236,7 +240,7 @@ Next.js 전환 작업이나 구조 변경이 포함되면 아래 항목을 함 - `CSR 페이지`라고 해서 Vite를 다시 쓰는 것은 아니다. - Next.js 안에서도 Client Component 기반으로 충분히 구현할 수 있다. -- `src/app/page.tsx`는 라우트 파일이고, `src/views/**`는 화면 구현 파일이다. +- `src/app/page.tsx`는 라우트 파일이고, `src/screens/**`는 화면 구현 파일이다. - 라우트 정의와 화면 구현을 분리하는 것이 현재 기준이다. - `pnpm build` 결과물은 `.next`만 보는 것이 아니라 최종적으로 `out/` 배포를 염두에 둔다. - `/`는 지금 정적 export 환경을 고려해 클라이언트에서 `/login`으로 이동한다. @@ -296,10 +300,10 @@ enum ButtonVariant { Primary = "primary" } // 1. 외부 라이브러리 import { useState } from 'react' -// 2. 내부 alias (FSD 레이어 순서) +// 2. 내부 alias (레이어 순서) import { auth } from '@/shared/config/firebase' -import { UserEntity } from '@/entities/user' -import { AuthFeature } from '@/features/auth' +import { loginWithBackend } from '@/api/auth' +import { signInWithGoogle } from '@/lib/auth' // 3. 상대 경로 import { getButtonClasses } from './button.utils' @@ -375,11 +379,11 @@ export const Loading: Story = { ### 화면 개발 패턴 -현재 프로젝트에서는 "라우트 파일은 얇게, 화면 구현은 views로" 가져가는 것을 권장합니다. +현재 프로젝트에서는 "라우트 파일은 얇게, 화면 구현은 screens로" 가져가는 것을 권장합니다. ```tsx // src/app/(auth)/login/page.tsx -import { LoginPage } from "@/views" +import { LoginPage } from "@/screens" export default function LoginRoute() { return @@ -387,7 +391,7 @@ export default function LoginRoute() { ``` ```tsx -// src/views/login/index.tsx +// src/screens/login/index.tsx "use client" export function LoginPage() { @@ -451,7 +455,7 @@ function LikeButton() { ## API 호출 가이드 -현재 저장소에는 공통 API 클라이언트 레이어가 아직 없다. API 연동이 시작되면 `src/shared/api` 또는 `src/shared/lib/api` 하위에 공통 fetch/HTTP 래퍼를 두고 일관되게 사용하는 것을 기본 정책으로 한다. +API 클라이언트는 `src/api` 하위에 둔다. 백엔드 API, Google People API처럼 네트워크 요청 자체와 요청/응답 타입은 이 레이어에 모으고, 화면 흐름과 세션 생성 로직은 `src/lib`에서 조합한다. 권장 원칙: @@ -459,6 +463,7 @@ function LikeButton() { - 인증 헤더/토큰 주입 로직 중앙화 - 에러 처리와 응답 파싱 로직 공통화 - 브라우저 전용 상호작용만 Client Component에서 처리 +- `src/api`는 `src/screens`나 `src/lib`를 import하지 않는다. ### Next.js 기준 권장 방식 diff --git a/firebase.json b/firebase.json index 5081109..52469e6 100644 --- a/firebase.json +++ b/firebase.json @@ -7,7 +7,16 @@ }, "hosting": { "public": "out", - "ignore": ["firebase.json", "**/.*", "**/node_modules/**"] + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "rewrites": [ + { + "source": "/api/**", + "run": { + "serviceId": "aim-be-prod", + "region": "us-central1" + } + } + ] }, "storage": { "rules": "storage.rules" diff --git a/src/App.css b/src/App.css deleted file mode 100644 index c3e4122..0000000 --- a/src/App.css +++ /dev/null @@ -1,2 +0,0 @@ -/* All styles have been consolidated in src/index.css to avoid duplication */ -/* This file is kept for App-component-specific styles if needed */ diff --git a/src/api/auth/auth-api.ts b/src/api/auth/auth-api.ts new file mode 100644 index 0000000..95ab225 --- /dev/null +++ b/src/api/auth/auth-api.ts @@ -0,0 +1,63 @@ +export type AuthRole = "STUDENT" | "PROFESSOR" | "COMPANY"; + +export type BackendLoginRequest = { + role: AuthRole; + name: string; + department: string; +}; + +export type BackendUser = { + userId: number; + role: AuthRole; + status: "ACTIVE" | "BLOCKED" | "SUSPENDED" | "PENDING"; + adminRole: "NONE" | "ADMIN" | "SUPER_ADMIN"; + name: string; + department: string; +}; + +export class BackendLoginError extends Error { + status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = "BackendLoginError"; + this.status = status; + } +} + +const getBackendApiBaseUrl = (): string => { + const backendApiBaseUrl = process.env.NEXT_PUBLIC_BACKEND_API_BASE_URL ?? ""; + + return backendApiBaseUrl.replace(/\/$/, ""); +}; + +export const loginWithBackend = async ( + idToken: string, + payload: BackendLoginRequest, +): Promise => { + let response: Response; + + try { + response = await fetch(`${getBackendApiBaseUrl()}/api/auth/login`, { + method: "POST", + headers: { + Authorization: `Bearer ${idToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + cache: "no-store", + }); + } catch { + throw new BackendLoginError("Backend login network failed."); + } + + if (!response.ok) { + const message = await response.text().catch(() => ""); + throw new BackendLoginError( + message || `Backend login failed. (${response.status})`, + response.status, + ); + } + + return response.json() as Promise; +}; diff --git a/src/api/auth/google-profile-api.ts b/src/api/auth/google-profile-api.ts new file mode 100644 index 0000000..8c594db --- /dev/null +++ b/src/api/auth/google-profile-api.ts @@ -0,0 +1,90 @@ +import type { AuthRole, BackendLoginRequest } from "./auth-api"; + +export const GOOGLE_PROFILE_SCOPES = [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/user.organization.read", +] as const; + +type PeopleApiName = { + displayName?: string; + metadata?: { + primary?: boolean; + }; +}; + +type PeopleApiOrganization = { + name?: string; + department?: string; + title?: string; + metadata?: { + primary?: boolean; + }; +}; + +export class GoogleProfileRoleError extends Error { + constructor() { + super("Google profile role could not be resolved."); + this.name = "GoogleProfileRoleError"; + } +} + +type PeopleApiProfile = { + names?: PeopleApiName[]; + organizations?: PeopleApiOrganization[]; +}; + +const getPrimaryItem = ( + items?: T[], +): T | undefined => items?.find((item) => item.metadata?.primary) ?? items?.[0]; + +const inferAuthRole = (organization?: PeopleApiOrganization): AuthRole => { + const profileText = [organization?.title, organization?.department, organization?.name] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + if (/(student|undergraduate|graduate|[1-6]\s*학년|학부생|대학원생|학생)/i.test(profileText)) { + return "STUDENT"; + } + + if (/(professor|faculty|교수|교원)/i.test(profileText)) { + return "PROFESSOR"; + } + + if (/(staff|employee|worker|직원|행정)/i.test(profileText)) { + throw new GoogleProfileRoleError(); + } + + return "STUDENT"; +}; + +export const fetchGoogleProfile = async (accessToken: string): Promise => { + const params = new URLSearchParams({ + personFields: "names,organizations", + }); + + const response = await fetch(`https://people.googleapis.com/v1/people/me?${params.toString()}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error("Google People API profile request failed."); + } + + const profile = (await response.json()) as PeopleApiProfile; + const primaryName = getPrimaryItem(profile.names); + const primaryOrganization = getPrimaryItem(profile.organizations); + + return { + role: inferAuthRole(primaryOrganization), + name: primaryName?.displayName?.trim() || "이름 미입력", + department: + primaryOrganization?.department?.trim() || + primaryOrganization?.name?.trim() || + primaryOrganization?.title?.trim() || + "", + }; +}; diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts new file mode 100644 index 0000000..dc2bdda --- /dev/null +++ b/src/api/auth/index.ts @@ -0,0 +1,12 @@ +export { + BackendLoginError, + loginWithBackend, + type AuthRole, + type BackendLoginRequest, + type BackendUser, +} from "./auth-api"; +export { + fetchGoogleProfile, + GOOGLE_PROFILE_SCOPES, + GoogleProfileRoleError, +} from "./google-profile-api"; diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 9925cdd..b3cd8ac 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,4 +1,4 @@ -import { LoginPage } from "@/views/login"; +import { LoginPage } from "@/screens/login"; export default function LoginRoute(): React.ReactElement { return ; diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx new file mode 100644 index 0000000..352c65f --- /dev/null +++ b/src/app/home/page.tsx @@ -0,0 +1,5 @@ +import { HomePage } from "@/screens/home"; + +export default function HomeRoute(): React.ReactElement { + return ; +} diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/entities/index.ts b/src/entities/index.ts deleted file mode 100644 index 7e9c59b..0000000 --- a/src/entities/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Entities barrel export -export * from "./user"; diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts deleted file mode 100644 index 6d6bbbf..0000000 --- a/src/entities/user/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// User entity exports -export {}; diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts deleted file mode 100644 index dc4c495..0000000 --- a/src/features/auth/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Auth feature exports -export {}; diff --git a/src/features/index.ts b/src/features/index.ts deleted file mode 100644 index cae4c3d..0000000 --- a/src/features/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Features barrel export -export * from "./auth"; diff --git a/src/lib/auth/auth-error-message.ts b/src/lib/auth/auth-error-message.ts new file mode 100644 index 0000000..413af79 --- /dev/null +++ b/src/lib/auth/auth-error-message.ts @@ -0,0 +1,44 @@ +import { BackendLoginError, GoogleProfileRoleError } from "@/api/auth"; + +export const getAuthErrorMessage = (error: unknown): string => { + if (typeof error === "object" && error !== null && "code" in error) { + const code = String(error.code); + + switch (code) { + case "auth/invalid-email": + return "이메일 형식이 올바르지 않습니다."; + case "auth/user-not-found": + case "auth/wrong-password": + case "auth/invalid-credential": + return "이메일 또는 비밀번호를 확인해주세요."; + case "auth/popup-closed-by-user": + return "로그인 창이 닫혔습니다."; + case "auth/network-request-failed": + return "네트워크 연결을 확인해주세요."; + default: + break; + } + } + + if (error instanceof BackendLoginError) { + if (!error.status) { + return "백엔드 서버에 연결할 수 없습니다. 서버 실행 상태와 CORS 설정을 확인해주세요."; + } + + if (error.status === 401 || error.status === 403) { + return "백엔드에서 로그인 권한을 확인하지 못했습니다."; + } + + return `백엔드 로그인 처리 중 문제가 발생했습니다. (${error.status})`; + } + + if (error instanceof GoogleProfileRoleError) { + return "Google 계정의 구성원 구분을 확인할 수 없습니다. 관리자에게 문의해주세요."; + } + + if (error instanceof TypeError) { + return "네트워크 요청 중 문제가 발생했습니다."; + } + + return "로그인 중 문제가 발생했습니다."; +}; diff --git a/src/lib/auth/auth-service.ts b/src/lib/auth/auth-service.ts new file mode 100644 index 0000000..7f7c55c --- /dev/null +++ b/src/lib/auth/auth-service.ts @@ -0,0 +1,80 @@ +"use client"; + +import { + GoogleAuthProvider, + signInWithEmailAndPassword, + signInWithPopup, + signOut as firebaseSignOut, +} from "firebase/auth"; +import { + fetchGoogleProfile, + GOOGLE_PROFILE_SCOPES, + loginWithBackend, + type BackendLoginRequest, + type BackendUser, +} from "@/api/auth"; +import { auth } from "@/shared/config/firebase"; + +type SessionResponse = { + uid: string; + email: string | null; + backendUser: BackendUser; +}; + +const googleProvider = new GoogleAuthProvider(); +GOOGLE_PROFILE_SCOPES.forEach((scope) => googleProvider.addScope(scope)); + +const createSession = async ( + idToken: string, + profile: BackendLoginRequest, +): Promise => { + let backendUser: BackendUser; + + try { + backendUser = await loginWithBackend(idToken, profile); + } catch (error) { + await firebaseSignOut(auth).catch(() => undefined); + throw error; + } + + return { + uid: auth.currentUser?.uid ?? "", + email: auth.currentUser?.email ?? null, + backendUser, + }; +}; + +export const signInWithGoogle = async (): Promise => { + const credential = await signInWithPopup(auth, googleProvider); + const googleCredential = GoogleAuthProvider.credentialFromResult(credential); + const accessToken = googleCredential?.accessToken; + + if (!accessToken) { + throw new Error("Google access token is missing."); + } + + const [idToken, profile] = await Promise.all([ + credential.user.getIdToken(), + fetchGoogleProfile(accessToken), + ]); + + return createSession(idToken, profile); +}; + +export const signInWithEmail = async ( + email: string, + password: string, +): Promise => { + const credential = await signInWithEmailAndPassword(auth, email, password); + const idToken = await credential.user.getIdToken(); + + return createSession(idToken, { + role: "COMPANY", + name: credential.user.displayName ?? credential.user.email ?? email, + department: "", + }); +}; + +export const signOut = async (): Promise => { + await firebaseSignOut(auth); +}; diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts new file mode 100644 index 0000000..9c07f14 --- /dev/null +++ b/src/lib/auth/index.ts @@ -0,0 +1,3 @@ +export { signInWithEmail, signInWithGoogle, signOut } from "./auth-service"; +export { getAuthErrorMessage } from "./auth-error-message"; +export type { AuthRole, BackendLoginRequest, BackendUser } from "@/api/auth"; diff --git a/src/screens/home/home.stories.tsx b/src/screens/home/home.stories.tsx new file mode 100644 index 0000000..296e808 --- /dev/null +++ b/src/screens/home/home.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; + +import { HomePage } from "./home"; + +const meta = { + title: "Pages/Home", + component: HomePage, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + layout: "fullscreen", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const LoggedOut: Story = {}; diff --git a/src/screens/home/home.tsx b/src/screens/home/home.tsx new file mode 100644 index 0000000..3b0663b --- /dev/null +++ b/src/screens/home/home.tsx @@ -0,0 +1,13 @@ +export const HomePage: React.FC = () => { + return ( +
+
+

로그인 완료

+

AIM AJOU Home

+

+ Firebase 로그인과 백엔드 로그인 API 호출이 완료되었습니다. +

+
+
+ ); +}; diff --git a/src/views/home/index.ts b/src/screens/home/index.ts similarity index 100% rename from src/views/home/index.ts rename to src/screens/home/index.ts diff --git a/src/views/index.ts b/src/screens/index.ts similarity index 75% rename from src/views/index.ts rename to src/screens/index.ts index 35758b4..3d3499f 100644 --- a/src/views/index.ts +++ b/src/screens/index.ts @@ -1,3 +1,3 @@ -// Pages barrel export +// Screen barrel export export { HomePage } from "./home"; export { LoginPage } from "./login"; diff --git a/src/screens/login/company-login-form.tsx b/src/screens/login/company-login-form.tsx new file mode 100644 index 0000000..a8df256 --- /dev/null +++ b/src/screens/login/company-login-form.tsx @@ -0,0 +1,74 @@ +import { Button } from "@/shared/ui/button/button"; +import { Input } from "@/shared/ui/input"; +import { LockIcon, MailIcon } from "@/shared/ui/icons"; + +type CompanyLoginFormProps = { + email: string; + password: string; + isSubmitting: boolean; + onEmailChange: (email: string) => void; + onPasswordChange: (password: string) => void; + onSubmit: (event: React.FormEvent) => void; +}; + +export const CompanyLoginForm = ({ + email, + password, + isSubmitting, + onEmailChange, + onPasswordChange, + onSubmit, +}: CompanyLoginFormProps): React.ReactElement => ( +
+ + onEmailChange(event.target.value)} + disabled={isSubmitting} + leftIcon={} + iconClassName="!text-[#000000] hover:!text-[#000000]" + size="large" + isFullWidth + className="rounded-[4px] text-[14px] shadow-none" + /> + + + onPasswordChange(event.target.value)} + disabled={isSubmitting} + leftIcon={} + iconClassName="!text-[#000000] hover:!text-[#000000]" + size="large" + isFullWidth + className="rounded-[4px] text-[14px] shadow-none" + /> + + + + 기업 계정이 없으신가요? 회원가입으로 이동하세요. + + +); diff --git a/src/screens/login/index.tsx b/src/screens/login/index.tsx new file mode 100644 index 0000000..7383406 --- /dev/null +++ b/src/screens/login/index.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { getAuthErrorMessage, signInWithEmail, signInWithGoogle } from "@/lib/auth"; +import { Navigation, Tabs, type NavItem, type TabItem } from "@/shared/ui"; +import { CompanyLoginForm } from "./company-login-form"; +import { StudentLoginPanel } from "./student-login-panel"; +import styles from "./login.module.css"; + +const navItems: NavItem[] = [ + { label: "포트폴리오", href: "#portfolio" }, + { label: "소개", href: "#about" }, + { label: "공지사항", href: "#notice" }, +]; + +const loginTabs: TabItem[] = [ + { id: "student", label: "학생/교수" }, + { id: "company", label: "기업" }, +]; + +export const LoginPage = () => { + const router = useRouter(); + const [activeTab, setActiveTab] = useState("student"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isStudent = activeTab === "student"; + + const handleGoogleLoginClick = async () => { + setErrorMessage(""); + setIsSubmitting(true); + + try { + await signInWithGoogle(); + router.replace("/home"); + } catch (error) { + console.error("[auth] Google login failed", error); + setErrorMessage(getAuthErrorMessage(error)); + } finally { + setIsSubmitting(false); + } + }; + + const handleCompanyLoginSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedEmail = email.trim(); + + if (!trimmedEmail || !password) { + setErrorMessage("이메일과 비밀번호를 입력해주세요."); + return; + } + + setErrorMessage(""); + setIsSubmitting(true); + + try { + await signInWithEmail(trimmedEmail, password); + router.replace("/home"); + } catch (error) { + console.error("[auth] Company login failed", error); + setErrorMessage(getAuthErrorMessage(error)); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ undefined} + onSignup={() => undefined} + className="login-page__nav" + /> + +
+
+
+
+

+ AIM AJOU +

+ +
+
+ { + setActiveTab(nextTab); + setErrorMessage(""); + }} + variant="horizontal" + isAnimated + className="[&>button]:flex-1 [&>button]:text-[13px] [&>button]:md:text-[14px]" + /> +
+ +
+
+ {isStudent ? ( + + ) : ( + + )} + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+
+
+
+
+
+
+
+ ); +}; diff --git a/src/views/login/login.module.css b/src/screens/login/login.module.css similarity index 100% rename from src/views/login/login.module.css rename to src/screens/login/login.module.css diff --git a/src/screens/login/student-login-panel.tsx b/src/screens/login/student-login-panel.tsx new file mode 100644 index 0000000..5ac398a --- /dev/null +++ b/src/screens/login/student-login-panel.tsx @@ -0,0 +1,32 @@ +import { Spinner } from "@/shared/ui/spinner/spinner"; + +type StudentLoginPanelProps = { + isSubmitting: boolean; + onGoogleLoginClick: () => void; +}; + +export const StudentLoginPanel = ({ + isSubmitting, + onGoogleLoginClick, +}: StudentLoginPanelProps): React.ReactElement => ( + <> +

+ 아주대학교 구성원(학생/교수)은 Google 계정으로 로그인하세요 +

+ + +); diff --git a/src/shared/ui/avatars/avatars.stories.tsx b/src/shared/ui/avatars/avatars.stories.tsx index b72e850..b39b2c0 100644 --- a/src/shared/ui/avatars/avatars.stories.tsx +++ b/src/shared/ui/avatars/avatars.stories.tsx @@ -3,12 +3,13 @@ import { Avatar, AvatarGroup, type AvatarProps } from "./avatars"; const meta = { title: "Shared/UI/Avatars", + component: Avatar, parameters: { layout: "padded", componentSubtitle: "유저 프로필 및 다중 유저를 표시하는 아바타 컴포넌트", }, tags: ["autodocs"], -} satisfies Meta; +} satisfies Meta; export default meta; diff --git a/src/shared/ui/checkbox/checkbox.stories.tsx b/src/shared/ui/checkbox/checkbox.stories.tsx index c497263..9faa501 100644 --- a/src/shared/ui/checkbox/checkbox.stories.tsx +++ b/src/shared/ui/checkbox/checkbox.stories.tsx @@ -1,5 +1,5 @@ +import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/nextjs"; -import { useArgs } from "storybook/preview-api"; import { Checkbox, type CheckboxProps } from "./checkbox"; const meta = { @@ -16,15 +16,9 @@ type Story = StoryObj; // 인터랙션이 가능한 래퍼 컴포넌트 const InteractiveCheckbox = (args: CheckboxProps) => { - const [{ checked }, updateArgs] = useArgs(); + const [checked, setChecked] = useState(Boolean(args.checked)); - return ( - updateArgs({ checked: e.target.checked })} - /> - ); + return setChecked(e.target.checked)} />; }; export const Default: Story = { diff --git a/src/shared/ui/empty_states/empty-states.stories.tsx b/src/shared/ui/empty-states/empty-states.stories.tsx similarity index 100% rename from src/shared/ui/empty_states/empty-states.stories.tsx rename to src/shared/ui/empty-states/empty-states.stories.tsx diff --git a/src/shared/ui/empty_states/empty-states.tsx b/src/shared/ui/empty-states/empty-states.tsx similarity index 100% rename from src/shared/ui/empty_states/empty-states.tsx rename to src/shared/ui/empty-states/empty-states.tsx diff --git a/src/shared/ui/file-uploader/file-uploader.stories.tsx b/src/shared/ui/file-uploader/file-uploader.stories.tsx index d55bbd7..b88a565 100644 --- a/src/shared/ui/file-uploader/file-uploader.stories.tsx +++ b/src/shared/ui/file-uploader/file-uploader.stories.tsx @@ -4,11 +4,12 @@ import { ThumbnailUploader, FileListItem, FileUploader } from "./file-uploader"; const meta = { title: "Shared/UI/FileUploader", + component: FileUploader, parameters: { layout: "centered", }, tags: ["autodocs"], -} satisfies Meta; +} satisfies Meta; export default meta; diff --git a/src/shared/ui/form/form.stories.tsx b/src/shared/ui/form/form.stories.tsx index 16dd59a..56b5201 100644 --- a/src/shared/ui/form/form.stories.tsx +++ b/src/shared/ui/form/form.stories.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/nextjs"; import { FormField, FormLabel, FormHelperText, FormErrorMessage } from "./form"; -import { Input, Textarea } from "@/shared/ui/inputBox/inputBox"; +import { Input, Textarea } from "@/shared/ui/input"; import { Checkbox } from "@/shared/ui/checkbox/checkbox"; import { ThumbnailUploader, diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 99d9206..d0b5431 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,5 +1,6 @@ export { Button } from "./button"; -export { Input } from "./input"; +export { Input, Select, Textarea } from "./input"; +export type { InputProps, InputSize, SelectProps, TextareaProps } from "./input"; export { DropdownMenu, SelectDropdown } from "./dropdown"; export { Pagination } from "./pagination/pagination"; export type { PaginationProps } from "./pagination/pagination"; diff --git a/src/shared/ui/input/index.ts b/src/shared/ui/input/index.ts new file mode 100644 index 0000000..b59f0b1 --- /dev/null +++ b/src/shared/ui/input/index.ts @@ -0,0 +1,2 @@ +export { Input, Select, Textarea } from "./input"; +export type { InputProps, InputSize, SelectProps, TextareaProps } from "./input"; diff --git a/src/shared/ui/input/index.tsx b/src/shared/ui/input/index.tsx deleted file mode 100644 index 93e096a..0000000 --- a/src/shared/ui/input/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// Input component - to be implemented -export const Input = () => { - return ; -}; diff --git a/src/shared/ui/inputBox/inputBox.stories.tsx b/src/shared/ui/input/input.stories.tsx similarity index 98% rename from src/shared/ui/inputBox/inputBox.stories.tsx rename to src/shared/ui/input/input.stories.tsx index 2d56e17..f093af3 100644 --- a/src/shared/ui/inputBox/inputBox.stories.tsx +++ b/src/shared/ui/input/input.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from "@storybook/nextjs"; -import { Input, Textarea, Select } from "./inputBox"; +import { Input, Textarea, Select } from "."; // 아이콘 임포트 import { UserIcon, MailIcon, LockIcon, XCircleIcon } from "@/shared/ui/icons"; const meta = { - title: "Shared/UI/InputBox", + title: "Shared/UI/Input", component: Input, parameters: { layout: "padded", diff --git a/src/shared/ui/inputBox/inputBox.tsx b/src/shared/ui/input/input.tsx similarity index 100% rename from src/shared/ui/inputBox/inputBox.tsx rename to src/shared/ui/input/input.tsx diff --git a/src/shared/ui/lists/lists.stories.tsx b/src/shared/ui/lists/lists.stories.tsx index 8400579..5878298 100644 --- a/src/shared/ui/lists/lists.stories.tsx +++ b/src/shared/ui/lists/lists.stories.tsx @@ -4,13 +4,13 @@ import { ListItem, Table, type TableColumn, type TableRowData } from "./lists"; const meta = { title: "Shared/UI/Lists & Tables", - // Storybook Meta는 하나의 메인 컴포넌트를 필요로 하지만, 두 개를 문서화하기 위해 Wrapper 활용 + component: ListItem, parameters: { layout: "padded", componentSubtitle: "디자인 시스템에 정의된 List Item 및 Table 컴포넌트", }, tags: ["autodocs"], -} satisfies Meta; +} satisfies Meta; export default meta; diff --git a/src/shared/ui/modal/modal.stories.tsx b/src/shared/ui/modal/modal.stories.tsx index aa6e8cf..715da3b 100644 --- a/src/shared/ui/modal/modal.stories.tsx +++ b/src/shared/ui/modal/modal.stories.tsx @@ -4,7 +4,7 @@ import { Modal, ModalHeader, ModalContent, ModalFooter } from "./modal"; import { Button } from "@/shared/ui/button/button"; import { Checkbox } from "@/shared/ui/checkbox/checkbox"; import { FormField, FormLabel } from "@/shared/ui/form"; -import { Input, Textarea, Select } from "@/shared/ui/inputBox/inputBox"; +import { Input, Textarea, Select } from "@/shared/ui/input"; const meta = { title: "Shared/UI/Modal", diff --git a/src/views/home/home.stories.tsx b/src/views/home/home.stories.tsx deleted file mode 100644 index 0f9ef78..0000000 --- a/src/views/home/home.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/nextjs"; - -import { expect, userEvent, within } from "storybook/test"; - -import { HomePage } from "./home"; - -const meta = { - title: "Pages/Home", - component: HomePage, - parameters: { - // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout - layout: "fullscreen", - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const LoggedOut: Story = {}; - -// More on component testing: https://storybook.js.org/docs/writing-tests/interaction-testing -export const LoggedIn: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const loginButton = canvas.getByRole("button", { name: /Log in/i }); - await expect(loginButton).toBeInTheDocument(); - await userEvent.click(loginButton); - await expect(loginButton).not.toBeInTheDocument(); - - const logoutButton = canvas.getByRole("button", { name: /Log out/i }); - await expect(logoutButton).toBeInTheDocument(); - }, -}; diff --git a/src/views/home/home.tsx b/src/views/home/home.tsx deleted file mode 100644 index ee7d7f1..0000000 --- a/src/views/home/home.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from "react"; - -export const HomePage: React.FC = () => { - return ( -
-
-

- Pages in Storybook -

-

- We recommend building UIs with a{" "} - - component-driven - {" "} - process starting with atomic components and ending with pages. -

-

- Render pages with mock data. This makes it easy to build and review page states without - needing to navigate to them in your app. Here are some handy patterns for managing page - data in Storybook: -

-
    -
  • - Use a higher-level connected component. Storybook helps you compose such data from the - "args" of child component stories -
  • -
  • - Assemble data in the page component from your services. You can mock these services out - using Storybook. -
  • -
-

- Get a guided tutorial on component-driven development at{" "} - - Storybook tutorials - - . Read more in the{" "} - - docs - - . -

-
- Tip -
- Adjust the width of the canvas with the - - - - - - Viewports addon in the toolbar -
-
- ); -}; diff --git a/src/views/login/index.tsx b/src/views/login/index.tsx deleted file mode 100644 index 3112095..0000000 --- a/src/views/login/index.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "@/shared/ui/button/button"; -import { Input } from "@/shared/ui/inputBox/inputBox"; -import { Navigation, Tabs, type NavItem, type TabItem } from "@/shared/ui"; -import { LockIcon, MailIcon } from "@/shared/ui/icons"; -import styles from "./login.module.css"; - -const navItems: NavItem[] = [ - { label: "포트폴리오", href: "#portfolio" }, - { label: "소개", href: "#about" }, - { label: "공지사항", href: "#notice" }, -]; - -const loginTabs: TabItem[] = [ - { id: "student", label: "학생/교수" }, - { id: "company", label: "기업" }, -]; - -export const LoginPage = () => { - const [activeTab, setActiveTab] = useState("student"); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - - const isStudent = activeTab === "student"; - const signupCopy = "기업 계정이 없으신가요? 회원가입으로 이동하세요."; - - const handleCompanyLoginSubmit = (event: React.FormEvent) => { - event.preventDefault(); - }; - - return ( -
- undefined} - onSignup={() => undefined} - className="login-page__nav" - /> - -
-
-
-
-

- AIM AJOU -

- -
-
- -
- -
-
- {isStudent ? ( - <> -

- 아주대학교 구성원(학생/교수)은 Google 계정으로 로그인하세요 -

- - - ) : ( -
- - setEmail(event.target.value)} - leftIcon={} - iconClassName="!text-[#000000] hover:!text-[#000000]" - size="large" - isFullWidth - className="rounded-[4px] text-[14px] shadow-none" - /> - - - setPassword(event.target.value)} - leftIcon={} - iconClassName="!text-[#000000] hover:!text-[#000000]" - size="large" - isFullWidth - className="rounded-[4px] text-[14px] shadow-none" - /> - - - - {signupCopy} - - - )} -
-
-
-
-
-
-
-
- ); -}; diff --git a/src/widgets/index.ts b/src/widgets/index.ts deleted file mode 100644 index b59c177..0000000 --- a/src/widgets/index.ts +++ /dev/null @@ -1 +0,0 @@ -// Widgets barrel export diff --git a/tsconfig.app.json b/tsconfig.app.json index 6b07958..aed9f68 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -28,10 +28,9 @@ "paths": { "@/*": ["*"], "@app/*": ["app/*"], - "@views/*": ["views/*"], - "@widgets/*": ["widgets/*"], - "@features/*": ["features/*"], - "@entities/*": ["entities/*"], + "@screens/*": ["screens/*"], + "@api/*": ["api/*"], + "@lib/*": ["lib/*"], "@shared/*": ["shared/*"] } }, diff --git a/tsconfig.json b/tsconfig.json index a27bd29..c58e3b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,17 +31,14 @@ "@app/*": [ "src/app/*" ], - "@views/*": [ - "src/views/*" + "@screens/*": [ + "src/screens/*" ], - "@widgets/*": [ - "src/widgets/*" + "@api/*": [ + "src/api/*" ], - "@features/*": [ - "src/features/*" - ], - "@entities/*": [ - "src/entities/*" + "@lib/*": [ + "src/lib/*" ], "@shared/*": [ "src/shared/*" From 17c82f0609182a23c1d77f519553a4ba8c02677d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=B8=EB=B9=88=20=EA=B6=8C?= Date: Thu, 30 Apr 2026 11:47:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=20=ED=9B=84?= =?UTF-8?q?=EC=86=8D=20=EA=B2=80=EC=A6=9D=20=EC=9D=B4=EC=8A=88=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인 실패 정리, 역할 추론 실패 처리, 정적 자산과 잠금파일을 정리했습니다. Confidence: high Scope-risk: moderate Tested: install; lint; tsc; build; storybook build; 정적 화면 확인 Not-tested: 실제 OAuth 팝업 로그인 --- pnpm-lock.yaml | 50 +++++++++++++++++++++++++++-- public/favicon.ico | Bin 0 -> 32038 bytes public/vite.svg | 15 --------- src/api/auth/google-profile-api.ts | 2 +- src/app/layout.tsx | 8 +++++ src/lib/auth/auth-service.ts | 26 +++++++++------ src/shared/ui/modal/modal.tsx | 15 +++------ 7 files changed, 76 insertions(+), 40 deletions(-) create mode 100644 public/favicon.ico delete mode 100644 public/vite.svg diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3adf359..840da8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,9 +69,6 @@ importers: autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) - baseline-browser-mapping: - specifier: ^2.10.0 - version: 2.10.0 eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -1222,89 +1219,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1383,24 +1396,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.2.1': resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.2.1': resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.2.1': resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.2.1': resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} @@ -1523,56 +1540,67 @@ packages: resolution: {integrity: sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.2': resolution: {integrity: sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.2': resolution: {integrity: sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.2': resolution: {integrity: sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.2': resolution: {integrity: sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.2': resolution: {integrity: sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.2': resolution: {integrity: sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.2': resolution: {integrity: sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.2': resolution: {integrity: sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.2': resolution: {integrity: sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.2': resolution: {integrity: sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.2': resolution: {integrity: sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==} @@ -1744,24 +1772,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2169,41 +2201,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3757,24 +3797,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d898792c18eeb34c4c1db8aff8200869e08e831d GIT binary patch literal 32038 zcmeI5b(|LE{;(GjMG-~pIwI<^0~0$j5DO6tWu=zx&IOk45+tO%Q$RYTLqL#_QdBHd zIOmAL{a)AXGkcbG(cd|5{PphVb1{A2-=2A9?irGjawMIc^zg$;ObaAk`)E?q6G=%) zx89n*Jm%`8r0T3IP$03KFDdE#ib+ZNn7V((larDbG)zjmmSbolDHU8F{XLvM`EUQ7 zP$4Pl1mtA>#ZVM_z-l-QA;BT0^TBO3pcK3U&%wj+AUq8(!5dHudcgwl*tne|%-eQ% zurK?x?`-AzZ}rT2+zHx0iS>D*AlwTjp)T}+sjvbz!Wvivlb{<^fYQ(aw8fK91gbzy zco|-Y)-V=U!pE=ymcnG{4RyeM?OPd?MVbC!i>&(mr_0&2nS*tw!!4i=!(bUKhC!h0 z>X!ogS)F@AXV6aNVJ4_c3DEBoVH_9-g}`H4K?_jVGT?E|z&^C;60mP&P!?rUw&QH@ ze?Z^3y_0DB3@8Zt%s91w^~P?6BsK+;YQGY zU7<4A*VS+jG>2)h0gMant_;SG`zf0;o&>Sn$4&pr9w$1E_1gD(Cq@NFZ4f|WAnouPzeUWa?sx$;d!_i?uRMx8JPRlFYVJG)L&bSgc&dnV!OF* z2ke8M&<3oR>NFT?gSwZ64`4kggU2-n>w$5gKL&y_D(g9*?8n*RztCUZ9|3*02-LkZ zybktr9^{0FVF`Q!)_^A9z8`|Rw*+H+JB$J2p*nPfy>JW;fV$hh$LL>m)z@jz2PT2- z7(?cuHKi(O2W_wjl=V?i_N2_5NdL_HB<5qk-_NqLOb7E$pC-fAa4N*@o1ezuJTRv9 z+a}lw)~y=gv2#FO%7N|Z!^xmu-G4m14cb@zZO>S;?GDfdX23LP1IERRVEgZbvFCQ} zt?cIt1-7;FRMxGuZ^Z-ROi z0qu1!=qIyd$B!?c)M|XWq)JT7K{E4i zLMbTC@|*A)^RH&CE0p zTZSF9LFVPe{25HI2isF;b?*iGbS{|RJ7EgwvjU(Ej0Np+FKCCCpxT7;NgdE-B+O<0 zLs-lF8gyL~O`R{Fv?{@Brpwv(KGPxWYaKDS)k~aj2jgKCsQX&5MzjL+Aq`r9vGNh9 zgEdi|)Gb5jZ1m5;_JU9bJboEWhH_xMnajsuAIycW@EZILY};ei-+Ziq{Ea46K6Sv< znrE+>S|i`LldI(ZeR9>)!?arY!}Qwcg{d|3g(=nZhAGv~NQe2UHO~Be=JWih* z=TCugZ=d_1BQ%EvFb*Dr3*mN1f$_+^5g&ex?tA97zV*<&wA+7~)$p?Le&Z{`!jAWc zC0!p2^V{AT=BC~d=CrsjVs7e5P&Vb)N9v+Z>ZXqBnh^(y`MgYTftuhsYbh8v_rar}|L4OJNCoq3 z7O20zc?8UvVQ>Js192Q?HM~5m8Il|}j;#@v_bL{ac7HOg9a=W5A6Yr98u)rx(XRy4 z*H~93Y#dWPY@XCGd_1*TSTneEc)v-(Ffrw%Kzs5z3Zq<+^}mvJ>OKS}f$fxsXQ2dW+cq!?mcvZ229<)c;5O~9oJZAtT-l^B zzs((C-^vl;+nr0p&UxJ;{kF|$9k$I(3tOf%30r5h3cDBg3txOREquRsO*njT2kZ>{ zSC0ydyF3ymS2-ih{P#2Il4*&_6%GMo{j^4Z$4 z*x(??tPab27Yo$DjP??3+sAjHpOw)#&_C^=GxUdPpiG-V*~^1|UkBPiT`vLk&j)uy zJ+RI7@Ghi5Z&3g7Fa})T2+WD%;Pu!-P@d!DG;gO=%M(`geKGv7Z%z2+@YmtMx^ZFm zqTb<)4U@tb8z+Z7ONjgR6WMk!{Cw#1u%=ZCsJ1L|K8^riXkaqq!9pe%%WUeHH#euK(-1Jt6#d zG#op6IQ+DKT{!&t#t?q_XE?NbMOfD3$@JLIUVr1jzO{jM&0P09Fa^ef@|&LzL1Bpf zHI#Yx>jCP2TVnZrruwrsG=x+zrrLtKTMM*>^VR}w^%;b0sd#djSmW$4r~9*E{ffz9 z{l>*%&iK}0?zom=Ao=dXrBfanaY#Rjn%2*fD-5lYKlEu-IJE6vJ2ZZ$O{m?mR;b^hT1e~N zICSmTH1zCKCv@#oKeT$cZD{gt=g_!sJ7^!8_U{tfcB>xx)xSB6sCZf!S0UpZ_=mm{ zGAN6FuK?<}1P*{YSqEeUlm%ni33(q!836C_c$M&Yj zQIumU)O$Xp)wm@LsdQ%0rt&BKu1&~hZZ3s?z?ZNE7J~WkIAoUpdX}}x1~?2Wz}Ru> zaXVonOapVnW9!4ma6(zeq?{buR=+Vk)wFK7Ds5o6G;Mgepv{M-fA^;cf&n*KMQ^{KV2 zHWY*FARjypD?$04uLIiaTQFvJ!zZv7Y{z=h78-)NKM?l9ar(#p8CfxR=vnn|p+fx^ z!{5<8SEnW6lunDoSsmUFm$e-g?r7O5yx6#Mc)DpFmPfK}Q8*QHcUs7FVKhCh*7wNA4e?wP&`BLMQ za3?m<7H4&sg}w_ibdT+DTF3d(dY22d8O|{kL-U$ z;nC(z!liA;WW>F3QM75TkW%l3P_xbx;q`{e;hMDmk=?XG-j1`wdF>~MdsEwlMz!uE z2G07kJWw`s`E^j{T3`)W0M@b1pbtH-tE;g*8H|(CV9fUi&jr81Hs}fWz%yWcWa#dE zoR9BTK0nl{^JpTkM@9K-{Hm)utGhP1zIEToCdS1%?WctsTE7#DH?Bm^6hr?ap-h8U z!o$s*Q#ap>;wHT=m*2F;Rs79JYaZSiat+>4Hh{(R3tNs1Y>VPs9hv&dJ*6-TE*nbi{ z2NnbM`3XE1Gy>ycB4{huO#$P?`f)3igjH~y{$tVI^K;$Wk3{-dv-MfN4l^TLSno?T zt`MGWQX^bWo~w^_{*tz1!vif^hDVw=!`FkN{jG&pv>6&+X;e1K_ghB7t@= zU!p$a_#092^r$YCt6w5I-&yO`QC~gTv|f0=N!93__cxZci8`vkdG9&U+%JgE=DK#X z-We0dl6jyFE@(F?I>xrGlhx}!70fs5ZJd9xTnQPK^(J@}tOFN8PEh|d;AVITj)DHR zUb^lAI2GJ4<|O9N2W!e7^tT?R*1SEu+^9Ty42g7C|7)pXW$V98K9`JYdM;#F_cQV3 z`P4J@daY4%DA}kSb{mjU@8Y;IAD(McJv`a0KDHVW>2JH%Ms4l6K-uv${_guCFYQk-BHpKg7o_dV+KO_F_*K_`W%IAc7wIAZ#UMupszE#fa z$?Yojo{8$(wXNTcu7lime)~yL9e5Vqs@5$U*~jyRwJ>kTIl*&=Q;&I-x?wI@3vE+8 zKfcxAjnKaObzxY=GqTfP8I@I;v+Ax+`Qc75{&&J`r~}qGbL3=jAMJM@xb93aH!|v9 ztp7mtZ&>?*^!$$elrE<}E#2U?CJ{zpxl&&i!q@7526~^#b>fBX zCPe2@>&Ek(H>?TjZv1;qXTb?F57q?p!egwR8Rx$^?uJ&(8`@F(y*`QkWFC7Sc$nH&jJo$=^A=HUwC2a>z4&^_ zT7PZYyOH0$-npdR*l>S~R@8;s)W)V!tel@I|2==!uJd^C_m#ghlmodkqm0U`-~JBj zRRYvS8;Etm8Zi}yLQ8l7%=62^>%KIo1=dbu!FE0XYev+ceS6*0x5|Z4O;RUg*IK4O z^_ABx*8WG)McwrA#atJ=?~~2yMfG1>X*2PBS%Pb1YlGK)7q%N8#f0r@KhGJhY80aO z#(y|%rvb?@l>uTGv5E<&Hjnm3Mq3uuFjv5CI}w3)eY-F&0r zTTwh&>$TVE8P~y`xsG@v^r(7y)OQx&E7NzNjLNDnjd69bZ!7!TUW z^PSskKo4jF=D~TOT{^&e_z@x*;@>@es$3k^vb$5;N9VA(UU{DLcd*xZ_q9li{P$|3 zGST(mf{&=1J84)onMa26Qn zbzuNBhpXWRP*<5Sip9;*$~ua)YCPF(kD>nw2}%b`q- z;1Re3^r8A@)kPikx5rr{i^E#j03{#~WIZNcHl|(%_mBIHrm;-cLiLP)3;a6|Mn(0` zx^7)G=goKPf&Ml}yoR`!9C2Ox`7ZtU{|n@U*Sv%H-Sa2+fy~R&@EB-^ns61wvO7Hk z)KOn6cMi~AvtcEqfcbG57&q2WbEptp0;htu9SWbrQOM9gJr7Q!J{1U+>OB|L_*hSW z(_h0iftBy-|L8rz#JO_+91+Do|*@q z6TNTw6xaXW+fX0RU+Sao>Az^r34I=sC^MUulDn<9fQltA|`ujAl|GmZ$b6?*Z`__Bo-#kx``}F%|UOP45+R|&C zN!(kDb1c!S_g&DZ>TeD3w_aUX1P5RbEC#O& zJa>Ey_EQG5VJg_p7;ydf5b3Xs+F)c#?$C?-px)Q*T3?4T<4|7eHQ({rB7p1`FeoyZan@Ic7wLi26Mr4Uu*af)T=%`3+jI| z7#k&EDQG+O(f8U&8-A8xAk)q;3uZtY=maakV>04^`6y=peP3iubUyI9$a`Y`CU~pC z>(O;yL4G&-`%vG<_lf=8*MZ+Swv$Eg*zd-kdbfj#;5p2kJ`5khCeRiKVFHYUeV~pd z;Zjh4ISKBAk)W^DXBn7F`$3!hD>1)|sWGBmlfhgt_7;J;kRh{j$M?mIZ_nx0!CPAO z%(w>hdN#gKtPPBVV%#TgQ2W8?x20`l(chS#59)4>*9Yt1DA@NA&=%^U{_d}=Q(!%e z0Cm;xvHni41Z&Q)BB>m<-cF8yH*0!vJ^_Zi2Jm6gUTNhZGnNpF?J)fY^kEY)=5B^dKwTb# z*P#-av)e!!=7D-^ht;6&h2Ttx{+uSr!8AA6mieMRooZW;6=S0cTmd;jy_Ml&&>k;C zS1|XCyM5HceLNTWfjs!t``bPjYFz8y(5UwPkqx}IvHn{N>J#%`L-V`-SC0FE7*cNi zxeC<1BUAQV&qfcc(P|JV)%*;XEgf;LzWgP}Dv zg;&9Afc)?{*q>B`4q%Ke1!d@m4ufDVdGI|pIQCAJi=y+H_f2|NxioaES`e;?=3O_u zVxHf)pZqPytONV41J6ToXaaqpKh%N(5X+s5`7^+Cw7IzqJP%k4hd^1l67+L)j0TA3 zGflOrv8A0CfwmhC>i#y|2gbc^)d%fl`(wa(TMW~oJD4ZNWe0Q_PaMpHW%yP$GJn9> zfJ1PQb$i%v4f{{>7$^mW;VO6(ta(d7pBRhUp~1|It%?16<)8EypalcDno z^fxx#&-iKvGe8@t(`#U?yU*<~5N@<~r| zJyVr+lVKBVfDgcUsR>VT%nfjb$HH@v2Ij$c3ABN+@CsZCIl$O{28?-iH~w3Iy0}fb zkJCR&g6!kK_@_(0QTvA`at`Yg>J9_)ZIP~!!A%i<yms-wOKYRnVW-)bHV2uBN%U= z!aA6fK-(*mwZgo~1988{O=CSzG(FLAtUn3z!j+(mHDDOn_h(=%TaVs>+E4_H>%}+Vx=~<%#+tI}CuNQMJ#Km;y-&3M z1nuNz{Via>_OD-;!bq?lwSk)O0^AMP!AoGg?1Q~v4y(&%PesGSq{2!MeW@tPkrz*}Fh%=mq_u zH@Ge>LC3^YU8+HS=nl4JJKw>Vuo8wtBPa>R)Rk~1Wb5;+`uw-cw11pkGcDWiIbhAP z_Vfj7lCioO%=Pi$`fkt(I)JfmoV11ZiDm8KHus$f>bnW__bhOIRVWPSLQcq5uK!lg z6CHP=eiG|XW||MKfZO0u}zBKp0f#1P;_#^CvH82%=Ky@ezH$Z+k zp|9DNVTb=j@9v7f(OL<2`dIMy^N>8!!Q??l`R$&m4#h^tumAN*yG zxh(V6!_(kWcowYp+u$cK&OK-MgUawGSjQiQr{EQM3!1?+u;%{*4uaPfwc%Dc3(SA> zEF<>g`M(lB-Y=V}Id(BT56wY2m46U;9ojRoY#o0annD+N5v$So>cn{LRKDBYB#IkkO{boaHu;zNq zGf*7VMV%f7{nHL?&l+V7ZvjO>d!4WrXV!uJveVUgIUDS!Gt7r!VBh6nJgfrG4eHSl zQbGAf!w@I~!(lxbSIW9LF;#YTGM+q#>KFa${A4H#SHY?92l-<8v)jfxWmkXgWt$Vh z+HD*cfP2BXT?Xp(KJ%4Ao-Gs#QU&a&r zW!5hz%NKz8Hx!nFvFSC{S)gurfI1nsO+epkbNknJ9_M+g0`!Ho;CWr&xXzr>&SRh( zsMjPgcHV+Ipbh4N>k7f2<^jYWaisl>clRyH zyH!OZ((_iC%CcO7sq^~aW7q(;Z_elob+Vm`P#-+6F9&td581{N>tosTG5;iJ4}Gc* z_N8q*fjZ;?^(YMLSsQvFTVLJ>>Ir=!$|d#YJwxYR*FBn+OX|kdcJya=FvglgGf?g= zpbpkA_2~-6pRuZMuZB|~bG*d!>UKY9Q|p0#Z3LU)E3ggQy$jUy8c^4=6H-oUIk{?{ z$y2JIxr=w9f1FY+?-Aa0J~FlXnMZh6?g-nQ9%lPNSUIs$uHNWdgJbnqB`^-x!>2F< z+Cdv=4c6F2U`~{Uiy@Xdp5|oTWuPsTy(PQ=_VE$S1ofH@`t>?c_MXVGZeqogcg|^k z%|YH@K02rQwbA>_zR$a`!@az>d|ULctMf~`K6-3n`+E*gtC@cv#~DBGLqo71<52s% zPCMz>kuV=RCgvwIEdu6(u^!8R9`mokP%tmmQ~4^wa2OA^-wSF&Ip`1DiILwXRXQ!K zW?aZ)sdduh?;m_8L4Ljy_3p+mO5_HgvbchNhyOS(PI zJE*4wjz0$ffOTNJXftzl6)c2};I=eSChcboXxFnq+4I3muoTQ$>u*mO1*4%07&FRU z57grz{0`AOoWAqFV{WJL&+k9u{m-dk&(i+9``kBd$JPh8&W-fa_J?@)_t4(8(YwRZ z`=9^5_w`TOQcn9eZnSkbPzLo_2I^;=EdjR`f>Yp5Xa?F|d9{CBBYH6JzUo^V#=#em zE<62CtLRh2kmC;4u%e3e3yiwFc6HlJfK`-z*^QAj2GL`2F8!;oc4x2 zkRdzsyhENdOs#)eSTQLzY}&XWESl0GtXeQQ?Ao<4?B2OH?D}+5*uHyf*t}(V*tTP1 z_-NIX@P67|S)W0oPxO1uSTY7$gE253hJgCK0@_hol;1jY9w_e`Fvg6dN-zUl=do{q z`s{*VAnq%g`aHB>jVnT0+H0Y9kCvfIhkBtw=h~rNFUAS7rtwz<7f ziw-<5t{%NZ8IS+S+Ahmmpeh)<+h74GXEGS?u0IUVfx0OF9#HPpFdM9w&7mo1kDnou zpXX3ab424U9dqoM6vrmzYd<3z>+d+eXBeY%d7EJ&PlvhbFuvoA4vu4YoX6B?OrvAs ze1|o?z2j`Nm%fx&%wc1uFysO2jWT@>4ZwZ$%|4h3?rRLa1=V0Yxa~K{kbewu)2!Cr z(X)2nqjHRiTYGyg?6e)2FUG#L>nLQ%?t4Xjd0y<8P{+zTHa>nI&UXSG8>ifk z9d?Y7V-6id=$Hccb(`>o@f|<73OzFB!c%>AMl? zUeMUb6kUCyp-Rue~x=`EZE4z zI}4fmisgTab;inY@Eq_EJO$Bh8EBf^i>JCJu%(DG?L$X<9j*2H+xl^_oA_9 z#=h@jJ=Ux-anmk(2gh7+yt?n_xh)=d>AN@j!1q+$9_fItFEptVjrGs+KKc>*HGZ^z zbvCwPR$=Pu~$P$QUv6;Bjh>a_SGq z%lQtS?}<2W$o0mU<7XXD^E7_&JtOnjICD(7J{`r_yiB>Q{Korra4wtl`A?35bZPLz;`K?gDc=XPfHAeZ3f5$>q@xuJ=q@^KV9H5P^Oq%%wGliWi9lECqUm^1cl&wxB!eZ_sOuo z?^kK>cub~K$157!j#<^#zMF9`?|1l)m$o&=l+#+}IA-^~iFbYUiR;y|3u9TcjO$W< z>!LOD61Wv^0^>rTyZ}?72fPf*{vgzYvT!pz0Lvhfe>~?J$4b}YoMoPBYscRD-jVTR z4*QOgd98fP>3GE0&eki(;+qGKO?SO@)G@lYo8@}q2l{ORmxJ+L7plRPa0jG-b>0|L zX3q;HzgPVnYn=Fw zfi*|nedo-vcJX_YnPUGJ`q~ba!T2c&gTPq11&Ts1NCvMThJbOSe_n@TPynugX7CaG z3egx?-&gnC={eLGP!?tKysLeE59D#qO~#Qq?zzqLg6~aQj~pkO{%*vuXgs-NDfMk8 z`M;&FvEVuPOfYto>m^XGO5nM`_^|%_o4|SV*4lF_Sf}!WZRmr4fq516)xYw&;Vs^| zaU8er;#dnEuje>D*K2!o!CK^4M&IAo*6B5Bex$>Ld<(#L7nD72J2Cx~sn-q0fHph> zP6OpS8`9tqTn3$C1$eIY`pA8h+hd$Je>%YT5cQ*-d`GNZ^&5zpO40aA#}_-^)Uk)! zI6rY?ZB!3yk=F{2(brdALs(ZGtLJ-ene1-+zkxNc98?6)tye=*g3FoqfS*9$)B?}F z)xrEVwmgTq{t>Va6^EJdBP48<7p+R=k0J0nA4jeBaS9= zKJLdgu;V_xhE`tfYfflm-}5#n9RHbKZ}rjm=)H)xHEv`a^>Ob#DEk*M8oIzoa0Hao z7*X!R&Y(1vSA!bhF=Jo@L^`C$QLgBnORt-?uVeXrFD)L& zZrnuI(!`QJF`gRLx6D)fa(a1g!*{qZpjgHEs%`hh+%rcQx}VJ_^0 z)nGh%+&8cg7Q<|?&US?3bnx3GzDsfs=PYZfb;p=;EV8+vPn6yFx2#R?@J?H%m{;}# zFdfWG<=g{DL3=8@v2LH!U>!7v%OK8)E17Qz%DoEo*$<#k)nPNJ^8;Xxq{0d~gdE3= zVQXO3y3a(l#`BY7#XTn|v%j}I_ZH{6qhs}JqW4e|^4tFBFb=B1%bl?56 zi!^Hxoy+u#IoztotzP3GbB>=m<{zNHl_v%6hD+flCjo+gn%kXKQE$P2p8I4|0I^yb>CMIjAkwp&Q%`S!Gv#*IfnrQM=kl zJ5Z-*iRqc08c?0=)Xf?d~4qa;SA9J+Rr*V8eW34 zLEW;GJzjSj+m*E?D8G8Y3CUnvzr(Na8|;E%;PwKL8?FHLDF^dGeHVeg(~p-zHLw=1 zha(AgGMx@(;eKce)4=_-;U#bq#BxTD@G>uFnpX~Wu#E+v-G{(t_!_jAI(!dHz&O>e z%9aexpf7w1zkvR40@s22jexI!e2DCDkf|{~6S_cK_y9)2Yj7bPCp*i3CcirB2mP$A zCc_4p2gdy>u&yds54acdfOTm+xSz*Z*MEl{pe=0g0(b*vgSAULd=BQRZ7)jDiRshe z{@LYg{0LQ9PlWk!6_~@lVInLA<7frE2c_U#u#LN5C42+Q{1Y5Y%*XQE=GBk}Tfz9+ z5Bkb{GS*wb18~B4&k#P-4EtxNhq_w}lr0tBhvi^D`no1O0XIT1(5}j}6ZFq^SOmt{ zL*Rba-=3hZ%Mw%VF$A>7-ER)1;Z8VVtg!7b<<6o;Z1WRMwNGv^t}X*(H5uB$R9Fjp z!T7gUX=h`52sDBh;3hZ^@_;t^Lw#XgM*msmbNOfgIV&kC8lRLiDQS_*IsQFOb@~7Q z{iOv`6MMwdMJ{8<*sk$3w)Zi&U!MN|`4nLGVaOP#m5+rJ{AKoMrg?pDJ+y9&fK6ad z4~9x$u3MLkFERc-54zuQcmq7%b~556o{vR|m$OXsv0MbK6_damYz8Ti3U$F;tO`TG z8etuGz5SYx){h=wJGS=+8M0ZIgLRjH{fq(odK0F@G_WSLgh8Mk?DIpgj*W%J@G>Z~ z^7RGRR|MO&{S(RIwgOz3Yxdt*n63l7WPXXJt z{S(QceC}f(+U-@y59flibcD%_6KMi9c)srcOYj!+)tI_mneFyD57M9oD3^X~1D@Y( z-+r>%K>uj-@z51kg8RP#7r<>$mhlul_#Xb^NtJVNn^OI(?R*D+JKy`>#=5PH1z9jL zC8x*Mf(D?!J_2=7mi}N&*iYto$i;FI&`&j>Jj@4cx#z2=p*ni5npW$a1Iv0A{drNR z2cvIIFYR6=EajWSs|LKvcbkj}|nVnGO0)xwNb2_0eE|ab8@(d@mRX z#$Zm<*k{ywMq_2IyV$$S&LZ}cs2$1xmWT)?6I8^YH-Q=iwmP+;HR-9K?$ z1*XR00=lmv#=?N-fwYZ+CcCgJpU`?tHWx-e; z1rLCJQD0+b5sU$2c{OOu=$?l6LR!~&U&xj<84P9EQh6_{cZmvNQQN=0p^48qRraC0f_qc`HtU-xrJ}xp4WDKxQTo3 z&ortUu4?seIJ@nnh;!Qd-OtX^ebX%W+dogVX?(YUo8T^30qU(Uluch7e-%Jkeuk)j z@5OoF-1~^WZ|}VKPkip;_n2JgJv#5FcpoVK-lWIFzrY&R5Y+o|C;|Oo0_bb~za9p_ ztFQ&4zI}G%cj4^Ad#^s<5uZi)txTV>`MpTLALo5ppSActyzkK`?&T=M$M7boqp_}! zt)2Qto0ubOz!-c9wgJcGz@FDc&q2JGts#&X zH-_#_O^fd5YZIUMD3`YJ*^A#Ys>^qqM*rIfpCsC~{k&lPy&opR+h83i2~UCRH`%|> zy?n>r`=s7O^`5ogyYiWh&oPZdpSSwGNAIWlT|8sdd&cc}Zkg$O0Gnv@9(WYA|KH(7 zXb1ZI4lw`q(QPmljw*xqG`-iXjkT-K?R*~WGb8Uqdp}xP)ZJV#2aHFb<#@d9z+|`) z-hh^%EcR`G+CX1i2%c-TO*Qz+X%vJr>&*$9w+2@x&ANM;% zKEH9?f#Y5>Jum*9Hr7EyP;X`M+;5D31o~Y&bc8g}7VSYFc&)GxjwyrVI`p@3V4i1= zIqQIN?=v!=T_o)8d1Dv62dlswtO|v|dTb3a56la9w#}6azjZGX{L7qYO3cKEYUt)70I*J-#!j@4UqF4yMYaU#&ytfc@tJ{V@v`!Dbi& z)&XNud+8(X@Ev|QYEAHaRnJp<^@-ozA>NJiFTgt59g4zba2F`Y3ee7T;0ZVd>|f4? z%Al1;P|UV(nl z2X2BKa4n1lWvB$^Slo9!y_9uLVH&&+>c0dI!e`JI?&p5#-?^7snER-2bN_T9e8{?n z^leV5S4Y?b`oP+96%>crkP7w__nCRBoCUzx90$t(2^bIm0{vDB%wOBH?VYe4-Uf9X z3SWc!d+cbiJ?*D%SAhLw?l<$i+mug#&4dp?pV?0@P#60#ewV@__#9H;LeO98{R0?B z9&hc_=C*rW-~I+wy+;_%6O{`*TE~W0L(MjzY9--?fs#j zzxWMmUR(j@V0)Mj6QL{gg8^V3^@LRD2-;;axbAuI_{?#hAw%4EJY~O(ZCU1XC)#;{ zX&Gn-lR&?i)9XMRcLL8R?pFXjF55QaJ{NI&AT?h2e>AnfMfAIu_x*gI%-a$LAmcv8 z$t+~~%c6%dSOx#i?kX3(cQGIv~`h)LBbb?+H>9uAc>$^cYI1A!7 zZeZTFjmg`f0%OHT@*8y2l&WWp=J)4F{tqA9)c4k~zF=M`$2njgwtzvn2B7IjUyZx`O{BXv>1I zW5cX4v+AC730$5Y*+avQmcOVJ83VcH4d+LObgHT=pP>I_FiaQ_Ytle zqHC7@Oto)AXamOjRA>d;92e>LW(%eb4%ah=LCh;BlB~5GHv!_;_Cw}T$?JBR?^}GI3XMH~s%ok%gFQ|*Q z+yG11-{*lfu;~h>$*i-sZG%Rj-|mJu{^K@EvCdc+0mkJtSP$C9IN1c+{wBzLY&@?n c+CzIP>qM|_b^&AiMaU2F{#mNQf4=y?0Fxu)R{#J2 literal 0 HcmV?d00001 diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index af8d599..0000000 --- a/public/vite.svg +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/src/api/auth/google-profile-api.ts b/src/api/auth/google-profile-api.ts index 8c594db..215213e 100644 --- a/src/api/auth/google-profile-api.ts +++ b/src/api/auth/google-profile-api.ts @@ -56,7 +56,7 @@ const inferAuthRole = (organization?: PeopleApiOrganization): AuthRole => { throw new GoogleProfileRoleError(); } - return "STUDENT"; + throw new GoogleProfileRoleError(); }; export const fetchGoogleProfile = async (accessToken: string): Promise => { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ef6b1de..5fd226a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,14 @@ import "./globals.css"; export const metadata: Metadata = { title: "AIM AJOU", description: "AIM AJOU frontend", + icons: { + icon: [ + { url: "/favicon.ico", sizes: "any" }, + { url: "/assets/ajou-logo.svg", type: "image/svg+xml" }, + ], + shortcut: "/favicon.ico", + apple: "/assets/ajou-logo.svg", + }, }; type RootLayoutProps = Readonly<{ diff --git a/src/lib/auth/auth-service.ts b/src/lib/auth/auth-service.ts index 7f7c55c..083af00 100644 --- a/src/lib/auth/auth-service.ts +++ b/src/lib/auth/auth-service.ts @@ -46,19 +46,25 @@ const createSession = async ( export const signInWithGoogle = async (): Promise => { const credential = await signInWithPopup(auth, googleProvider); - const googleCredential = GoogleAuthProvider.credentialFromResult(credential); - const accessToken = googleCredential?.accessToken; - if (!accessToken) { - throw new Error("Google access token is missing."); - } + try { + const googleCredential = GoogleAuthProvider.credentialFromResult(credential); + const accessToken = googleCredential?.accessToken; - const [idToken, profile] = await Promise.all([ - credential.user.getIdToken(), - fetchGoogleProfile(accessToken), - ]); + if (!accessToken) { + throw new Error("Google access token is missing."); + } - return createSession(idToken, profile); + const [idToken, profile] = await Promise.all([ + credential.user.getIdToken(), + fetchGoogleProfile(accessToken), + ]); + + return await createSession(idToken, profile); + } catch (error) { + await firebaseSignOut(auth).catch(() => undefined); + throw error; + } }; export const signInWithEmail = async ( diff --git a/src/shared/ui/modal/modal.tsx b/src/shared/ui/modal/modal.tsx index a66f649..470e62b 100644 --- a/src/shared/ui/modal/modal.tsx +++ b/src/shared/ui/modal/modal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useId, useState } from "react"; +import React, { useEffect, useId } from "react"; import { createPortal } from "react-dom"; import { XIcon } from "@/shared/ui/icons"; @@ -56,15 +56,8 @@ const getClasses = (...classes: (string | undefined)[]) => classes.filter(Boolea export const Modal = ({ isOpen, onClose, children, className }: ModalProps) => { const generatedId = useId(); const titleId = `modal-title-${generatedId}`; - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - setIsMounted(true); - return () => setIsMounted(false); - }, []); - useEffect(() => { - if (!isMounted || !isOpen) return; + if (!isOpen || typeof document === "undefined") return; // ESC 키 대응 const handleEsc = (e: KeyboardEvent) => { @@ -87,9 +80,9 @@ export const Modal = ({ isOpen, onClose, children, className }: ModalProps) => { } window.removeEventListener("keydown", handleEsc); }; - }, [isMounted, isOpen, onClose]); + }, [isOpen, onClose]); - if (!isMounted || !isOpen) return null; + if (!isOpen || typeof document === "undefined") return null; // 자식 요소들에 titleId를 주입하기 위해 React.Children.map 사용 (ModalHeader 탐색) const childrenWithA11y = React.Children.map(children, (child) => {