Settings: reorganise into tabs (General, Account, Notifications, Integrations, Data, Admin)
Replaces the single long scrolling page with a tab bar. Active tab persists to localStorage across navigation. Admin tab only visible to admins. Push notifications description clarified to mention HTTPS requirement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+249
-171
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
@@ -28,6 +28,10 @@ const exporting = ref(false);
|
||||
const restoring = ref(false);
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Tab state — persisted across navigation
|
||||
const activeTab = ref(localStorage.getItem("settings_tab") ?? "general");
|
||||
watch(activeTab, (v) => localStorage.setItem("settings_tab", v));
|
||||
|
||||
// Chat retention
|
||||
const chatRetentionDays = ref(90);
|
||||
const savingRetention = ref(false);
|
||||
@@ -439,9 +443,24 @@ function hostname(url: string): string {
|
||||
<main class="settings-page">
|
||||
<h1>Settings</h1>
|
||||
|
||||
<div class="settings-grid">
|
||||
<!-- Tab bar -->
|
||||
<div class="settings-tabs" role="tablist">
|
||||
<button
|
||||
v-for="tab in (authStore.isAdmin
|
||||
? ['general', 'account', 'notifications', 'integrations', 'data', 'admin']
|
||||
: ['general', 'account', 'notifications', 'integrations', 'data'])"
|
||||
:key="tab"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tab"
|
||||
:class="['tab-btn', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Assistant ── full width ── -->
|
||||
<!-- ── General ── -->
|
||||
<div v-show="activeTab === 'general'" class="settings-grid">
|
||||
<section class="settings-section full-width">
|
||||
<h2>Assistant</h2>
|
||||
<div class="assistant-grid">
|
||||
@@ -472,8 +491,11 @@ function hostname(url: string): string {
|
||||
<span v-if="saved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ── Account ── -->
|
||||
<div v-show="activeTab === 'account'" class="settings-grid">
|
||||
|
||||
<!-- ── Email Address ── half width ── -->
|
||||
<section class="settings-section">
|
||||
<h2>Email Address</h2>
|
||||
<p class="section-desc">Used for password resets and notifications.</p>
|
||||
@@ -509,21 +531,6 @@ function hostname(url: string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Invalidate Sessions ── half width ── -->
|
||||
<section class="settings-section">
|
||||
<h2>Active Sessions</h2>
|
||||
<p class="section-desc">
|
||||
Sign out all other devices and sessions. Use this after an SSO password change
|
||||
to ensure stale sessions are revoked.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn-danger-outline" @click="invalidateSessions" :disabled="invalidatingSessions">
|
||||
{{ invalidatingSessions ? "Invalidating..." : "Invalidate All Other Sessions" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Change Password ── half width ── -->
|
||||
<section class="settings-section">
|
||||
<h2>Change Password</h2>
|
||||
<div class="field">
|
||||
@@ -572,9 +579,26 @@ function hostname(url: string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Notifications ── half width ── -->
|
||||
<section class="settings-section">
|
||||
<h2>Notifications</h2>
|
||||
<h2>Active Sessions</h2>
|
||||
<p class="section-desc">
|
||||
Sign out all other devices and sessions. Use this after an SSO password change
|
||||
to ensure stale sessions are revoked.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn-danger-outline" @click="invalidateSessions" :disabled="invalidatingSessions">
|
||||
{{ invalidatingSessions ? "Invalidating..." : "Invalidate All Other Sessions" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Notifications ── -->
|
||||
<div v-show="activeTab === 'notifications'" class="settings-grid">
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Email Notifications</h2>
|
||||
<p class="section-desc">
|
||||
Email notifications when SMTP is configured by an admin.
|
||||
</p>
|
||||
@@ -600,153 +624,13 @@ function hostname(url: string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Chat Retention ── half width ── -->
|
||||
<section class="settings-section">
|
||||
<h2>Chat History</h2>
|
||||
<p class="section-desc">
|
||||
Conversations older than this many days are automatically deleted when you load the chat.
|
||||
Set to <strong>0</strong> to keep conversations forever.
|
||||
</p>
|
||||
<div class="field retention-field">
|
||||
<label class="field-label">Retention period (days)</label>
|
||||
<div class="retention-row">
|
||||
<input
|
||||
v-model.number="chatRetentionDays"
|
||||
type="number"
|
||||
min="0"
|
||||
max="3650"
|
||||
class="input retention-input"
|
||||
/>
|
||||
<button class="btn-primary" :disabled="savingRetention" @click="saveRetention">
|
||||
{{ savingRetention ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Data ── half width ── -->
|
||||
<section class="settings-section">
|
||||
<h2>Data</h2>
|
||||
<p class="section-desc">Export your data or restore from a backup.</p>
|
||||
<div class="data-actions">
|
||||
<button class="btn-secondary" @click="exportNotes('markdown')" :disabled="exportingNotes">
|
||||
{{ exportingNotes ? "Exporting..." : "Export as Markdown" }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="exportNotes('json')" :disabled="exportingNotes">
|
||||
{{ exportingNotes ? "Exporting..." : "Export as JSON" }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
|
||||
{{ exporting ? "Exporting..." : "Export My Data" }}
|
||||
</button>
|
||||
<template v-if="authStore.isAdmin">
|
||||
<button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
|
||||
{{ exporting ? "Exporting..." : "Full Backup" }}
|
||||
</button>
|
||||
<button class="btn-secondary btn-warn" @click="triggerRestoreUpload" :disabled="restoring">
|
||||
{{ restoring ? "Restoring..." : "Restore from Backup" }}
|
||||
</button>
|
||||
<input
|
||||
ref="restoreFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden-file-input"
|
||||
@change="handleRestoreFile"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── CalDAV ── full width ── -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Calendar (CalDAV)</h2>
|
||||
<p class="section-desc">
|
||||
Connect to a CalDAV server (Nextcloud, iCloud, Radicale, Baikal) to create and view calendar events from chat.
|
||||
</p>
|
||||
<div class="caldav-grid">
|
||||
<div class="field">
|
||||
<label for="caldav-url">CalDAV URL</label>
|
||||
<input id="caldav-url" v-model="caldav.caldav_url" type="url" placeholder="https://cloud.example.com/remote.php/dav" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="caldav-username">Username</label>
|
||||
<input id="caldav-username" v-model="caldav.caldav_username" type="text" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="caldav-password">Password</label>
|
||||
<input id="caldav-password" v-model="caldav.caldav_password" type="password" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="caldav-calendar-name">Calendar Name</label>
|
||||
<input id="caldav-calendar-name" v-model="caldav.caldav_calendar_name" type="text" placeholder="Leave empty to use first available" class="input" />
|
||||
<p class="field-hint">Optional. Exact calendar name to use.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-bottom: 1rem;">
|
||||
<button class="btn-save" @click="saveCaldav" :disabled="savingCaldav">
|
||||
{{ savingCaldav ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<span v-if="caldavSaved" class="saved-msg">Saved!</span>
|
||||
<button class="btn-secondary" @click="testCaldav" :disabled="testingCaldav">
|
||||
{{ testingCaldav ? "Testing..." : "Test Connection" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="caldavTestResult" class="test-result" :class="{ success: caldavTestResult.success, error: !caldavTestResult.success }">
|
||||
<template v-if="caldavTestResult.success">
|
||||
{{ caldavTestResult.message }}
|
||||
<div v-if="caldavTestResult.calendars?.length" class="test-calendars">
|
||||
Calendars: {{ caldavTestResult.calendars.join(", ") }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ caldavTestResult.error }}</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Web Search ── full width ── -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Web Search (SearXNG)</h2>
|
||||
<template v-if="searxngConfigured">
|
||||
<p class="section-desc">
|
||||
Connected to <code class="url-chip">{{ searxngUrl }}</code>.
|
||||
Test a query below to verify results and rate limiting.
|
||||
</p>
|
||||
<div class="search-row">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Enter a search query..."
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<button class="btn-save" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
|
||||
{{ searchLoading ? "Searching..." : "Search" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="searchError" class="search-error">{{ searchError }}</p>
|
||||
<ul v-if="searchResults.length" class="search-results">
|
||||
<li v-for="(r, i) in searchResults" :key="i" class="search-result">
|
||||
<div class="result-header">
|
||||
<a :href="r.url" target="_blank" rel="noopener noreferrer" class="result-title">{{ r.title || r.url }}</a>
|
||||
<span class="result-host">{{ hostname(r.url) }}</span>
|
||||
</div>
|
||||
<p v-if="r.snippet" class="result-snippet">{{ r.snippet }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="section-desc not-configured">
|
||||
Not configured. Set <code>SEARXNG_URL</code> in docker-compose to enable web research from chat.
|
||||
</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── Push Notifications ── half width ── -->
|
||||
<section class="settings-section">
|
||||
<h2>Push Notifications</h2>
|
||||
<p class="section-desc">
|
||||
Receive browser push notifications when a response is ready.
|
||||
Receive browser push notifications when a response is ready. Requires HTTPS.
|
||||
</p>
|
||||
<template v-if="!pushStore.isSupported">
|
||||
<p class="push-unsupported">Push notifications are not supported in this browser.</p>
|
||||
<p class="push-unsupported">Push notifications are not supported in this browser or connection (requires HTTPS).</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="push-status-row">
|
||||
@@ -792,8 +676,164 @@ function hostname(url: string): string {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── Application URL (admin) ── full width ── -->
|
||||
<section v-if="authStore.isAdmin" class="settings-section full-width">
|
||||
<section class="settings-section">
|
||||
<h2>Chat History</h2>
|
||||
<p class="section-desc">
|
||||
Conversations older than this many days are automatically deleted when you load the chat.
|
||||
Set to <strong>0</strong> to keep conversations forever.
|
||||
</p>
|
||||
<div class="field retention-field">
|
||||
<label class="field-label">Retention period (days)</label>
|
||||
<div class="retention-row">
|
||||
<input
|
||||
v-model.number="chatRetentionDays"
|
||||
type="number"
|
||||
min="0"
|
||||
max="3650"
|
||||
class="input retention-input"
|
||||
/>
|
||||
<button class="btn-primary" :disabled="savingRetention" @click="saveRetention">
|
||||
{{ savingRetention ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Integrations ── -->
|
||||
<div v-show="activeTab === 'integrations'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Calendar (CalDAV)</h2>
|
||||
<p class="section-desc">
|
||||
Connect to a CalDAV server (Nextcloud, iCloud, Radicale, Baikal) to create and view calendar events from chat.
|
||||
</p>
|
||||
<div class="caldav-grid">
|
||||
<div class="field">
|
||||
<label for="caldav-url">CalDAV URL</label>
|
||||
<input id="caldav-url" v-model="caldav.caldav_url" type="url" placeholder="https://cloud.example.com/remote.php/dav" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="caldav-username">Username</label>
|
||||
<input id="caldav-username" v-model="caldav.caldav_username" type="text" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="caldav-password">Password</label>
|
||||
<input id="caldav-password" v-model="caldav.caldav_password" type="password" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="caldav-calendar-name">Calendar Name</label>
|
||||
<input id="caldav-calendar-name" v-model="caldav.caldav_calendar_name" type="text" placeholder="Leave empty to use first available" class="input" />
|
||||
<p class="field-hint">Optional. Exact calendar name to use.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-bottom: 1rem;">
|
||||
<button class="btn-save" @click="saveCaldav" :disabled="savingCaldav">
|
||||
{{ savingCaldav ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<span v-if="caldavSaved" class="saved-msg">Saved!</span>
|
||||
<button class="btn-secondary" @click="testCaldav" :disabled="testingCaldav">
|
||||
{{ testingCaldav ? "Testing..." : "Test Connection" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="caldavTestResult" class="test-result" :class="{ success: caldavTestResult.success, error: !caldavTestResult.success }">
|
||||
<template v-if="caldavTestResult.success">
|
||||
{{ caldavTestResult.message }}
|
||||
<div v-if="caldavTestResult.calendars?.length" class="test-calendars">
|
||||
Calendars: {{ caldavTestResult.calendars.join(", ") }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ caldavTestResult.error }}</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Web Search (SearXNG)</h2>
|
||||
<template v-if="searxngConfigured">
|
||||
<p class="section-desc">
|
||||
Connected to <code class="url-chip">{{ searxngUrl }}</code>.
|
||||
Test a query below to verify results and rate limiting.
|
||||
</p>
|
||||
<div class="search-row">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Enter a search query..."
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<button class="btn-save" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
|
||||
{{ searchLoading ? "Searching..." : "Search" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="searchError" class="search-error">{{ searchError }}</p>
|
||||
<ul v-if="searchResults.length" class="search-results">
|
||||
<li v-for="(r, i) in searchResults" :key="i" class="search-result">
|
||||
<div class="result-header">
|
||||
<a :href="r.url" target="_blank" rel="noopener noreferrer" class="result-title">{{ r.title || r.url }}</a>
|
||||
<span class="result-host">{{ hostname(r.url) }}</span>
|
||||
</div>
|
||||
<p v-if="r.snippet" class="result-snippet">{{ r.snippet }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="section-desc not-configured">
|
||||
Not configured. Set <code>SEARXNG_URL</code> in docker-compose to enable web research from chat.
|
||||
</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Data ── -->
|
||||
<div v-show="activeTab === 'data'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Export</h2>
|
||||
<p class="section-desc">Download your notes and tasks in portable formats.</p>
|
||||
<div class="data-actions">
|
||||
<button class="btn-secondary" @click="exportNotes('markdown')" :disabled="exportingNotes">
|
||||
{{ exportingNotes ? "Exporting..." : "Export as Markdown" }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="exportNotes('json')" :disabled="exportingNotes">
|
||||
{{ exportingNotes ? "Exporting..." : "Export as JSON" }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
|
||||
{{ exporting ? "Exporting..." : "Export My Data" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-if="authStore.isAdmin">
|
||||
<section class="settings-section full-width">
|
||||
<h2>Backup & Restore</h2>
|
||||
<p class="section-desc">Full application backup includes all users and their data.</p>
|
||||
<div class="data-actions">
|
||||
<button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
|
||||
{{ exporting ? "Exporting..." : "Full Backup" }}
|
||||
</button>
|
||||
<button class="btn-secondary btn-warn" @click="triggerRestoreUpload" :disabled="restoring">
|
||||
{{ restoring ? "Restoring..." : "Restore from Backup" }}
|
||||
</button>
|
||||
<input
|
||||
ref="restoreFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden-file-input"
|
||||
@change="handleRestoreFile"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Admin ── -->
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'admin'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Application URL</h2>
|
||||
<p class="section-desc">
|
||||
Public URL used in email links (invitations, password resets). Example: https://notes.example.com
|
||||
@@ -816,8 +856,7 @@ function hostname(url: string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── SMTP (admin) ── full width ── -->
|
||||
<section v-if="authStore.isAdmin" class="settings-section full-width">
|
||||
<section class="settings-section full-width">
|
||||
<h2>Email / SMTP</h2>
|
||||
<p class="section-desc">Configure SMTP to enable email notifications for all users.</p>
|
||||
<div class="smtp-grid">
|
||||
@@ -876,6 +915,7 @@ function hostname(url: string): string {
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -886,7 +926,39 @@ function hostname(url: string): string {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
.settings-page h1 {
|
||||
margin: 0 0 1.5rem;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Two-column grid — small sections pair up, full-width sections span both */
|
||||
@@ -916,10 +988,10 @@ function hostname(url: string): string {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Assistant — 3-col internal grid */
|
||||
/* Assistant — 2-col internal grid */
|
||||
.assistant-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 1rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
@@ -1035,7 +1107,6 @@ function hostname(url: string): string {
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.retention-input {
|
||||
width: 6rem;
|
||||
}
|
||||
@@ -1251,5 +1322,12 @@ function hostname(url: string): string {
|
||||
padding: 0 1rem;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
.settings-tabs {
|
||||
gap: 0;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user