PWA notification

This commit is contained in:
ChrispyBacon-dev 2026-04-13 11:19:45 +02:00
parent be72eb3922
commit 82677a1bd6
12 changed files with 345 additions and 146 deletions

View file

@ -88,8 +88,7 @@ def push_subscribe():
db.execute("""
INSERT INTO push_subscriptions (mailbox_address, endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
mailbox_address=excluded.mailbox_address,
ON CONFLICT(mailbox_address, endpoint) DO UPDATE SET
p256dh=excluded.p256dh,
auth=excluded.auth,
created_at=excluded.created_at

View file

@ -163,7 +163,29 @@ def _migrate(conn):
try:
conn.execute(sql)
except Exception:
pass
pass
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS push_subscriptions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_address TEXT NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE,
UNIQUE(mailbox_address, endpoint)
);
INSERT OR IGNORE INTO push_subscriptions_new
SELECT id, mailbox_address, endpoint, p256dh, auth, created_at
FROM push_subscriptions;
DROP TABLE push_subscriptions;
ALTER TABLE push_subscriptions_new RENAME TO push_subscriptions;
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_mailbox ON push_subscriptions(mailbox_address);
""")
except Exception:
pass
def init_db():

View file

@ -19,6 +19,17 @@ def _dispatch(mailbox_address: str, payload: dict):
if not private_key:
return
# pywebpush expects a base64url-encoded DER private key.
# DockFlare Master stores VAPID keys as PEM — convert if needed.
if private_key.strip().startswith('-----'):
from cryptography.hazmat.primitives.serialization import (
load_pem_private_key, Encoding, PrivateFormat, NoEncryption
)
import base64
key_obj = load_pem_private_key(private_key.encode(), password=None)
der = key_obj.private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())
private_key = base64.urlsafe_b64encode(der).rstrip(b'=').decode()
from pywebpush import webpush, WebPushException
from app.core.database import get_standalone_db

View file

