diff --git a/Caddyfile b/Caddyfile index 66b79c5..27aa2db 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,7 +1,27 @@ pkm.hyungi.net { - # Phase 0~3: FastAPI가 모든 요청 처리 (셋업 위자드 포함) - # Phase 4: 프론트엔드 분리 시 경로별 라우팅 추가 - reverse_proxy fastapi:8000 + encode gzip + + # API + 문서 → FastAPI + handle /api/* { + reverse_proxy fastapi:8000 + } + handle /docs { + reverse_proxy fastapi:8000 + } + handle /openapi.json { + reverse_proxy fastapi:8000 + } + handle /health { + reverse_proxy fastapi:8000 + } + handle /setup { + reverse_proxy fastapi:8000 + } + + # 프론트엔드 + handle { + reverse_proxy frontend:3000 + } } # Synology Office 프록시 diff --git a/app/api/auth.py b/app/api/auth.py index 8c68b8a..b9ce637 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,14 +1,19 @@ -"""인증 API — 로그인, 토큰 갱신, TOTP 검증""" +"""인증 API — 로그인, 토큰 갱신, TOTP 검증 + +access token: 응답 body (프론트에서 메모리 보관) +refresh token: HttpOnly cookie (XSS 방어) +""" from datetime import datetime, timezone from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import ( + REFRESH_TOKEN_EXPIRE_DAYS, create_access_token, create_refresh_token, decode_token, @@ -32,16 +37,11 @@ class LoginRequest(BaseModel): totp_code: str | None = None -class TokenResponse(BaseModel): +class AccessTokenResponse(BaseModel): access_token: str - refresh_token: str token_type: str = "bearer" -class RefreshRequest(BaseModel): - refresh_token: str - - class ChangePasswordRequest(BaseModel): current_password: str new_password: str @@ -58,15 +58,31 @@ class UserResponse(BaseModel): from_attributes = True +# ─── 헬퍼 ─── + +def _set_refresh_cookie(response: Response, token: str): + """refresh token을 HttpOnly cookie로 설정""" + response.set_cookie( + key="refresh_token", + value=token, + httponly=True, + secure=True, + samesite="strict", + max_age=REFRESH_TOKEN_EXPIRE_DAYS * 86400, + path="/api/auth", + ) + + # ─── 엔드포인트 ─── -@router.post("/login", response_model=TokenResponse) +@router.post("/login", response_model=AccessTokenResponse) async def login( body: LoginRequest, + response: Response, session: Annotated[AsyncSession, Depends(get_session)], ): - """로그인 → JWT 발급""" + """로그인 → access token(body) + refresh token(cookie)""" result = await session.execute( select(User).where(User.username == body.username) ) @@ -101,19 +117,28 @@ async def login( user.last_login_at = datetime.now(timezone.utc) await session.commit() - return TokenResponse( + # refresh token → HttpOnly cookie + _set_refresh_cookie(response, create_refresh_token(user.username)) + + return AccessTokenResponse( access_token=create_access_token(user.username), - refresh_token=create_refresh_token(user.username), ) -@router.post("/refresh", response_model=TokenResponse) +@router.post("/refresh", response_model=AccessTokenResponse) async def refresh_token( - body: RefreshRequest, + response: Response, session: Annotated[AsyncSession, Depends(get_session)], + refresh_token: str | None = Cookie(None), ): - """리프레시 토큰으로 새 토큰 쌍 발급""" - payload = decode_token(body.refresh_token) + """cookie의 refresh token으로 새 토큰 쌍 발급""" + if not refresh_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="리프레시 토큰이 없습니다", + ) + + payload = decode_token(refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -131,12 +156,21 @@ async def refresh_token( detail="유저를 찾을 수 없음", ) - return TokenResponse( + # 새 refresh token → cookie + _set_refresh_cookie(response, create_refresh_token(user.username)) + + return AccessTokenResponse( access_token=create_access_token(user.username), - refresh_token=create_refresh_token(user.username), ) +@router.post("/logout") +async def logout(response: Response): + """로그아웃 — refresh cookie 삭제""" + response.delete_cookie("refresh_token", path="/api/auth") + return {"message": "로그아웃 완료"} + + @router.get("/me", response_model=UserResponse) async def get_me(user: Annotated[User, Depends(get_current_user)]): """현재 로그인한 유저 정보""" diff --git a/app/api/dashboard.py b/app/api/dashboard.py new file mode 100644 index 0000000..463ba8d --- /dev/null +++ b/app/api/dashboard.py @@ -0,0 +1,135 @@ +"""대시보드 위젯 데이터 API""" + +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from core.database import get_session +from models.document import Document +from models.queue import ProcessingQueue +from models.user import User + +router = APIRouter() + + +class DomainCount(BaseModel): + domain: str | None + count: int + + +class RecentDocument(BaseModel): + id: int + title: str | None + file_format: str + ai_domain: str | None + created_at: str + + +class PipelineStatus(BaseModel): + stage: str + status: str + count: int + + +class DashboardResponse(BaseModel): + today_added: int + today_by_domain: list[DomainCount] + inbox_count: int + law_alerts: int + recent_documents: list[RecentDocument] + pipeline_status: list[PipelineStatus] + failed_count: int + total_documents: int + + +@router.get("/", response_model=DashboardResponse) +async def get_dashboard( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """대시보드 위젯 데이터 집계""" + + # 오늘 추가된 문서 + today_result = await session.execute( + select(Document.ai_domain, func.count(Document.id)) + .where(func.date(Document.created_at) == func.current_date()) + .group_by(Document.ai_domain) + ) + today_rows = today_result.all() + today_added = sum(row[1] for row in today_rows) + + # Inbox 미분류 수 + inbox_result = await session.execute( + select(func.count(Document.id)) + .where(Document.file_path.like("PKM/Inbox/%")) + ) + inbox_count = inbox_result.scalar() or 0 + + # 법령 알림 (오늘) + law_result = await session.execute( + select(func.count(Document.id)) + .where( + Document.source_channel == "law_monitor", + func.date(Document.created_at) == func.current_date(), + ) + ) + law_alerts = law_result.scalar() or 0 + + # 최근 문서 5건 + recent_result = await session.execute( + select(Document) + .order_by(Document.created_at.desc()) + .limit(5) + ) + recent_docs = recent_result.scalars().all() + + # 파이프라인 상태 (24h) + pipeline_result = await session.execute( + text(""" + SELECT stage, status, COUNT(*) + FROM processing_queue + WHERE created_at > NOW() - INTERVAL '24 hours' + GROUP BY stage, status + """) + ) + + # 실패 건수 + failed_result = await session.execute( + select(func.count()) + .select_from(ProcessingQueue) + .where(ProcessingQueue.status == "failed") + ) + failed_count = failed_result.scalar() or 0 + + # 전체 문서 수 + total_result = await session.execute(select(func.count(Document.id))) + total_documents = total_result.scalar() or 0 + + return DashboardResponse( + today_added=today_added, + today_by_domain=[ + DomainCount(domain=row[0], count=row[1]) for row in today_rows + ], + inbox_count=inbox_count, + law_alerts=law_alerts, + recent_documents=[ + RecentDocument( + id=doc.id, + title=doc.title, + file_format=doc.file_format, + ai_domain=doc.ai_domain, + created_at=doc.created_at.isoformat() if doc.created_at else "", + ) + for doc in recent_docs + ], + pipeline_status=[ + PipelineStatus(stage=row[0], status=row[1], count=row[2]) + for row in pipeline_result + ], + failed_count=failed_count, + total_documents=total_documents, + ) diff --git a/app/api/documents.py b/app/api/documents.py index 9755b21..32d8515 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -63,9 +63,52 @@ class DocumentUpdate(BaseModel): data_origin: str | None = None +# ─── 스키마 (트리) ─── + + +class SubGroupNode(BaseModel): + sub_group: str + count: int + + +class DomainNode(BaseModel): + domain: str + count: int + children: list[SubGroupNode] + + # ─── 엔드포인트 ─── +@router.get("/tree", response_model=list[DomainNode]) +async def get_document_tree( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """도메인/sub_group 트리 (사이드바용)""" + from sqlalchemy import text as sql_text + + result = await session.execute( + sql_text(""" + SELECT ai_domain, ai_sub_group, COUNT(*) + FROM documents + WHERE ai_domain IS NOT NULL + GROUP BY ai_domain, ai_sub_group + ORDER BY ai_domain, ai_sub_group + """) + ) + + tree: dict[str, DomainNode] = {} + for domain, sub_group, count in result: + if domain not in tree: + tree[domain] = DomainNode(domain=domain, count=0, children=[]) + tree[domain].count += count + if sub_group: + tree[domain].children.append(SubGroupNode(sub_group=sub_group, count=count)) + + return list(tree.values()) + + @router.get("/", response_model=DocumentListResponse) async def list_documents( user: Annotated[User, Depends(get_current_user)], diff --git a/app/main.py b/app/main.py index d4d140a..cad2429 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from fastapi.responses import RedirectResponse from sqlalchemy import func, select, text from api.auth import router as auth_router +from api.dashboard import router as dashboard_router from api.documents import router as documents_router from api.search import router as search_router from api.setup import router as setup_router @@ -61,9 +62,10 @@ app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) app.include_router(documents_router, prefix="/api/documents", tags=["documents"]) app.include_router(search_router, prefix="/api/search", tags=["search"]) -# TODO: Phase 3~4에서 추가 +app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"]) + +# TODO: Phase 5에서 추가 # app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"]) -# app.include_router(dashboard.router, prefix="/api/dashboard", tags=["dashboard"]) # app.include_router(export.router, prefix="/api/export", tags=["export"]) diff --git a/docker-compose.yml b/docker-compose.yml index 513f583..c5fe68d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,14 +51,13 @@ services: - KORDOC_ENDPOINT=http://kordoc-service:3100 restart: unless-stopped - # frontend: Phase 4에서 SvelteKit 빌드 안정화 후 활성화 - # frontend: - # build: ./frontend - # ports: - # - "3000:3000" - # depends_on: - # - fastapi - # restart: unless-stopped + frontend: + build: ./frontend + ports: + - "3000:3000" + depends_on: + - fastapi + restart: unless-stopped caddy: image: caddy:2 @@ -70,6 +69,7 @@ services: - caddy_data:/data depends_on: - fastapi + - frontend restart: unless-stopped volumes: diff --git a/frontend/package.json b/frontend/package.json index 43c2e12..7fca933 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,13 @@ "devDependencies": { "@sveltejs/adapter-node": "^5.0.0", "@sveltejs/kit": "^2.0.0", + "@tailwindcss/vite": "^4.0.0", "svelte": "^5.0.0", + "tailwindcss": "^4.0.0", "vite": "^8.0.0" + }, + "dependencies": { + "lucide-svelte": "^0.400.0", + "marked": "^15.0.0" } } diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..ac22664 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --border: #2a2d3a; + --text: #e4e4e7; + --text-dim: #8b8d98; + --accent: #6c8aff; + --accent-hover: #859dff; + --error: #f5564e; + --success: #4ade80; + --warning: #fbbf24; +} + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + margin: 0; +} + +/* 스크롤바 */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..1b71f89 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,133 @@ +/** + * API fetch 래퍼 + * + * - access token: 메모리 변수 + * - refresh token: HttpOnly cookie (서버가 관리) + * - refresh 중복 방지: isRefreshing 플래그 + 대기 큐 + * - 401 retry: 1회만, 실패 시 강제 logout + */ + +const API_BASE = '/api'; + +let accessToken: string | null = null; + +// refresh 큐 +let isRefreshing = false; +let refreshQueue: Array<{ + resolve: (token: string) => void; + reject: (err: Error) => void; +}> = []; + +export function setAccessToken(token: string | null) { + accessToken = token; +} + +export function getAccessToken(): string | null { + return accessToken; +} + +async function refreshAccessToken(): Promise { + const res = await fetch(`${API_BASE}/auth/refresh`, { + method: 'POST', + credentials: 'include', // cookie 전송 + }); + if (!res.ok) { + throw new Error('refresh failed'); + } + const data = await res.json(); + accessToken = data.access_token; + return data.access_token; +} + +function processRefreshQueue(error: Error | null, token: string | null) { + refreshQueue.forEach(({ resolve, reject }) => { + if (error) reject(error); + else resolve(token!); + }); + refreshQueue = []; +} + +async function handleTokenRefresh(): Promise { + if (isRefreshing) { + return new Promise((resolve, reject) => { + refreshQueue.push({ resolve, reject }); + }); + } + + isRefreshing = true; + try { + const token = await refreshAccessToken(); + processRefreshQueue(null, token); + return token; + } catch (err) { + const error = err instanceof Error ? err : new Error('refresh failed'); + processRefreshQueue(error, null); + // 강제 logout + accessToken = null; + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + throw error; + } finally { + isRefreshing = false; + } +} + +export type ApiError = { + status: number; + detail: string; +}; + +export async function api( + path: string, + options: RequestInit = {}, +): Promise { + const headers: Record = { + ...(options.headers as Record || {}), + }; + + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + + // FormData일 때는 Content-Type 자동 설정 + if (options.body && !(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + credentials: 'include', + }); + + // 401 → refresh 1회 시도 + if (res.status === 401 && accessToken) { + try { + await handleTokenRefresh(); + headers['Authorization'] = `Bearer ${accessToken}`; + const retryRes = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + credentials: 'include', + }); + if (!retryRes.ok) { + const err = await retryRes.json().catch(() => ({ detail: 'Unknown error' })); + throw { status: retryRes.status, detail: err.detail || retryRes.statusText }; + } + return retryRes.json(); + } catch { + throw { status: 401, detail: '인증이 만료되었습니다' }; + } + } + + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw { status: res.status, detail: err.detail || res.statusText } as ApiError; + } + + // 204 No Content + if (res.status === 204) return {} as T; + + return res.json(); +} diff --git a/frontend/src/lib/stores/auth.ts b/frontend/src/lib/stores/auth.ts new file mode 100644 index 0000000..fb38452 --- /dev/null +++ b/frontend/src/lib/stores/auth.ts @@ -0,0 +1,55 @@ +import { writable } from 'svelte/store'; +import { api, setAccessToken } from '$lib/api'; + +interface User { + id: number; + username: string; + is_active: boolean; + totp_enabled: boolean; + last_login_at: string | null; +} + +export const user = writable(null); +export const isAuthenticated = writable(false); + +export async function login(username: string, password: string, totp_code?: string) { + const data = await api<{ access_token: string }>('/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password, totp_code: totp_code || undefined }), + }); + setAccessToken(data.access_token); + await fetchUser(); +} + +export async function fetchUser() { + try { + const data = await api('/auth/me'); + user.set(data); + isAuthenticated.set(true); + } catch { + user.set(null); + isAuthenticated.set(false); + } +} + +export async function logout() { + try { + await api('/auth/logout', { method: 'POST' }); + } catch { /* ignore */ } + setAccessToken(null); + user.set(null); + isAuthenticated.set(false); +} + +export async function tryRefresh() { + try { + const data = await api<{ access_token: string }>('/auth/refresh', { + method: 'POST', + }); + setAccessToken(data.access_token); + await fetchUser(); + return true; + } catch { + return false; + } +} diff --git a/frontend/src/lib/stores/ui.ts b/frontend/src/lib/stores/ui.ts new file mode 100644 index 0000000..0261fcd --- /dev/null +++ b/frontend/src/lib/stores/ui.ts @@ -0,0 +1,27 @@ +import { writable } from 'svelte/store'; + +export const sidebarOpen = writable(true); +export const selectedDocId = writable(null); + +// Toast 시스템 +interface Toast { + id: number; + type: 'success' | 'error' | 'warning' | 'info'; + message: string; +} + +let toastId = 0; +export const toasts = writable([]); + +export function addToast(type: Toast['type'], message: string, duration = 5000) { + const id = ++toastId; + toasts.update(t => [...t, { id, type, message }]); + if (duration > 0) { + setTimeout(() => removeToast(id), duration); + } + return id; +} + +export function removeToast(id: number) { + toasts.update(t => t.filter(toast => toast.id !== id)); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..49fa6b3 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,57 @@ + + + + +{#if $isAuthenticated || PUBLIC_PATHS.some(p => $page.url.pathname.startsWith(p))} + +{/if} + + +
+ {#each $toasts as toast (toast.id)} + + {/each} +
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 317a4bd..3af6740 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,14 +1,107 @@ -

hyungi Document Server

-

PKM 대시보드 — Phase 4에서 구현 예정

+
+ + -
-

시스템 상태

- -
+ +
+

대시보드

+ + {#if loading} +
+ {#each Array(4) as _} +
+ {/each} +
+ {:else if dashboard} + +
+ +
+

전체 문서

+

{dashboard.total_documents}

+

오늘 +{dashboard.today_added}

+
+ + +
+

Inbox 미분류

+

0}>{dashboard.inbox_count}

+ {#if dashboard.inbox_count > 0} + 분류하기 + {/if} +
+ + +
+

법령 알림

+

{dashboard.law_alerts}

+

오늘 변경

+
+ + +
+

파이프라인

+ {#if dashboard.failed_count > 0} +

{dashboard.failed_count} 실패

+ {:else} +

정상

+ {/if} +
+
+ + +
+

최근 문서

+ {#if dashboard.recent_documents.length > 0} +
+ {#each dashboard.recent_documents as doc} + +
+ {doc.file_format} + {doc.title || '제목 없음'} +
+ {doc.ai_domain || ''} +
+ {/each} +
+ {:else} +

문서가 없습니다

+ {/if} +
+ {/if} +
+
diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte new file mode 100644 index 0000000..52ab793 --- /dev/null +++ b/frontend/src/routes/documents/+page.svelte @@ -0,0 +1,181 @@ + + +
+ + +
+ +
+ + +
+ + + {#if loading} +
+ {#each Array(5) as _} +
+ {/each} +
+ {:else if items.length === 0} +
+ {#if searchQuery} +

'{searchQuery}'에 대한 결과가 없습니다

+

다른 검색어를 시도하거나 검색 모드를 변경해보세요

+ {:else} +

등록된 문서가 없습니다

+ {/if} +
+ {:else} + + + + {#if !searchResults && totalPages > 1} +
+ {#each Array(totalPages) as _, i} + + {/each} +
+ {/if} + {/if} +
+
diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte new file mode 100644 index 0000000..f5148fe --- /dev/null +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -0,0 +1,150 @@ + + +
+ + + {#if loading} +
+
+
+ {:else if doc} +
+ +
+ {#if viewerType === 'markdown' || viewerType === 'hwp-markdown'} +
+ {@html marked(doc.extracted_text || '*텍스트 추출 대기 중*')} +
+ {:else if viewerType === 'pdf'} + + {:else if viewerType === 'image'} + {doc.title} + {:else if viewerType === 'synology'} +
+

Synology Office 문서

+ + 새 창에서 열기 + +
+ {:else} +
+

이 문서 형식은 인앱 미리보기를 지원하지 않습니다

+

포맷: {doc.file_format}

+
+ {/if} +
+ + +
+ +
+

문서 정보

+
+
+
포맷
+
{doc.file_format}
+
+
+
크기
+
{doc.file_size ? (doc.file_size / 1024).toFixed(1) + ' KB' : '-'}
+
+
+
도메인
+
{doc.ai_domain || '미분류'}
+
+
+
출처
+
{doc.source_channel || '-'}
+
+
+
+ + + {#if doc.ai_summary} +
+

AI 요약

+

{doc.ai_summary}

+
+ {/if} + + + {#if doc.ai_tags?.length > 0} +
+

태그

+
+ {#each doc.ai_tags as tag} + {tag} + {/each} +
+
+ {/if} + + +
+

가공 이력

+
+
+
텍스트 추출
+
{doc.extracted_at ? new Date(doc.extracted_at).toLocaleDateString('ko') : '대기'}
+
+
+
AI 분류
+
{doc.ai_processed_at ? new Date(doc.ai_processed_at).toLocaleDateString('ko') : '대기'}
+
+
+
벡터 임베딩
+
{doc.embedded_at ? new Date(doc.embedded_at).toLocaleDateString('ko') : '대기'}
+
+
+
+
+
+ {/if} +
diff --git a/frontend/src/routes/inbox/+page.svelte b/frontend/src/routes/inbox/+page.svelte new file mode 100644 index 0000000..eaf1ac2 --- /dev/null +++ b/frontend/src/routes/inbox/+page.svelte @@ -0,0 +1,192 @@ + + +
+ + +
+ {#if loading} +
+ {#each Array(3) as _} +
+ {/each} +
+ {:else if documents.length === 0} +
+

Inbox가 비어 있습니다

+

새 파일이 들어오면 자동으로 표시됩니다

+
+ {:else} +
+ {#each documents as doc} +
+ toggleSelect(doc.id)} + class="mt-1 accent-[var(--accent)]" + /> +
+
+ {doc.file_format} + {doc.title || '제목 없음'} +
+ {#if doc.ai_summary} +

{doc.ai_summary.slice(0, 120)}

+ {/if} +
+ AI 분류: + + {#if doc.ai_tags?.length > 0} +
+ {#each doc.ai_tags.slice(0, 3) as tag} + {tag} + {/each} +
+ {/if} +
+
+
+ {/each} +
+ {/if} +
+ + + {#if showConfirm} +
+
+

{selected.size}건을 승인합니다

+

AI 분류 결과를 확정하고 Inbox에서 이동합니다.

+
+ + +
+
+
+ {/if} +
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..be295d4 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,89 @@ + + +
+
+

hyungi Document Server

+

로그인

+ +
{ e.preventDefault(); handleLogin(); }} class="space-y-4"> +
+ + +
+ +
+ + +
+ + {#if needsTotp} +
+ + +
+ {/if} + + {#if error} +

{error}

+ {/if} + + +
+
+
diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte new file mode 100644 index 0000000..6adf2cf --- /dev/null +++ b/frontend/src/routes/settings/+page.svelte @@ -0,0 +1,89 @@ + + +
+ + +
+ +
+

계정 정보

+
+
+
아이디
+
{$user?.username}
+
+
+
2FA (TOTP)
+
+ {$user?.totp_enabled ? '활성' : '비활성'} +
+
+
+
+ + +
+

비밀번호 변경

+
{ e.preventDefault(); changePassword(); }} class="space-y-3"> +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..2c01dec --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,7 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], +});