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:
@@ -6,6 +6,12 @@
|
|||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
|
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
|
||||||
"port": 5173
|
"port": 5173
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "backend-dev",
|
||||||
|
"runtimeExecutable": "backend\\run_dev.cmd",
|
||||||
|
"runtimeArgs": [],
|
||||||
|
"port": 8000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,3 +107,5 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
|
|||||||
| `OPENAI_API_KEY` | — | OpenAI API キー |
|
| `OPENAI_API_KEY` | — | OpenAI API キー |
|
||||||
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
|
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
|
||||||
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
|
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
|
||||||
|
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
|
||||||
|
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ DEBUG=false
|
|||||||
# HTTPS化していない環境ではBasic認証の資格情報が平文で流れるため、公開環境ではHTTPS化とセットで使うこと。
|
# HTTPS化していない環境ではBasic認証の資格情報が平文で流れるため、公開環境ではHTTPS化とセットで使うこと。
|
||||||
SETTINGS_PASSWORD=
|
SETTINGS_PASSWORD=
|
||||||
|
|
||||||
|
# サイト全体(チャット含む全機能)を保護する簡易パスワード。SETTINGS_PASSWORDとは別物。
|
||||||
|
# 未設定だとこれまで通りサイト全体が無認証で使える。外部公開する場合は必ず設定すること。
|
||||||
|
SITE_PASSWORD=
|
||||||
|
# セッションCookieの署名鍵。未設定でも動くが、その場合バックエンド再起動のたびに全員ログインし直しになる。
|
||||||
|
# 公開環境では `openssl rand -hex 32` などで生成した値を設定しておくこと。
|
||||||
|
SESSION_SECRET_KEY=
|
||||||
|
# ローカルのhttp開発環境(HTTPS化していない)でログインが効かない場合のみ false にする。公開環境では変更しないこと。
|
||||||
|
SESSION_COOKIE_SECURE=true
|
||||||
|
# ログインセッションの有効日数
|
||||||
|
SESSION_MAX_AGE_DAYS=30
|
||||||
|
|
||||||
# Tavily Web検索(未設定だとweb_searchツールは何も検索せず空振りする)
|
# Tavily Web検索(未設定だとweb_searchツールは何も検索せず空振りする)
|
||||||
TAVILY_API_KEY=
|
TAVILY_API_KEY=
|
||||||
# 蓄積知識をシステムプロンプトに注入する際の最大件数
|
# 蓄積知識をシステムプロンプトに注入する際の最大件数
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.config import SESSION_COOKIE_SECURE, SESSION_MAX_AGE_DAYS, SITE_PASSWORD
|
||||||
|
from app.core.site_auth import COOKIE_NAME, create_session_token, verify_session_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuthStatus(BaseModel):
|
||||||
|
required: bool
|
||||||
|
authenticated: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=AuthStatus)
|
||||||
|
def status(request: Request):
|
||||||
|
if not SITE_PASSWORD:
|
||||||
|
return AuthStatus(required=False, authenticated=True)
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
return AuthStatus(required=True, authenticated=bool(token and verify_session_token(token)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=AuthStatus)
|
||||||
|
def login(req: LoginRequest, response: Response):
|
||||||
|
if not SITE_PASSWORD:
|
||||||
|
raise HTTPException(status_code=403, detail="SITE_PASSWORD is not configured on the server")
|
||||||
|
if not secrets.compare_digest(req.password, SITE_PASSWORD):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
|
response.set_cookie(
|
||||||
|
COOKIE_NAME,
|
||||||
|
create_session_token(),
|
||||||
|
max_age=SESSION_MAX_AGE_DAYS * 86400,
|
||||||
|
httponly=True,
|
||||||
|
secure=SESSION_COOKIE_SECURE,
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return AuthStatus(required=True, authenticated=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=AuthStatus)
|
||||||
|
def logout(response: Response):
|
||||||
|
response.delete_cookie(COOKIE_NAME, path="/")
|
||||||
|
return AuthStatus(required=bool(SITE_PASSWORD), authenticated=False)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -12,6 +13,14 @@ DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
|
|||||||
# GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
|
# GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
|
||||||
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD")
|
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD")
|
||||||
|
|
||||||
|
# サイト全体(設定画面以外の全API)を保護する簡易パスワード。未設定の場合はこれまで通り無認証で動く。
|
||||||
|
SITE_PASSWORD: str | None = os.getenv("SITE_PASSWORD")
|
||||||
|
# セッションCookieの署名に使う秘密鍵。未設定なら起動毎に使い捨てキーを生成する(バックエンド再起動でログインし直しになる)。
|
||||||
|
SESSION_SECRET_KEY: str = os.getenv("SESSION_SECRET_KEY") or secrets.token_hex(32)
|
||||||
|
# ローカルのhttp開発環境ではSecure Cookieが送信されないため、その場合のみfalseにする。公開環境では必ずtrueのままにすること。
|
||||||
|
SESSION_COOKIE_SECURE: bool = os.getenv("SESSION_COOKIE_SECURE", "true").lower() in ("1", "true", "yes")
|
||||||
|
SESSION_MAX_AGE_DAYS: int = int(os.getenv("SESSION_MAX_AGE_DAYS", "30"))
|
||||||
|
|
||||||
# メッセージへの自動タグ付けに使うモデル(未設定ならDEFAULT_MODELを使用)
|
# メッセージへの自動タグ付けに使うモデル(未設定ならDEFAULT_MODELを使用)
|
||||||
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
|
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from app.core.config import SESSION_MAX_AGE_DAYS, SESSION_SECRET_KEY, SITE_PASSWORD
|
||||||
|
|
||||||
|
COOKIE_NAME = "playchat_session"
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(payload: str) -> str:
|
||||||
|
return hmac.new(SESSION_SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_token() -> str:
|
||||||
|
issued_at = str(int(time.time()))
|
||||||
|
return f"{issued_at}.{_sign(issued_at)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_session_token(token: str) -> bool:
|
||||||
|
issued_at, _, signature = token.partition(".")
|
||||||
|
if not issued_at or not signature:
|
||||||
|
return False
|
||||||
|
if not hmac.compare_digest(_sign(issued_at), signature):
|
||||||
|
return False
|
||||||
|
age_seconds = time.time() - int(issued_at)
|
||||||
|
return 0 <= age_seconds <= SESSION_MAX_AGE_DAYS * 86400
|
||||||
|
|
||||||
|
|
||||||
|
def require_site_session(request: Request) -> None:
|
||||||
|
"""サイト全体(設定画面以外の全API)を保護するセッションCookie認証。
|
||||||
|
|
||||||
|
SITE_PASSWORD が未設定の場合は既存のローカル開発体験を壊さないよう常に通す。
|
||||||
|
"""
|
||||||
|
if not SITE_PASSWORD:
|
||||||
|
return
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not token or not verify_session_token(token):
|
||||||
|
raise HTTPException(status_code=401, detail="Login required")
|
||||||
+10
-7
@@ -1,14 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api import auth as auth_api
|
||||||
from app.api import chat, logs, memory, system
|
from app.api import chat, logs, memory, system
|
||||||
from app.api import models as models_api
|
from app.api import models as models_api
|
||||||
from app.api import settings as settings_api
|
from app.api import settings as settings_api
|
||||||
from app.core import settings_store
|
from app.core import settings_store
|
||||||
from app.core.config import CORS_ORIGINS
|
from app.core.config import CORS_ORIGINS
|
||||||
|
from app.core.site_auth import require_site_session
|
||||||
from app.core.discord.discord_bot import start_discord_bot
|
from app.core.discord.discord_bot import start_discord_bot
|
||||||
from app.core.discord.scheduler import start_scheduler
|
from app.core.discord.scheduler import start_scheduler
|
||||||
from app.core.memory_kb.word_memory import seed_from_history
|
from app.core.memory_kb.word_memory import seed_from_history
|
||||||
@@ -88,12 +90,13 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
app.include_router(chat.router)
|
app.include_router(auth_api.router)
|
||||||
app.include_router(memory.router)
|
app.include_router(chat.router, dependencies=[Depends(require_site_session)])
|
||||||
app.include_router(logs.router)
|
app.include_router(memory.router, dependencies=[Depends(require_site_session)])
|
||||||
app.include_router(system.router)
|
app.include_router(logs.router, dependencies=[Depends(require_site_session)])
|
||||||
app.include_router(settings_api.router)
|
app.include_router(system.router, dependencies=[Depends(require_site_session)])
|
||||||
app.include_router(models_api.router)
|
app.include_router(settings_api.router, dependencies=[Depends(require_site_session)])
|
||||||
|
app.include_router(models_api.router, dependencies=[Depends(require_site_session)])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@echo off
|
||||||
|
rem SSLKEYLOGFILE がこのマシンのTLS監視プロキシの名前付きパイプを指していると
|
||||||
|
rem litellm(OpenSSLを直接ロード)が OPENSSL_Uplink エラーで起動時にクラッシュするため、
|
||||||
|
rem このコマンドの実行中だけ unset する(CLAUDE.md の「ローカル開発環境メモ」参照)。
|
||||||
|
set SSLKEYLOGFILE=
|
||||||
|
uv run --directory backend uvicorn app.main:app --reload --port 8000
|
||||||
+54
-31
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import SessionList from './components/SessionList.vue'
|
import SessionList from './components/SessionList.vue'
|
||||||
import ChatWindow from './components/ChatWindow.vue'
|
import ChatWindow from './components/ChatWindow.vue'
|
||||||
import MessageInput from './components/MessageInput.vue'
|
import MessageInput from './components/MessageInput.vue'
|
||||||
@@ -10,6 +10,10 @@ import ToastList from './components/ToastList.vue'
|
|||||||
import MemoryMonitor from './components/MemoryMonitor.vue'
|
import MemoryMonitor from './components/MemoryMonitor.vue'
|
||||||
import Clock from './components/Clock.vue'
|
import Clock from './components/Clock.vue'
|
||||||
import WordPool from './components/WordPool.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'
|
type SidePanel = 'none' | 'logs' | 'settings'
|
||||||
const activePanel = ref<SidePanel>('none')
|
const activePanel = ref<SidePanel>('none')
|
||||||
@@ -17,42 +21,61 @@ const activePanel = ref<SidePanel>('none')
|
|||||||
function togglePanel(panel: SidePanel) {
|
function togglePanel(panel: SidePanel) {
|
||||||
activePanel.value = activePanel.value === panel ? 'none' : panel
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app">
|
<template v-if="authStore.loading" />
|
||||||
<div class="sidebar">
|
<LoginGate v-else-if="authStore.required && !authStore.authenticated" />
|
||||||
<SessionList />
|
<template v-else>
|
||||||
<MemoryMonitor />
|
<div class="app">
|
||||||
<WordPool />
|
<div class="sidebar">
|
||||||
</div>
|
<SessionList />
|
||||||
<div class="main">
|
<MemoryMonitor />
|
||||||
<header>
|
<WordPool />
|
||||||
<span class="title">PlayChat</span>
|
</div>
|
||||||
<ModelSelector />
|
<div class="main">
|
||||||
<Clock />
|
<header>
|
||||||
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
|
<span class="title">PlayChat</span>
|
||||||
設定
|
<ModelSelector />
|
||||||
</button>
|
<Clock />
|
||||||
<button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')">
|
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
|
||||||
ログ
|
設定
|
||||||
</button>
|
</button>
|
||||||
</header>
|
<button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')">
|
||||||
<div class="body">
|
ログ
|
||||||
<div class="chat-pane">
|
</button>
|
||||||
<ChatWindow />
|
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
|
||||||
<MessageInput />
|
ログアウト
|
||||||
</div>
|
</button>
|
||||||
<div v-if="activePanel === 'logs'" class="log-pane">
|
</header>
|
||||||
<LogViewer />
|
<div class="body">
|
||||||
</div>
|
<div class="chat-pane">
|
||||||
<div v-else-if="activePanel === 'settings'" class="log-pane">
|
<ChatWindow />
|
||||||
<SettingsPanel />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ToastList />
|
||||||
<ToastList />
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ async function parseJson<T>(res: Response, label: string): Promise<T> {
|
|||||||
throw new Error(`${label}: バックエンドに接続できません (${res.status} ${res.statusText})`)
|
throw new Error(`${label}: バックエンドに接続できません (${res.status} ${res.statusText})`)
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) window.dispatchEvent(new Event('playchat:unauthorized'))
|
||||||
const body = await res.json().catch(() => ({}))
|
const body = await res.json().catch(() => ({}))
|
||||||
throw new Error(`${label}: ${body.detail ?? res.statusText}`)
|
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' })
|
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 {
|
export interface SettingEntry {
|
||||||
key: string
|
key: string
|
||||||
is_set: boolean
|
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