@ -8,7 +8,7 @@ server {
listen 80;
server_name _;
client_max_body_size 25m;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; connect-src 'self';";
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' https://static.cloudflareinsights.com; worker-src 'self'; connect-src 'self' https://fcm.googleapis.com https://*.googleapis.com https://cloudflareinsights.com;";
location /api/ {
proxy_pass http://dockflare-mail-manager:8025/api/;

View file

@ -3,7 +3,8 @@ import {
SplitterGroup, SplitterPanel, SplitterResizeHandle,
TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, TooltipPortal,
} from 'radix-vue'
import { PenSquare, Sun, Moon, LogOut } from 'lucide-vue-next'
import { defineAsyncComponent } from 'vue'
import { PenSquare, Sun, Moon, LogOut, Settings, Columns2, Maximize2 } from 'lucide-vue-next'
import { cn } from '../../lib/utils'
import Separator from '../ui/Separator.vue'
import MailboxSelector from './MailboxSelector.vue'
@ -14,6 +15,8 @@ import ComposeDialog from './ComposeDialog.vue'
import { useMailStore } from '../../stores/mail'
import { useAuth } from '../../composables/useAuth'
const SettingsDialog = defineAsyncComponent(() => import('./SettingsDialog.vue'))
const store = useMailStore()
const { logout } = useAuth()
@ -78,8 +81,8 @@ const compose = () => {
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0"
@click="store.toggleViewMode()"
>
<Columns v-if="store.viewMode === 'full'" class="size-4" />
<Maximize v-else class="size-4" />
<Columns2 v-if="store.viewMode === 'full'" class="size-4" />
<Maximize2 v-else class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
@ -105,6 +108,22 @@ const compose = () => {
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<!-- Settings -->
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0"
@click="store.isSettingsOpen = true"
>
<Settings class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="bottom" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
Settings
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<!-- Logout -->
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
@ -146,6 +165,16 @@ const compose = () => {
<TooltipContent side="right" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">{{ store.isDark ? 'Light mode' : 'Dark mode' }}</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent transition-colors" @click="store.isSettingsOpen = true">
<Settings class="size-4" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">Settings</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<TooltipRoot :delay-duration="0">
<TooltipTrigger as-child>
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent transition-colors" @click="logout">
@ -214,5 +243,6 @@ const compose = () => {
</SplitterGroup>
<ComposeDialog />
<SettingsDialog />
</TooltipProvider>
</template>

View file

@ -0,0 +1,40 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { useMailStore } from '../../stores/mail'
import SettingsContent from '../settings/SettingsContent.vue'
const store = useMailStore()
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="store.isSettingsOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="store.isSettingsOpen = false"
>
<div class="relative w-full max-w-lg rounded-xl border bg-background shadow-xl mx-4 max-h-[85vh] overflow-y-auto">
<button
class="absolute right-4 top-4 inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
@click="store.isSettingsOpen = false"
>
<X class="size-4" />
</button>
<SettingsContent />
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -0,0 +1,134 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useInstallPrompt } from '@/composables/useInstallPrompt'
import { useNotificationsStore } from '@/stores/notifications'
import { usePushSubscription } from '@/composables/usePushSubscription'
import { useMailStore } from '@/stores/mail'
import { mailApi } from '@/api/mail'
const { canInstall, promptInstall } = useInstallPrompt()
const notificationsStore = useNotificationsStore()
const push = usePushSubscription()
const mailStore = useMailStore()
const notificationPreview = ref(true)
const previewLoading = ref(false)
watch(
() => mailStore.currentMailbox,
async (address) => {
if (!address) return
try {
const res = await mailApi.getMailboxPreferences(address)
notificationPreview.value = res.data.notification_preview
} catch { /* ignore */ }
},
{ immediate: true }
)
async function togglePreview() {
if (!mailStore.currentMailbox || previewLoading.value) return
previewLoading.value = true
const next = !notificationPreview.value
try {
await mailApi.updateMailboxPreferences(mailStore.currentMailbox, { notification_preview: next })
notificationPreview.value = next
} catch { /* ignore */ } finally {
previewLoading.value = false
}
}
</script>
<template>
<div class="p-6">
<h1 class="text-xl font-semibold mb-5">Settings</h1>
<div v-if="canInstall" class="mb-4 rounded-lg border p-4 space-y-3">
<div>
<h2 class="text-sm font-medium">Install App</h2>
<p class="text-sm text-muted-foreground mt-0.5">
Install DockFlare Mail as a desktop app for faster access and desktop notifications.
</p>
</div>
<button
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
@click="promptInstall"
>
Install
</button>
</div>
<div class="mb-4 rounded-lg border p-4 space-y-3">
<div>
<h2 class="text-sm font-medium">Notifications</h2>
<p class="text-sm text-muted-foreground mt-0.5">
Get notified when new mail arrives, even when the app is closed.
</p>
</div>
<template v-if="notificationsStore.isDenied">
<p class="text-sm text-muted-foreground">
Notifications are blocked. Enable them in your browser or OS settings.
</p>
</template>
<template v-else-if="!notificationsStore.isGranted">
<button
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
@click="notificationsStore.requestPermission"
>
Enable Notifications
</button>
</template>
<template v-else>
<p class="text-sm text-muted-foreground">Permission granted.</p>
<div v-if="push.isSupported" class="space-y-1.5">
<div class="flex items-center gap-3">
<span class="text-sm">Background push (all mailboxes)</span>
<button
v-if="!push.isSubscribed.value"
:disabled="push.isLoading.value"
class="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
@click="push.subscribe()"
>
{{ push.isLoading.value ? 'Enabling…' : 'Enable' }}
</button>
<button
v-else
:disabled="push.isLoading.value"
class="inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-accent transition-colors disabled:opacity-50"
@click="push.unsubscribe()"
>
{{ push.isLoading.value ? 'Disabling…' : 'Disable' }}
</button>
</div>
<p v-if="push.error.value" class="text-xs text-destructive">{{ push.error.value }}</p>
</div>
<p v-else class="text-sm text-muted-foreground">
Background push is not supported in this browser.
</p>
<div v-if="mailStore.currentMailbox" class="flex items-center gap-3 pt-1">
<span class="text-sm">Show subject &amp; sender in notifications</span>
<button
:disabled="previewLoading"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 disabled:opacity-50"
:class="notificationPreview ? 'bg-primary' : 'bg-muted'"
role="switch"
:aria-checked="notificationPreview"
@click="togglePreview"
>
<span
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200"
:class="notificationPreview ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</template>
</div>
<p class="text-sm text-muted-foreground">Additional settings are managed in DockFlare Master.</p>
</div>
</template>

View file

@ -1,6 +1,5 @@
import { onUnmounted, ref, watch } from 'vue'
import { mailApi } from '@/api/mail'
import { useNotificationsStore } from '@/stores/notifications'
import { useMailStore } from '@/stores/mail'
interface MailboxStatus {
@ -19,7 +18,6 @@ function updateBadge(count: number) {
}
export function useMailPolling() {
const notificationsStore = useNotificationsStore()
const mailStore = useMailStore()
const lastSeen = ref<Record<string, string | null>>({})
const initialized = ref(false)
@ -42,8 +40,6 @@ export function useMailPolling() {
return
}
if (!notificationsStore.isGranted) return
for (const s of statuses) {
const prev = lastSeen.value[s.address]
if (
@ -51,7 +47,21 @@ export function useMailPolling() {
(prev === undefined || prev === null || s.latest_received_at > prev)
) {
lastSeen.value[s.address] = s.latest_received_at
fireNotification(s.address, s.unread_count)
if (s.address === mailStore.currentMailbox && mailStore.currentFolder) {
try {
const mRes = await mailApi.getMessages(s.address, {
folder: mailStore.currentFolder,
order: mailStore.sortOrder,
})
const payload = mRes.data
mailStore.messages = Array.isArray(payload) ? payload : payload.items || []
} catch { /* network error — skip */ }
}
if (Notification.permission === 'granted') {
fireNotification(s.address, s.unread_count)
}
}
}
} catch {

View file

@ -1,5 +1,6 @@
import { ref } from 'vue'
import apiClient from '@/api/client'
import { useMailStore } from '@/stores/mail'
const isSupported = typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window
@ -14,6 +15,8 @@ function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
export function usePushSubscription() {
const isSubscribed = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const mailStore = useMailStore()
const checkSubscription = async () => {
if (!isSupported) return
@ -22,23 +25,42 @@ export function usePushSubscription() {
isSubscribed.value = !!sub
}
const subscribe = async (mailboxAddress: string) => {
const subscribe = async () => {
if (!isSupported) return
isLoading.value = true
error.value = null
try {
const { data } = await apiClient.get('/notifications/vapid-key')
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(data.public_key),
})
const subJson = sub.toJSON()
await apiClient.post('/notifications/subscribe', {
endpoint: subJson.endpoint,
keys: subJson.keys,
mailbox_address: mailboxAddress,
})
let addresses: string[] = mailStore.mailboxes.map((m: any) => m.address)
if (addresses.length === 0) {
const statusRes = await apiClient.get('/mailboxes/status')
addresses = (statusRes.data as any[]).map((s) => s.address)
}
if (addresses.length === 0) {
error.value = 'No mailboxes found'
return
}
for (const address of addresses) {
await apiClient.post('/notifications/subscribe', {
endpoint: subJson.endpoint,
keys: subJson.keys,
mailbox_address: address,
})
}
isSubscribed.value = true
} catch (err: any) {
console.error('Push subscribe failed:', err)
error.value = err?.message ?? 'Subscription failed'
} finally {
isLoading.value = false
}
@ -47,6 +69,7 @@ export function usePushSubscription() {
const unsubscribe = async () => {
if (!isSupported) return
isLoading.value = true
error.value = null
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
@ -57,6 +80,9 @@ export function usePushSubscription() {
await sub.unsubscribe()
}
isSubscribed.value = false
} catch (err: any) {
console.error('Push unsubscribe failed:', err)
error.value = err?.message ?? 'Unsubscribe failed'
} finally {
isLoading.value = false
}
@ -64,5 +90,5 @@ export function usePushSubscription() {
checkSubscription()
return { isSubscribed, isLoading, isSupported, subscribe, unsubscribe }
return { isSubscribed, isLoading, isSupported, error, subscribe, unsubscribe }
}

View file

@ -10,6 +10,7 @@ export const useMailStore = defineStore('mail', () => {
const currentMessage = ref<any>(null)
const isComposeOpen = ref(false)
const isComposeFullView = ref(false)
const isSettingsOpen = ref(false)
const composeDefaults = ref<{ to?: string; subject?: string; body?: string; quotedHtml?: string; draftId?: number } | null>(null)
const composeBody = ref('')
const activeTab = ref<'all' | 'unread' | 'starred'>('all')
@ -50,7 +51,7 @@ export const useMailStore = defineStore('mail', () => {
mailboxes, currentMailbox,
folders, currentFolder, currentFolderObj,
messages, currentMessage,
isComposeOpen, isComposeFullView, composeDefaults, composeBody,
isComposeOpen, isComposeFullView, isSettingsOpen, composeDefaults, composeBody,
activeTab, isCollapsed,
sortOrder, isDark, toggleTheme,
viewMode, toggleViewMode,

View file

@ -1,15 +1,21 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMail } from '../composables/useMail'
import { useMailPolling } from '../composables/useMailPolling'
import { useNotificationsStore } from '../stores/notifications'
import { mailApi } from '../api/mail'
import MailLayout from '../components/mail/MailLayout.vue'
const route = useRoute()
const router = useRouter()
const { store, loadMailboxes } = useMail()
const mailStore = store
const notificationsStore = useNotificationsStore()
useMailPolling()
const showNotifPrompt = ref(false)
const loadMessages = async (addr: string, folder: string) => {
if (!addr || !folder) return
try {
@ -22,6 +28,20 @@ const loadMessages = async (addr: string, folder: string) => {
}
}
async function enableNotifications() {
await notificationsStore.requestPermission()
showNotifPrompt.value = false
localStorage.setItem('notif_prompted', '1')
if (notificationsStore.isGranted) {
mailStore.isSettingsOpen = true
}
}
function dismissPrompt() {
showNotifPrompt.value = false
localStorage.setItem('notif_prompted', '1')
}
onMounted(async () => {
await loadMailboxes()
@ -31,6 +51,10 @@ onMounted(async () => {
if (found) store.currentMailbox = mailboxParam
}
if (Notification.permission === 'default' && !localStorage.getItem('notif_prompted')) {
showNotifPrompt.value = true
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (ev: MessageEvent) => {
if (ev.data?.type === 'NOTIFICATION_CLICK' && ev.data.mailbox) {
@ -98,5 +122,28 @@ watch(() => store.currentMessage, async (msg) => {
</script>
<template>
<MailLayout />
<div class="relative h-full">
<MailLayout />
<Transition name="slide-up">
<div
v-if="showNotifPrompt"
class="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 rounded-xl border bg-background shadow-lg px-5 py-3.5 text-sm"
>
<span class="text-muted-foreground">Enable notifications for new mail?</span>
<button
class="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
@click="enableNotifications"
>
Enable
</button>
<button
class="text-muted-foreground hover:text-foreground transition-colors"
@click="dismissPrompt"
>
Dismiss
</button>
</div>
</Transition>
</div>
</template>

View file

@ -1,130 +1,9 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useInstallPrompt } from '@/composables/useInstallPrompt'
import { useNotificationsStore } from '@/stores/notifications'
import { usePushSubscription } from '@/composables/usePushSubscription'
import { useMailStore } from '@/stores/mail'
import { mailApi } from '@/api/mail'
const { canInstall, promptInstall } = useInstallPrompt()
const notificationsStore = useNotificationsStore()
const push = usePushSubscription()
const mailStore = useMailStore()
const notificationPreview = ref(true)
const previewLoading = ref(false)
watch(
() => mailStore.currentMailbox,
async (address) => {
if (!address) return
try {
const res = await mailApi.getMailboxPreferences(address)
notificationPreview.value = res.data.notification_preview
} catch { /* ignore */ }
},
{ immediate: true }
)
async function togglePreview() {
if (!mailStore.currentMailbox || previewLoading.value) return
previewLoading.value = true
const next = !notificationPreview.value
try {
await mailApi.updateMailboxPreferences(mailStore.currentMailbox, { notification_preview: next })
notificationPreview.value = next
} catch { /* ignore */ } finally {
previewLoading.value = false
}
}
import SettingsContent from '@/components/settings/SettingsContent.vue'
</script>
<template>
<div class="p-8 max-w-2xl">
<h1 class="text-2xl font-semibold mb-6">Settings</h1>
<div v-if="canInstall" class="mb-4 rounded-lg border p-4 space-y-3">
<div>
<h2 class="text-sm font-medium">Install App</h2>
<p class="text-sm text-muted-foreground mt-0.5">
Install DockFlare Mail as a desktop app for faster access and desktop notifications.
</p>
</div>
<button
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
@click="promptInstall"
>
Install
</button>
</div>
<div class="mb-4 rounded-lg border p-4 space-y-3">
<div>
<h2 class="text-sm font-medium">Notifications</h2>
<p class="text-sm text-muted-foreground mt-0.5">
Get notified when new mail arrives, even when the app is closed.
</p>
</div>
<template v-if="notificationsStore.isDenied">
<p class="text-sm text-muted-foreground">
Notifications are blocked. Enable them in your browser or OS settings.
</p>
</template>
<template v-else-if="!notificationsStore.isGranted">
<button
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
@click="notificationsStore.requestPermission"
>
Enable Notifications
</button>
</template>
<template v-else>
<p class="text-sm text-muted-foreground">Permission granted.</p>
<div v-if="push.isSupported && mailStore.currentMailbox" class="flex items-center gap-3">
<span class="text-sm">Background push for {{ mailStore.currentMailbox }}</span>
<button
v-if="!push.isSubscribed.value"
:disabled="push.isLoading.value"
class="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
@click="push.subscribe(mailStore.currentMailbox)"
>
Enable
</button>
<button
v-else
:disabled="push.isLoading.value"
class="inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-muted transition-colors disabled:opacity-50"
@click="push.unsubscribe()"
>
Disable
</button>
</div>
<p v-else-if="!push.isSupported" class="text-sm text-muted-foreground">
Background push is not supported in this browser.
</p>
<div v-if="mailStore.currentMailbox" class="flex items-center gap-3 pt-1">
<span class="text-sm">Show subject &amp; sender in notifications</span>
<button
:disabled="previewLoading"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 disabled:opacity-50"
:class="notificationPreview ? 'bg-primary' : 'bg-muted'"
role="switch"
:aria-checked="notificationPreview"
@click="togglePreview"
>
<span
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200"
:class="notificationPreview ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</template>
</div>
<p class="text-sm text-muted-foreground">Additional settings are managed in DockFlare Master.</p>
<SettingsContent />
</div>
</template>