feat: implement Phase 4 SvelteKit frontend + backend enhancements

Backend:
- Add dashboard API (today stats, inbox count, law alerts, pipeline status)
- Add /api/documents/tree endpoint for sidebar domain/sub_group tree
- Migrate auth to HttpOnly cookie for refresh token (XSS defense)
- Add /api/auth/logout endpoint (cookie cleanup)
- Register dashboard router in main.py

Frontend (SvelteKit + Tailwind CSS v4):
- api.ts: fetch wrapper with refresh queue pattern, 401 single retry,
  forced logout on refresh failure
- Auth store: login/logout/refresh with memory-based access token
- UI store: toast system, sidebar state
- Login page with TOTP support
- Dashboard with 4 stat widgets + recent documents
- Document list with hybrid search (debounce, URL query state, mode select)
- Document detail with format-aware viewer (markdown/PDF/HWP/Synology/fallback)
- Metadata panel (AI summary, tags, processing history)
- Inbox triage UI (batch select, confirm dialog, domain override)
- Settings page (password change, TOTP status)

Infrastructure:
- Enable frontend service in docker-compose
- Caddy path routing (/api/* → fastapi, / → frontend) + gzip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-03 06:46:19 +09:00
parent 46537ee11a
commit cfa95ff031
19 changed files with 1380 additions and 41 deletions

View File

@@ -0,0 +1,89 @@
<script>
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { user } from '$lib/stores/auth';
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
let changing = false;
async function changePassword() {
if (newPassword !== confirmPassword) {
addToast('error', '새 비밀번호가 일치하지 않습니다');
return;
}
if (newPassword.length < 8) {
addToast('error', '비밀번호는 8자 이상이어야 합니다');
return;
}
changing = true;
try {
await api('/auth/change-password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
addToast('success', '비밀번호가 변경되었습니다');
currentPassword = '';
newPassword = '';
confirmPassword = '';
} catch (err) {
addToast('error', err.detail || '비밀번호 변경 실패');
} finally {
changing = false;
}
}
</script>
<div class="min-h-screen">
<nav class="flex items-center gap-2 px-6 py-3 border-b border-[var(--border)] bg-[var(--surface)] text-sm">
<a href="/" class="text-[var(--text-dim)] hover:text-[var(--text)]">PKM</a>
<span class="text-[var(--text-dim)]">/</span>
<span>설정</span>
</nav>
<div class="max-w-lg mx-auto p-6">
<!-- 계정 정보 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5 mb-6">
<h2 class="text-lg font-semibold mb-3">계정 정보</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">아이디</dt>
<dd>{$user?.username}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">2FA (TOTP)</dt>
<dd class={$user?.totp_enabled ? 'text-[var(--success)]' : 'text-[var(--text-dim)]'}>
{$user?.totp_enabled ? '활성' : '비활성'}
</dd>
</div>
</dl>
</div>
<!-- 비밀번호 변경 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h2 class="text-lg font-semibold mb-3">비밀번호 변경</h2>
<form onsubmit={(e) => { e.preventDefault(); changePassword(); }} class="space-y-3">
<div>
<label class="block text-sm text-[var(--text-dim)] mb-1">현재 비밀번호</label>
<input type="password" bind:value={currentPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
</div>
<div>
<label class="block text-sm text-[var(--text-dim)] mb-1">새 비밀번호</label>
<input type="password" bind:value={newPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
</div>
<div>
<label class="block text-sm text-[var(--text-dim)] mb-1">새 비밀번호 확인</label>
<input type="password" bind:value={confirmPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
</div>
<button type="submit" disabled={changing} class="w-full py-2.5 bg-[var(--accent)] text-white rounded-lg disabled:opacity-50">
{changing ? '변경 중...' : '비밀번호 변경'}
</button>
</form>
</div>
</div>
</div>