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:
+54
-31
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
})
|
||||
Reference in New Issue
Block a user