Add site-wide login gate protecting the whole app

Chat/memory/logs/models APIs were reachable without any auth even
though the app is deployed publicly behind Caddy HTTPS; only the
settings screen had a password. Add an optional SITE_PASSWORD-backed
session cookie gate in front of everything (settings included, on
top of its existing SETTINGS_PASSWORD), with a matching login screen
on the frontend. Stays fully backward compatible: with SITE_PASSWORD
unset the app behaves exactly as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 23:04:01 +09:00
co-authored by Claude Sonnet 5
parent 002282a8b7
commit 1ad8b130c7
12 changed files with 319 additions and 38 deletions
+54 -31
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import SessionList from './components/SessionList.vue'
import ChatWindow from './components/ChatWindow.vue'
import MessageInput from './components/MessageInput.vue'
@@ -10,6 +10,10 @@ import ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue'
import Clock from './components/Clock.vue'
import WordPool from './components/WordPool.vue'
import LoginGate from './components/LoginGate.vue'
import { useAuthStore } from './stores/auth'
const authStore = useAuthStore()
type SidePanel = 'none' | 'logs' | 'settings'
const activePanel = ref<SidePanel>('none')
@@ -17,42 +21,61 @@ const activePanel = ref<SidePanel>('none')
function togglePanel(panel: SidePanel) {
activePanel.value = activePanel.value === panel ? 'none' : panel
}
function handleUnauthorized() {
authStore.markUnauthenticated()
}
onMounted(() => {
authStore.checkStatus()
window.addEventListener('playchat:unauthorized', handleUnauthorized)
})
onUnmounted(() => {
window.removeEventListener('playchat:unauthorized', handleUnauthorized)
})
</script>
<template>
<div class="app">
<div class="sidebar">
<SessionList />
<MemoryMonitor />
<WordPool />
</div>
<div class="main">
<header>
<span class="title">PlayChat</span>
<ModelSelector />
<Clock />
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
設定
</button>
<button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')">
ログ
</button>
</header>
<div class="body">
<div class="chat-pane">
<ChatWindow />
<MessageInput />
</div>
<div v-if="activePanel === 'logs'" class="log-pane">
<LogViewer />
</div>
<div v-else-if="activePanel === 'settings'" class="log-pane">
<SettingsPanel />
<template v-if="authStore.loading" />
<LoginGate v-else-if="authStore.required && !authStore.authenticated" />
<template v-else>
<div class="app">
<div class="sidebar">
<SessionList />
<MemoryMonitor />
<WordPool />
</div>
<div class="main">
<header>
<span class="title">PlayChat</span>
<ModelSelector />
<Clock />
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
設定
</button>
<button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')">
ログ
</button>
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
ログアウト
</button>
</header>
<div class="body">
<div class="chat-pane">
<ChatWindow />
<MessageInput />
</div>
<div v-if="activePanel === 'logs'" class="log-pane">
<LogViewer />
</div>
<div v-else-if="activePanel === 'settings'" class="log-pane">
<SettingsPanel />
</div>
</div>
</div>
</div>
</div>
<ToastList />
<ToastList />
</template>
</template>
<style>
+24
View File
@@ -34,6 +34,7 @@ async function parseJson<T>(res: Response, label: string): Promise<T> {
throw new Error(`${label}: バックエンドに接続できません (${res.status} ${res.statusText})`)
}
if (!res.ok) {
if (res.status === 401) window.dispatchEvent(new Event('playchat:unauthorized'))
const body = await res.json().catch(() => ({}))
throw new Error(`${label}: ${body.detail ?? res.statusText}`)
}
@@ -81,6 +82,29 @@ export async function deleteLog(logId: number): Promise<void> {
await fetch(`${BASE}/logs/${logId}`, { method: 'DELETE' })
}
export interface AuthStatus {
required: boolean
authenticated: boolean
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
const res = await fetch(`${BASE}/api/auth/status`)
return parseJson<AuthStatus>(res, '認証状態取得')
}
export async function login(password: string): Promise<AuthStatus> {
const res = await fetch(`${BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
return parseJson<AuthStatus>(res, 'ログイン')
}
export async function logout(): Promise<void> {
await fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
}
export interface SettingEntry {
key: string
is_set: boolean
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '../stores/auth'
const store = useAuthStore()
const passwordInput = ref('')
async function submit() {
const ok = await store.login(passwordInput.value)
if (ok) passwordInput.value = ''
}
</script>
<template>
<div class="login-gate">
<form class="card" @submit.prevent="submit">
<span class="title">PlayChat</span>
<p class="hint">サイトを利用するにはパスワードが必要です。</p>
<input
v-model="passwordInput"
type="password"
placeholder="パスワード"
autocomplete="current-password"
autofocus
/>
<button type="submit" :disabled="store.loading || !passwordInput">
{{ store.loading ? '確認中...' : 'ログイン' }}
</button>
<p v-if="store.error" class="error">{{ store.error }}</p>
</form>
</div>
</template>
<style scoped>
.login-gate {
height: 100vh; display: flex; align-items: center; justify-content: center;
background: #111;
}
.card {
display: flex; flex-direction: column; gap: 10px;
width: 280px; padding: 24px; background: #1a1a1a;
border: 1px solid #2a2a2a; border-radius: 10px;
}
.title { font-size: 18px; font-weight: 600; color: #fff; }
.hint { font-size: 12px; color: #888; }
input {
background: #2a2a2a; color: #eee; border: 1px solid #444;
border-radius: 6px; padding: 8px 10px; font-size: 13px; width: 100%;
}
button {
background: #1d3557; color: #93c5fd; border: 1px solid #2563eb;
border-radius: 6px; padding: 8px 10px; font-size: 13px; cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: default; }
.error { font-size: 12px; color: #f87171; }
</style>
+51
View File
@@ -0,0 +1,51 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchAuthStatus, login as loginApi, logout as logoutApi } from '../api/client'
export const useAuthStore = defineStore('auth', () => {
const required = ref(false)
const authenticated = ref(false)
const loading = ref(true)
const error = ref<string | null>(null)
async function checkStatus(): Promise<void> {
loading.value = true
try {
const status = await fetchAuthStatus()
required.value = status.required
authenticated.value = status.authenticated
} catch (e) {
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
} finally {
loading.value = false
}
}
async function login(password: string): Promise<boolean> {
loading.value = true
error.value = null
try {
const status = await loginApi(password)
required.value = status.required
authenticated.value = status.authenticated
return true
} catch (e) {
authenticated.value = false
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
return false
} finally {
loading.value = false
}
}
async function logout(): Promise<void> {
await logoutApi().catch(() => {})
authenticated.value = false
}
function markUnauthenticated(): void {
authenticated.value = false
}
return { required, authenticated, loading, error, checkStatus, login, logout, markUnauthenticated }
})