feat: Mini-Cloud Plattform - komplette Implementierung Phase 0-8
Selbstgehostete Web-Cloud mit Dateiverwaltung, Kalender, Kontakte, Email-Webclient, Office-Viewer und Passwort-Manager. Backend (Flask/Python): - JWT-Auth mit Access/Refresh Tokens, Benutzerverwaltung - Dateien: Upload/Download, Ordner, Berechtigungen, Share-Links - Kalender: CRUD, Teilen, iCal-Export, CalDAV well-known URLs - Kontakte: Adressbuecher, vCard-Export, Teilen - Email: IMAP/SMTP-Proxy, Multi-Account - Office-Viewer: DOCX/XLSX/PPTX/PDF Vorschau - Passwort-Manager: AES-256-GCM clientseitig, KeePass-Import - Sync-API fuer Desktop/Mobile-Clients - SQLite mit WAL-Modus Frontend (Vue 3 + PrimeVue): - Datei-Explorer mit Breadcrumbs und Share-Dialogen - Monatskalender mit Event-Verwaltung - Kontaktliste mit Adressbuch-Sidebar - Email-Client mit 3-Spalten-Layout - Passwort-Manager mit TOTP und Passwort-Generator - Admin-Panel, Settings, oeffentliche Share-Seite Docker: Multi-Stage Build, Bind Mounts (keine Volumes) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1650
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primevue/themes": "^4.5.4",
|
||||
"axios": "^1.15.0",
|
||||
"pinia": "^3.0.4",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.5.5",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import router from '../router'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor: attach access token
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const auth = useAuthStore()
|
||||
if (auth.accessToken) {
|
||||
config.headers.Authorization = `Bearer ${auth.accessToken}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor: handle 401 with token refresh
|
||||
let isRefreshing = false
|
||||
let failedQueue = []
|
||||
|
||||
const processQueue = (error, token = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error)
|
||||
} else {
|
||||
prom.resolve(token)
|
||||
}
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/auth/login') {
|
||||
const auth = useAuthStore()
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject })
|
||||
}).then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
return apiClient(originalRequest)
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const auth = useAuthStore()
|
||||
const newToken = await auth.refreshToken()
|
||||
processQueue(null, newToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null)
|
||||
const auth = useAuthStore()
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,28 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import 'primeicons/primeicons.css'
|
||||
import './style.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: '.dark-mode',
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ToastService)
|
||||
app.use(ConfirmationService)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('../views/RegisterView.vue'),
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/AppLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/files',
|
||||
},
|
||||
{
|
||||
path: 'files/:folderId?',
|
||||
name: 'Files',
|
||||
component: () => import('../views/FilesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
name: 'Calendar',
|
||||
component: () => import('../views/CalendarView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'contacts',
|
||||
name: 'Contacts',
|
||||
component: () => import('../views/ContactsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'Email',
|
||||
component: () => import('../views/EmailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'passwords',
|
||||
name: 'Passwords',
|
||||
component: () => import('../views/PasswordsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
name: 'Admin',
|
||||
component: () => import('../views/AdminView.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/share/:token',
|
||||
name: 'Share',
|
||||
component: () => import('../views/ShareView.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
// Try to refresh token
|
||||
try {
|
||||
await auth.refreshToken()
|
||||
await auth.fetchMe()
|
||||
} catch {
|
||||
return next('/login')
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.guest && auth.isAuthenticated) {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin && !auth.isAdmin) {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,72 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
const accessToken = ref(null)
|
||||
const masterKeySalt = ref(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
const hasEmailAccounts = computed(() => (user.value?.email_account_count || 0) > 0)
|
||||
|
||||
async function login(username, password) {
|
||||
const response = await apiClient.post('/auth/login', { username, password })
|
||||
user.value = response.data.user
|
||||
accessToken.value = response.data.access_token
|
||||
masterKeySalt.value = response.data.master_key_salt
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function register(username, password, email) {
|
||||
const payload = { username, password }
|
||||
if (email) payload.email = email
|
||||
const response = await apiClient.post('/auth/register', payload)
|
||||
user.value = response.data.user
|
||||
accessToken.value = response.data.access_token
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function refreshToken() {
|
||||
const response = await apiClient.post('/auth/refresh')
|
||||
accessToken.value = response.data.access_token
|
||||
return response.data.access_token
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
const response = await apiClient.get('/auth/me')
|
||||
user.value = response.data
|
||||
masterKeySalt.value = response.data.master_key_salt
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function changePassword(currentPassword, newPassword) {
|
||||
await apiClient.post('/auth/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
}
|
||||
|
||||
function logout() {
|
||||
apiClient.post('/auth/logout').catch(() => {})
|
||||
user.value = null
|
||||
accessToken.value = null
|
||||
masterKeySalt.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
masterKeySalt,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
hasEmailAccounts,
|
||||
login,
|
||||
register,
|
||||
refreshToken,
|
||||
fetchMe,
|
||||
changePassword,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
export const useFilesStore = defineStore('files', () => {
|
||||
const files = ref([])
|
||||
const breadcrumb = ref([])
|
||||
const currentParentId = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadFiles(parentId = null) {
|
||||
loading.value = true
|
||||
try {
|
||||
currentParentId.value = parentId
|
||||
const params = parentId ? { parent_id: parentId } : {}
|
||||
const response = await apiClient.get('/files', { params })
|
||||
files.value = response.data.files
|
||||
breadcrumb.value = response.data.breadcrumb
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder(name, parentId = null) {
|
||||
const response = await apiClient.post('/files/folder', {
|
||||
name,
|
||||
parent_id: parentId,
|
||||
})
|
||||
await loadFiles(parentId)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function uploadFile(file, parentId = null) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (parentId) formData.append('parent_id', parentId)
|
||||
|
||||
const response = await apiClient.post('/files/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
await loadFiles(parentId)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteFile(fileId) {
|
||||
await apiClient.delete(`/files/${fileId}`)
|
||||
await loadFiles(currentParentId.value)
|
||||
}
|
||||
|
||||
async function renameFile(fileId, newName) {
|
||||
await apiClient.put(`/files/${fileId}`, { name: newName })
|
||||
await loadFiles(currentParentId.value)
|
||||
}
|
||||
|
||||
async function moveFile(fileId, newParentId) {
|
||||
await apiClient.put(`/files/${fileId}`, { parent_id: newParentId })
|
||||
await loadFiles(currentParentId.value)
|
||||
}
|
||||
|
||||
async function createShareLink(fileId, options = {}) {
|
||||
const response = await apiClient.post(`/files/${fileId}/share`, options)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function getShareLinks(fileId) {
|
||||
const response = await apiClient.get(`/files/${fileId}/shares`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteShareLink(token) {
|
||||
await apiClient.delete(`/share/${token}`)
|
||||
}
|
||||
|
||||
function downloadUrl(fileId) {
|
||||
return `/api/files/${fileId}/download`
|
||||
}
|
||||
|
||||
return {
|
||||
files, breadcrumb, currentParentId, loading,
|
||||
loadFiles, createFolder, uploadFile, deleteFile,
|
||||
renameFile, moveFile, createShareLink, getShareLinks,
|
||||
deleteShareLink, downloadUrl,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Administration</h2>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h3>Benutzerverwaltung</h3>
|
||||
<DataTable :value="users" :loading="loading" striped-rows>
|
||||
<Column field="id" header="ID" style="width: 60px" />
|
||||
<Column field="username" header="Benutzername" />
|
||||
<Column field="email" header="E-Mail" />
|
||||
<Column field="role" header="Rolle">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.role" :severity="data.role === 'admin' ? 'danger' : 'info'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="is_active" header="Aktiv">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.is_active ? 'Ja' : 'Nein'" :severity="data.is_active ? 'success' : 'warn'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="storage_quota_mb" header="Quota (MB)" />
|
||||
<Column header="Aktionen" style="width: 100px">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="editUser(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import apiClient from '../api/client'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Tag from 'primevue/tag'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/users')
|
||||
users.value = response.data
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Benutzer:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editUser(user) {
|
||||
// TODO: Edit dialog in Phase 8
|
||||
console.log('Edit user:', user)
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header h2 { margin: 0 0 1.5rem; }
|
||||
.admin-section h3 { margin: 0 0 1rem; font-size: 1.125rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<i class="pi pi-cloud"></i>
|
||||
<span class="sidebar-title">Mini-Cloud</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/files" class="nav-item" active-class="active">
|
||||
<i class="pi pi-folder"></i>
|
||||
<span>Dateien</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/calendar" class="nav-item" active-class="active">
|
||||
<i class="pi pi-calendar"></i>
|
||||
<span>Kalender</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/contacts" class="nav-item" active-class="active">
|
||||
<i class="pi pi-users"></i>
|
||||
<span>Kontakte</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="auth.hasEmailAccounts"
|
||||
to="/email"
|
||||
class="nav-item"
|
||||
active-class="active"
|
||||
>
|
||||
<i class="pi pi-envelope"></i>
|
||||
<span>E-Mail</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/passwords" class="nav-item" active-class="active">
|
||||
<i class="pi pi-key"></i>
|
||||
<span>Passwoerter</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<router-link
|
||||
v-if="auth.isAdmin"
|
||||
to="/admin"
|
||||
class="nav-item"
|
||||
active-class="active"
|
||||
>
|
||||
<i class="pi pi-cog"></i>
|
||||
<span>Admin</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings" class="nav-item" active-class="active">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>Einstellungen</span>
|
||||
</router-link>
|
||||
|
||||
<a class="nav-item" @click="handleLogout">
|
||||
<i class="pi pi-sign-out"></i>
|
||||
<span>Abmelden</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--p-surface-0);
|
||||
border-right: 1px solid var(--p-surface-200);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--p-surface-200);
|
||||
}
|
||||
|
||||
.sidebar-header i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--p-surface-200);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
color: var(--p-text-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--p-surface-100);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--p-primary-50);
|
||||
color: var(--p-primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
font-size: 1.125rem;
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: var(--p-surface-50);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Kalender</h2>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" />
|
||||
<Button icon="pi pi-plus" label="Neues Event" size="small" @click="openNewEvent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-layout">
|
||||
<aside class="calendar-sidebar">
|
||||
<div v-for="cal in calendars" :key="cal.id" class="calendar-item">
|
||||
<div class="calendar-color" :style="{ background: cal.color }"></div>
|
||||
<span>{{ cal.name }}</span>
|
||||
<span v-if="cal.owner_name" class="shared-label">({{ cal.owner_name }})</span>
|
||||
<Button icon="pi pi-ellipsis-v" text size="small" @click="openCalendarMenu(cal, $event)" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="calendar-main">
|
||||
<div class="cal-nav">
|
||||
<Button icon="pi pi-chevron-left" text @click="changeMonth(-1)" />
|
||||
<h3>{{ currentMonthLabel }}</h3>
|
||||
<Button icon="pi pi-chevron-right" text @click="changeMonth(1)" />
|
||||
<Button label="Heute" text size="small" @click="goToday" />
|
||||
</div>
|
||||
|
||||
<div class="cal-grid">
|
||||
<div class="cal-header" v-for="day in weekDays" :key="day">{{ day }}</div>
|
||||
<div
|
||||
v-for="(cell, i) in calendarCells"
|
||||
:key="i"
|
||||
class="cal-cell"
|
||||
:class="{ 'other-month': !cell.currentMonth, 'today': cell.isToday }"
|
||||
@click="openNewEventOnDate(cell.date)"
|
||||
>
|
||||
<span class="cell-day">{{ cell.day }}</span>
|
||||
<div v-for="evt in cell.events" :key="evt.id" class="cell-event"
|
||||
:style="{ background: evt.color }" @click.stop="openEditEvent(evt)">
|
||||
{{ evt.summary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Calendar Dialog -->
|
||||
<Dialog v-model:visible="showNewCalendar" header="Neuer Kalender" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="newCalName" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Farbe</label>
|
||||
<InputText v-model="newCalColor" type="color" style="width: 60px; height: 36px" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewCalendar = false" />
|
||||
<Button label="Erstellen" @click="createCalendar" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Event Dialog -->
|
||||
<Dialog v-model:visible="showEventDialog" :header="editingEvent ? 'Event bearbeiten' : 'Neues Event'" modal :style="{ width: '500px' }">
|
||||
<div class="field">
|
||||
<label>Titel</label>
|
||||
<InputText v-model="eventForm.summary" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Kalender</label>
|
||||
<Select v-model="eventForm.calendar_id" :options="ownCalendars" optionLabel="name" optionValue="id" fluid />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Start</label>
|
||||
<InputText v-model="eventForm.dtstart" type="datetime-local" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ende</label>
|
||||
<InputText v-model="eventForm.dtend" type="datetime-local" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="deleteEvent" />
|
||||
<Button label="Abbrechen" text @click="showEventDialog = false" />
|
||||
<Button :label="editingEvent ? 'Speichern' : 'Erstellen'" @click="saveEvent" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Calendar Context Menu -->
|
||||
<Dialog v-model:visible="showCalMenu" header="Kalender-Optionen" modal :style="{ width: '400px' }">
|
||||
<div v-if="selectedCal" class="cal-menu-content">
|
||||
<p><strong>{{ selectedCal.name }}</strong></p>
|
||||
|
||||
<div class="field">
|
||||
<label>Mit Benutzer teilen</label>
|
||||
<div class="share-row">
|
||||
<InputText v-model="shareUsername" placeholder="Benutzername" fluid />
|
||||
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
|
||||
<Button label="Teilen" size="small" @click="shareCalendar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedCal.permission === 'owner'" class="field">
|
||||
<Button label="iCal-Link generieren" icon="pi pi-link" outlined size="small" @click="generateIcalLink" />
|
||||
<div v-if="icalUrl" class="ical-url">
|
||||
<code>{{ fullIcalUrl }}</code>
|
||||
<Button icon="pi pi-copy" text size="small" @click="copyIcal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button v-if="selectedCal.permission === 'owner'" label="Kalender loeschen"
|
||||
severity="danger" text size="small" @click="deleteCalendar" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
const toast = useToast()
|
||||
const calendars = ref([])
|
||||
const allEvents = ref([])
|
||||
const currentDate = ref(new Date())
|
||||
|
||||
const showNewCalendar = ref(false)
|
||||
const newCalName = ref('')
|
||||
const newCalColor = ref('#3788d8')
|
||||
|
||||
const showEventDialog = ref(false)
|
||||
const editingEvent = ref(null)
|
||||
const eventForm = ref({ summary: '', calendar_id: null, dtstart: '', dtend: '', all_day: false })
|
||||
|
||||
const showCalMenu = ref(false)
|
||||
const selectedCal = ref(null)
|
||||
const shareUsername = ref('')
|
||||
const sharePermission = ref('read')
|
||||
const icalUrl = ref('')
|
||||
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
|
||||
|
||||
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
|
||||
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
|
||||
const fullIcalUrl = computed(() => icalUrl.value ? `${window.location.origin}${icalUrl.value}` : '')
|
||||
|
||||
const currentMonthLabel = computed(() => {
|
||||
return currentDate.value.toLocaleString('de-DE', { month: 'long', year: 'numeric' })
|
||||
})
|
||||
|
||||
const calendarCells = computed(() => {
|
||||
const year = currentDate.value.getFullYear()
|
||||
const month = currentDate.value.getMonth()
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
const today = new Date()
|
||||
|
||||
let startDay = (firstDay.getDay() + 6) % 7
|
||||
const cells = []
|
||||
|
||||
for (let i = startDay - 1; i >= 0; i--) {
|
||||
const d = new Date(year, month, -i)
|
||||
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
|
||||
}
|
||||
|
||||
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||
const date = new Date(year, month, d)
|
||||
const isToday = date.toDateString() === today.toDateString()
|
||||
const dayEvents = allEvents.value.filter(e => {
|
||||
const start = new Date(e.dtstart)
|
||||
return start.getFullYear() === year && start.getMonth() === month && start.getDate() === d
|
||||
})
|
||||
cells.push({ date, day: d, currentMonth: true, isToday, events: dayEvents })
|
||||
}
|
||||
|
||||
while (cells.length < 42) {
|
||||
const d = new Date(year, month + 1, cells.length - startDay - lastDay.getDate() + 1)
|
||||
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
|
||||
}
|
||||
|
||||
return cells
|
||||
})
|
||||
|
||||
function changeMonth(delta) {
|
||||
const d = new Date(currentDate.value)
|
||||
d.setMonth(d.getMonth() + delta)
|
||||
currentDate.value = d
|
||||
loadEvents()
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
currentDate.value = new Date()
|
||||
loadEvents()
|
||||
}
|
||||
|
||||
async function loadCalendars() {
|
||||
const res = await apiClient.get('/calendars')
|
||||
calendars.value = res.data
|
||||
if (!calendars.value.length) {
|
||||
await apiClient.post('/calendars', { name: 'Mein Kalender', color: '#3788d8' })
|
||||
const res2 = await apiClient.get('/calendars')
|
||||
calendars.value = res2.data
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
allEvents.value = []
|
||||
for (const cal of calendars.value) {
|
||||
const year = currentDate.value.getFullYear()
|
||||
const month = currentDate.value.getMonth()
|
||||
const start = new Date(year, month - 1, 1).toISOString()
|
||||
const end = new Date(year, month + 2, 0).toISOString()
|
||||
try {
|
||||
const res = await apiClient.get(`/calendars/${cal.id}/events`, { params: { start, end } })
|
||||
allEvents.value.push(...res.data.map(e => ({ ...e, color: cal.color, calendarName: cal.name })))
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function createCalendar() {
|
||||
if (!newCalName.value.trim()) return
|
||||
await apiClient.post('/calendars', { name: newCalName.value.trim(), color: newCalColor.value })
|
||||
showNewCalendar.value = false
|
||||
newCalName.value = ''
|
||||
await loadCalendars()
|
||||
}
|
||||
|
||||
function openNewEvent() {
|
||||
editingEvent.value = null
|
||||
const now = new Date()
|
||||
const later = new Date(now.getTime() + 3600000)
|
||||
eventForm.value = {
|
||||
summary: '',
|
||||
calendar_id: ownCalendars.value[0]?.id,
|
||||
dtstart: toLocalISO(now),
|
||||
dtend: toLocalISO(later),
|
||||
all_day: false,
|
||||
}
|
||||
showEventDialog.value = true
|
||||
}
|
||||
|
||||
function openNewEventOnDate(date) {
|
||||
editingEvent.value = null
|
||||
const start = new Date(date); start.setHours(9, 0)
|
||||
const end = new Date(date); end.setHours(10, 0)
|
||||
eventForm.value = {
|
||||
summary: '',
|
||||
calendar_id: ownCalendars.value[0]?.id,
|
||||
dtstart: toLocalISO(start),
|
||||
dtend: toLocalISO(end),
|
||||
all_day: false,
|
||||
}
|
||||
showEventDialog.value = true
|
||||
}
|
||||
|
||||
function openEditEvent(evt) {
|
||||
editingEvent.value = evt
|
||||
eventForm.value = {
|
||||
summary: evt.summary,
|
||||
calendar_id: evt.calendar_id,
|
||||
dtstart: toLocalISO(new Date(evt.dtstart)),
|
||||
dtend: toLocalISO(new Date(evt.dtend)),
|
||||
all_day: evt.all_day,
|
||||
}
|
||||
showEventDialog.value = true
|
||||
}
|
||||
|
||||
async function saveEvent() {
|
||||
if (!eventForm.value.summary.trim()) return
|
||||
const payload = { ...eventForm.value }
|
||||
|
||||
if (editingEvent.value) {
|
||||
await apiClient.put(`/events/${editingEvent.value.id}`, payload)
|
||||
} else {
|
||||
await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload)
|
||||
}
|
||||
showEventDialog.value = false
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
async function deleteEvent() {
|
||||
if (!editingEvent.value) return
|
||||
await apiClient.delete(`/events/${editingEvent.value.id}`)
|
||||
showEventDialog.value = false
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
function openCalendarMenu(cal) {
|
||||
selectedCal.value = cal
|
||||
icalUrl.value = ''
|
||||
showCalMenu.value = true
|
||||
}
|
||||
|
||||
async function shareCalendar() {
|
||||
if (!shareUsername.value.trim() || !selectedCal.value) return
|
||||
try {
|
||||
await apiClient.post(`/calendars/${selectedCal.value.id}/share`, {
|
||||
username: shareUsername.value.trim(), permission: sharePermission.value,
|
||||
})
|
||||
toast.add({ severity: 'success', summary: 'Kalender geteilt', life: 3000 })
|
||||
shareUsername.value = ''
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function generateIcalLink() {
|
||||
if (!selectedCal.value) return
|
||||
const res = await apiClient.post(`/calendars/${selectedCal.value.id}/ical-link`)
|
||||
icalUrl.value = res.data.ical_url
|
||||
}
|
||||
|
||||
function copyIcal() {
|
||||
navigator.clipboard.writeText(fullIcalUrl.value)
|
||||
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
|
||||
}
|
||||
|
||||
async function deleteCalendar() {
|
||||
if (!selectedCal.value) return
|
||||
await apiClient.delete(`/calendars/${selectedCal.value.id}`)
|
||||
showCalMenu.value = false
|
||||
await loadCalendars()
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
function toLocalISO(date) {
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCalendars()
|
||||
await loadEvents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.view-header h2 { margin: 0; }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.calendar-layout { display: flex; gap: 1rem; }
|
||||
.calendar-sidebar { width: 220px; flex-shrink: 0; }
|
||||
.calendar-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
|
||||
.calendar-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
|
||||
.shared-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.calendar-main { flex: 1; }
|
||||
.cal-nav { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
.cal-nav h3 { margin: 0; min-width: 180px; text-align: center; }
|
||||
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); border: 1px solid var(--p-surface-200); }
|
||||
.cal-header { padding: 0.5rem; text-align: center; font-weight: 600; font-size: 0.8rem; background: var(--p-surface-100); border-bottom: 1px solid var(--p-surface-200); }
|
||||
.cal-cell { min-height: 80px; padding: 0.25rem; border: 1px solid var(--p-surface-100); cursor: pointer; font-size: 0.8rem; }
|
||||
.cal-cell:hover { background: var(--p-surface-50); }
|
||||
.cal-cell.other-month { opacity: 0.4; }
|
||||
.cal-cell.today { background: var(--p-primary-50); }
|
||||
.cell-day { font-weight: 500; font-size: 0.75rem; }
|
||||
.cell-event { font-size: 0.7rem; padding: 1px 4px; border-radius: 3px; color: white; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.field-row { display: flex; gap: 1rem; }
|
||||
.field-row .field { flex: 1; }
|
||||
.share-row { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.ical-url { margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ical-url code { font-size: 0.75rem; word-break: break-all; }
|
||||
.cal-menu-content p { margin: 0 0 1rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Kontakte</h2>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-book" label="Neues Adressbuch" size="small" outlined @click="showNewBook = true" />
|
||||
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small" @click="openNewContact" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contacts-layout">
|
||||
<aside class="books-sidebar">
|
||||
<div v-for="book in addressBooks" :key="book.id"
|
||||
class="book-item" :class="{ active: selectedBookId === book.id }"
|
||||
@click="selectBook(book.id)">
|
||||
<i class="pi pi-book"></i>
|
||||
<span>{{ book.name }}</span>
|
||||
<span class="count">{{ book.contact_count }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="contacts-main">
|
||||
<div class="search-bar">
|
||||
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="loadContacts" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="contacts" :loading="loading" striped-rows @row-click="openEditContact">
|
||||
<template #empty><p class="empty">Keine Kontakte</p></template>
|
||||
<Column field="display_name" header="Name" sortable />
|
||||
<Column field="email" header="E-Mail" sortable />
|
||||
<Column field="phone" header="Telefon" />
|
||||
<Column header="" style="width: 80px">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="deleteContact(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Address Book -->
|
||||
<Dialog v-model:visible="showNewBook" header="Neues Adressbuch" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="newBookName" fluid autofocus @keyup.enter="createBook" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewBook = false" />
|
||||
<Button label="Erstellen" @click="createBook" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Contact Form -->
|
||||
<Dialog v-model:visible="showContactForm" :header="editingContact ? 'Kontakt bearbeiten' : 'Neuer Kontakt'" modal :style="{ width: '500px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="contactForm.display_name" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>E-Mail</label>
|
||||
<InputText v-model="contactForm.email" type="email" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Telefon</label>
|
||||
<InputText v-model="contactForm.phone" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Organisation</label>
|
||||
<InputText v-model="contactForm.organization" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notizen</label>
|
||||
<Textarea v-model="contactForm.notes" rows="3" fluid />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showContactForm = false" />
|
||||
<Button :label="editingContact ? 'Speichern' : 'Erstellen'" @click="saveContact" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
|
||||
const toast = useToast()
|
||||
const addressBooks = ref([])
|
||||
const contacts = ref([])
|
||||
const selectedBookId = ref(null)
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const showNewBook = ref(false)
|
||||
const newBookName = ref('')
|
||||
|
||||
const showContactForm = ref(false)
|
||||
const editingContact = ref(null)
|
||||
const contactForm = ref({ display_name: '', email: '', phone: '', organization: '', notes: '' })
|
||||
|
||||
async function loadBooks() {
|
||||
const res = await apiClient.get('/addressbooks')
|
||||
addressBooks.value = res.data
|
||||
if (addressBooks.value.length && !selectedBookId.value) {
|
||||
selectedBookId.value = addressBooks.value[0].id
|
||||
await loadContacts()
|
||||
}
|
||||
if (!addressBooks.value.length) {
|
||||
await apiClient.post('/addressbooks', { name: 'Kontakte' })
|
||||
await loadBooks()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContacts() {
|
||||
if (!selectedBookId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = searchQuery.value ? { search: searchQuery.value } : {}
|
||||
const res = await apiClient.get(`/addressbooks/${selectedBookId.value}/contacts`, { params })
|
||||
contacts.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectBook(id) {
|
||||
selectedBookId.value = id
|
||||
loadContacts()
|
||||
}
|
||||
|
||||
async function createBook() {
|
||||
if (!newBookName.value.trim()) return
|
||||
await apiClient.post('/addressbooks', { name: newBookName.value.trim() })
|
||||
showNewBook.value = false
|
||||
newBookName.value = ''
|
||||
await loadBooks()
|
||||
}
|
||||
|
||||
function openNewContact() {
|
||||
editingContact.value = null
|
||||
contactForm.value = { display_name: '', email: '', phone: '', organization: '', notes: '' }
|
||||
showContactForm.value = true
|
||||
}
|
||||
|
||||
function openEditContact(event) {
|
||||
const c = event.data
|
||||
editingContact.value = c
|
||||
contactForm.value = {
|
||||
display_name: c.display_name || '',
|
||||
email: c.email || '',
|
||||
phone: c.phone || '',
|
||||
organization: '',
|
||||
notes: '',
|
||||
}
|
||||
showContactForm.value = true
|
||||
}
|
||||
|
||||
async function saveContact() {
|
||||
if (!contactForm.value.display_name.trim()) return
|
||||
try {
|
||||
if (editingContact.value) {
|
||||
await apiClient.put(`/contacts/${editingContact.value.id}`, contactForm.value)
|
||||
} else {
|
||||
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, contactForm.value)
|
||||
}
|
||||
showContactForm.value = false
|
||||
await loadContacts()
|
||||
await loadBooks()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(contact) {
|
||||
await apiClient.delete(`/contacts/${contact.id}`)
|
||||
await loadContacts()
|
||||
await loadBooks()
|
||||
}
|
||||
|
||||
onMounted(loadBooks)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.view-header h2 { margin: 0; }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.contacts-layout { display: flex; gap: 1rem; }
|
||||
.books-sidebar { width: 220px; flex-shrink: 0; }
|
||||
.book-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
|
||||
.book-item:hover { background: var(--p-surface-100); }
|
||||
.book-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
|
||||
.book-item .count { margin-left: auto; color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.contacts-main { flex: 1; }
|
||||
.search-bar { margin-bottom: 1rem; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="view-container email-view">
|
||||
<div class="email-layout">
|
||||
<!-- Folder sidebar -->
|
||||
<aside class="email-sidebar">
|
||||
<div v-for="account in accounts" :key="account.id" class="account-group">
|
||||
<div class="account-header">
|
||||
<i class="pi pi-envelope"></i>
|
||||
<span>{{ account.display_name }}</span>
|
||||
</div>
|
||||
<div v-for="folder in account.folders || []" :key="folder.name"
|
||||
class="folder-item" :class="{ active: activeFolder === `${account.id}:${folder.name}` }"
|
||||
@click="selectFolder(account, folder)">
|
||||
<i :class="folderIcon(folder.name)"></i>
|
||||
<span>{{ folder.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon="pi pi-cog" label="Konten verwalten" text size="small" class="manage-btn"
|
||||
@click="$router.push('/settings')" />
|
||||
</aside>
|
||||
|
||||
<!-- Message list -->
|
||||
<div class="message-list-panel">
|
||||
<div class="list-header">
|
||||
<span class="folder-title">{{ currentFolderName }}</span>
|
||||
<Button icon="pi pi-refresh" text size="small" @click="loadMessages" />
|
||||
<Button icon="pi pi-pencil" label="Neue E-Mail" size="small" @click="openCompose" />
|
||||
</div>
|
||||
<div v-if="loadingMessages" class="loading-center">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
</div>
|
||||
<div v-else class="message-list">
|
||||
<div v-for="msg in messages" :key="msg.uid"
|
||||
class="message-item" :class="{ unread: !msg.seen, active: selectedMessage?.uid === msg.uid }"
|
||||
@click="selectMessage(msg)">
|
||||
<div class="msg-from">{{ msg.from }}</div>
|
||||
<div class="msg-subject">{{ msg.subject || '(Kein Betreff)' }}</div>
|
||||
<div class="msg-date">{{ formatDate(msg.date) }}</div>
|
||||
</div>
|
||||
<div v-if="!messages.length" class="empty">Keine Nachrichten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message view -->
|
||||
<div class="message-view-panel">
|
||||
<div v-if="selectedMessage && messageDetail">
|
||||
<div class="msg-header">
|
||||
<h3>{{ messageDetail.subject }}</h3>
|
||||
<div class="msg-meta">
|
||||
<div><strong>Von:</strong> {{ messageDetail.from }}</div>
|
||||
<div><strong>An:</strong> {{ messageDetail.to }}</div>
|
||||
<div v-if="messageDetail.cc"><strong>CC:</strong> {{ messageDetail.cc }}</div>
|
||||
<div><strong>Datum:</strong> {{ messageDetail.date }}</div>
|
||||
</div>
|
||||
<div class="msg-actions">
|
||||
<Button icon="pi pi-reply" label="Antworten" size="small" outlined @click="replyTo" />
|
||||
<Button icon="pi pi-trash" size="small" severity="danger" text @click="deleteMessage" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="messageDetail.html_body" class="msg-body" v-html="messageDetail.html_body"></div>
|
||||
<pre v-else-if="messageDetail.text_body" class="msg-body-text">{{ messageDetail.text_body }}</pre>
|
||||
<div v-if="messageDetail.attachments?.length" class="msg-attachments">
|
||||
<strong>Anhaenge:</strong>
|
||||
<span v-for="a in messageDetail.attachments" :key="a.filename" class="attachment">
|
||||
<i class="pi pi-paperclip"></i> {{ a.filename }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-message">
|
||||
<i class="pi pi-envelope" style="font-size: 2rem; color: var(--p-surface-400)"></i>
|
||||
<p>Nachricht auswaehlen</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compose Dialog -->
|
||||
<Dialog v-model:visible="showCompose" header="Neue E-Mail" modal :style="{ width: '700px' }">
|
||||
<div v-if="accounts.length > 1" class="field">
|
||||
<label>Von</label>
|
||||
<Select v-model="composeForm.account_id" :options="accounts" optionLabel="email_address" optionValue="id" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>An</label>
|
||||
<InputText v-model="composeForm.to" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>CC</label>
|
||||
<InputText v-model="composeForm.cc" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Betreff</label>
|
||||
<InputText v-model="composeForm.subject" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nachricht</label>
|
||||
<Textarea v-model="composeForm.body_text" rows="12" fluid />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showCompose = false" />
|
||||
<Button label="Senden" icon="pi pi-send" @click="sendEmail" :loading="sending" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const accounts = ref([])
|
||||
const messages = ref([])
|
||||
const selectedMessage = ref(null)
|
||||
const messageDetail = ref(null)
|
||||
const activeFolder = ref('')
|
||||
const currentFolderName = ref('INBOX')
|
||||
const currentAccount = ref(null)
|
||||
const loadingMessages = ref(false)
|
||||
|
||||
const showCompose = ref(false)
|
||||
const sending = ref(false)
|
||||
const composeForm = ref({ account_id: null, to: '', cc: '', subject: '', body_text: '' })
|
||||
|
||||
function getEncKey() { return auth.masterKeySalt || '' }
|
||||
|
||||
function folderIcon(name) {
|
||||
const n = name.toLowerCase()
|
||||
if (n === 'inbox') return 'pi pi-inbox'
|
||||
if (n.includes('sent') || n.includes('gesendet')) return 'pi pi-send'
|
||||
if (n.includes('draft') || n.includes('entwu')) return 'pi pi-file-edit'
|
||||
if (n.includes('trash') || n.includes('papier') || n.includes('gelöscht')) return 'pi pi-trash'
|
||||
if (n.includes('spam') || n.includes('junk')) return 'pi pi-ban'
|
||||
return 'pi pi-folder'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
const res = await apiClient.get('/email/accounts')
|
||||
accounts.value = res.data
|
||||
|
||||
for (const acc of accounts.value) {
|
||||
try {
|
||||
const fRes = await apiClient.get(`/email/accounts/${acc.id}/folders`, {
|
||||
headers: { 'X-Encryption-Key': getEncKey() }
|
||||
})
|
||||
acc.folders = fRes.data
|
||||
} catch { acc.folders = [{ name: 'INBOX', flags: [], delimiter: '/' }] }
|
||||
}
|
||||
|
||||
if (accounts.value.length) {
|
||||
selectFolder(accounts.value[0], { name: 'INBOX' })
|
||||
}
|
||||
}
|
||||
|
||||
function selectFolder(account, folder) {
|
||||
currentAccount.value = account
|
||||
activeFolder.value = `${account.id}:${folder.name}`
|
||||
currentFolderName.value = `${account.display_name} - ${folder.name}`
|
||||
loadMessages()
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
if (!currentAccount.value) return
|
||||
loadingMessages.value = true
|
||||
selectedMessage.value = null
|
||||
messageDetail.value = null
|
||||
try {
|
||||
const folder = activeFolder.value.split(':')[1]
|
||||
const res = await apiClient.get(
|
||||
`/email/accounts/${currentAccount.value.id}/folders/${encodeURIComponent(folder)}/messages`,
|
||||
{ headers: { 'X-Encryption-Key': getEncKey() } }
|
||||
)
|
||||
messages.value = res.data.messages
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
messages.value = []
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectMessage(msg) {
|
||||
selectedMessage.value = msg
|
||||
try {
|
||||
const folder = activeFolder.value.split(':')[1]
|
||||
const res = await apiClient.get(
|
||||
`/email/accounts/${currentAccount.value.id}/messages/${msg.uid}`,
|
||||
{ params: { folder }, headers: { 'X-Encryption-Key': getEncKey() } }
|
||||
)
|
||||
messageDetail.value = res.data
|
||||
msg.seen = true
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
function openCompose() {
|
||||
composeForm.value = {
|
||||
account_id: currentAccount.value?.id || accounts.value[0]?.id,
|
||||
to: '', cc: '', subject: '', body_text: '',
|
||||
}
|
||||
showCompose.value = true
|
||||
}
|
||||
|
||||
function replyTo() {
|
||||
if (!messageDetail.value) return
|
||||
composeForm.value = {
|
||||
account_id: currentAccount.value?.id,
|
||||
to: messageDetail.value.from,
|
||||
cc: '',
|
||||
subject: `Re: ${messageDetail.value.subject}`,
|
||||
body_text: `\n\n--- Urspruengliche Nachricht ---\n${messageDetail.value.text_body || ''}`,
|
||||
}
|
||||
showCompose.value = true
|
||||
}
|
||||
|
||||
async function sendEmail() {
|
||||
sending.value = true
|
||||
try {
|
||||
await apiClient.post('/email/send', composeForm.value, {
|
||||
headers: { 'X-Encryption-Key': getEncKey() }
|
||||
})
|
||||
showCompose.value = false
|
||||
toast.add({ severity: 'success', summary: 'E-Mail gesendet', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Sendefehler', detail: err.response?.data?.error, life: 5000 })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMessage() {
|
||||
if (!selectedMessage.value || !currentAccount.value) return
|
||||
const folder = activeFolder.value.split(':')[1]
|
||||
try {
|
||||
await apiClient.delete(
|
||||
`/email/accounts/${currentAccount.value.id}/messages/${selectedMessage.value.uid}`,
|
||||
{ params: { folder }, headers: { 'X-Encryption-Key': getEncKey() } }
|
||||
)
|
||||
selectedMessage.value = null
|
||||
messageDetail.value = null
|
||||
await loadMessages()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAccounts)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 0; height: calc(100vh - 0px); }
|
||||
.email-layout { display: flex; height: 100%; }
|
||||
.email-sidebar { width: 220px; border-right: 1px solid var(--p-surface-200); overflow-y: auto; padding: 0.5rem; flex-shrink: 0; }
|
||||
.account-group { margin-bottom: 0.5rem; }
|
||||
.account-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; font-weight: 600; font-size: 0.85rem; }
|
||||
.folder-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.5rem 0.375rem 1.5rem; font-size: 0.825rem; cursor: pointer; border-radius: 4px; }
|
||||
.folder-item:hover { background: var(--p-surface-100); }
|
||||
.folder-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
|
||||
.manage-btn { margin-top: 0.5rem; }
|
||||
.message-list-panel { width: 350px; border-right: 1px solid var(--p-surface-200); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.list-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; border-bottom: 1px solid var(--p-surface-200); }
|
||||
.folder-title { font-weight: 600; font-size: 0.875rem; flex: 1; }
|
||||
.message-list { flex: 1; overflow-y: auto; }
|
||||
.message-item { padding: 0.625rem 0.75rem; border-bottom: 1px solid var(--p-surface-100); cursor: pointer; }
|
||||
.message-item:hover { background: var(--p-surface-50); }
|
||||
.message-item.active { background: var(--p-primary-50); }
|
||||
.message-item.unread { font-weight: 600; }
|
||||
.msg-from { font-size: 0.8rem; color: var(--p-text-muted-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.msg-subject { font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.msg-date { font-size: 0.7rem; color: var(--p-text-muted-color); }
|
||||
.message-view-panel { flex: 1; overflow-y: auto; padding: 1rem; }
|
||||
.msg-header h3 { margin: 0 0 0.75rem; }
|
||||
.msg-meta { font-size: 0.85rem; margin-bottom: 0.75rem; line-height: 1.6; }
|
||||
.msg-actions { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.msg-body { border: 1px solid var(--p-surface-200); border-radius: 6px; padding: 1rem; background: white; }
|
||||
.msg-body-text { white-space: pre-wrap; font-size: 0.875rem; }
|
||||
.msg-attachments { margin-top: 1rem; font-size: 0.85rem; }
|
||||
.attachment { display: inline-flex; align-items: center; gap: 0.25rem; margin-right: 0.75rem; }
|
||||
.empty-message { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 0.5rem; color: var(--p-text-muted-color); }
|
||||
.empty { text-align: center; padding: 2rem; color: var(--p-text-muted-color); }
|
||||
.loading-center { display: flex; justify-content: center; padding: 2rem; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<div class="breadcrumb">
|
||||
<a @click="navigateTo(null)" class="crumb">Dateien</a>
|
||||
<template v-for="item in filesStore.breadcrumb" :key="item.id">
|
||||
<i class="pi pi-angle-right crumb-sep"></i>
|
||||
<a @click="navigateTo(item.id)" class="crumb">{{ item.name }}</a>
|
||||
</template>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
|
||||
<Button icon="pi pi-upload" label="Hochladen" size="small" @click="triggerUpload" />
|
||||
<input ref="fileInput" type="file" multiple hidden @change="handleUpload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="filesStore.files"
|
||||
:loading="filesStore.loading"
|
||||
@row-dblclick="handleDoubleClick"
|
||||
striped-rows
|
||||
removable-sort
|
||||
class="files-table"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<i class="pi pi-folder-open" style="font-size: 2rem; color: var(--p-surface-400)"></i>
|
||||
<p>Dieser Ordner ist leer</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="name" header="Name" sortable style="min-width: 300px">
|
||||
<template #body="{ data }">
|
||||
<div class="file-name" @click="data.is_folder && navigateTo(data.id)">
|
||||
<i :class="fileIcon(data)" class="file-icon"></i>
|
||||
<span>{{ data.name }}</span>
|
||||
<Tag v-if="data.shared" value="Geteilt" severity="info" class="shared-tag" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="size" header="Groesse" sortable style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
{{ data.is_folder ? '' : formatSize(data.size) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="updated_at" header="Geaendert" sortable style="width: 180px">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.updated_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="" style="width: 140px">
|
||||
<template #body="{ data }">
|
||||
<div class="row-actions">
|
||||
<Button
|
||||
v-if="!data.is_folder"
|
||||
icon="pi pi-download"
|
||||
text rounded size="small"
|
||||
@click="downloadFile(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-share-alt"
|
||||
text rounded size="small"
|
||||
@click="openShare(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text rounded size="small"
|
||||
@click="openRename(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text rounded size="small"
|
||||
severity="danger"
|
||||
@click="confirmDelete(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- New Folder Dialog -->
|
||||
<Dialog v-model:visible="showNewFolder" header="Neuer Ordner" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Ordnername</label>
|
||||
<InputText v-model="newFolderName" fluid autofocus @keyup.enter="createFolder" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewFolder = false" />
|
||||
<Button label="Erstellen" @click="createFolder" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Rename Dialog -->
|
||||
<Dialog v-model:visible="showRename" header="Umbenennen" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Neuer Name</label>
|
||||
<InputText v-model="renameName" fluid autofocus @keyup.enter="doRename" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showRename = false" />
|
||||
<Button label="Umbenennen" @click="doRename" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Share Dialog -->
|
||||
<Dialog v-model:visible="showShare" header="Teilen" modal :style="{ width: '500px' }">
|
||||
<div v-if="shareFile" class="share-content">
|
||||
<h4>{{ shareFile.name }}</h4>
|
||||
|
||||
<div class="share-form">
|
||||
<div class="field">
|
||||
<label>Passwort (optional)</label>
|
||||
<Password v-model="sharePassword" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ablaufdatum (optional)</label>
|
||||
<InputText v-model="shareExpiry" type="date" fluid />
|
||||
</div>
|
||||
<Button label="Link erstellen" icon="pi pi-link" @click="createShare" :loading="shareLoading" />
|
||||
</div>
|
||||
|
||||
<div v-if="shareLinks.length" class="existing-links">
|
||||
<h4>Bestehende Links</h4>
|
||||
<div v-for="link in shareLinks" :key="link.id" class="share-link-item">
|
||||
<div class="link-info">
|
||||
<code>{{ window.location.origin }}/share/{{ link.token }}</code>
|
||||
<small>
|
||||
{{ link.download_count }} Downloads
|
||||
<template v-if="link.expires_at"> | Bis {{ formatDate(link.expires_at) }}</template>
|
||||
<template v-if="link.has_password"> | Passwortgeschuetzt</template>
|
||||
</small>
|
||||
</div>
|
||||
<div class="link-actions">
|
||||
<Button icon="pi pi-copy" text size="small" @click="copyLink(link.token)" />
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(link.token)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirm -->
|
||||
<Dialog v-model:visible="showDeleteConfirm" header="Loeschen bestaetigen" modal :style="{ width: '400px' }">
|
||||
<p>Moechtest du <strong>{{ deleteTarget?.name }}</strong> wirklich loeschen?</p>
|
||||
<p v-if="deleteTarget?.is_folder" class="text-warn">Alle Dateien in diesem Ordner werden ebenfalls geloescht!</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="doDelete" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useFilesStore } from '../stores/files'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const filesStore = useFilesStore()
|
||||
const toast = useToast()
|
||||
|
||||
const fileInput = ref(null)
|
||||
const showNewFolder = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const showRename = ref(false)
|
||||
const renameName = ref('')
|
||||
const renameTarget = ref(null)
|
||||
const showShare = ref(false)
|
||||
const shareFile = ref(null)
|
||||
const sharePassword = ref('')
|
||||
const shareExpiry = ref('')
|
||||
const shareLinks = ref([])
|
||||
const shareLoading = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleteTarget = ref(null)
|
||||
|
||||
function currentParentId() {
|
||||
const id = route.params.folderId
|
||||
return id ? parseInt(id) : null
|
||||
}
|
||||
|
||||
function navigateTo(folderId) {
|
||||
if (folderId) {
|
||||
router.push(`/files/${folderId}`)
|
||||
} else {
|
||||
router.push('/files')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDoubleClick(event) {
|
||||
const data = event.data
|
||||
if (data.is_folder) {
|
||||
navigateTo(data.id)
|
||||
} else {
|
||||
downloadFile(data)
|
||||
}
|
||||
}
|
||||
|
||||
function fileIcon(data) {
|
||||
if (data.is_folder) return 'pi pi-folder'
|
||||
const mime = data.mime_type || ''
|
||||
if (mime.startsWith('image/')) return 'pi pi-image'
|
||||
if (mime.startsWith('video/')) return 'pi pi-video'
|
||||
if (mime.startsWith('audio/')) return 'pi pi-volume-up'
|
||||
if (mime.includes('pdf')) return 'pi pi-file-pdf'
|
||||
if (mime.includes('word') || mime.includes('document')) return 'pi pi-file-word'
|
||||
if (mime.includes('sheet') || mime.includes('excel')) return 'pi pi-file-excel'
|
||||
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'pi pi-file'
|
||||
return 'pi pi-file'
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
let size = bytes
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleUpload(event) {
|
||||
const uploadFiles = event.target.files
|
||||
if (!uploadFiles.length) return
|
||||
|
||||
for (const file of uploadFiles) {
|
||||
try {
|
||||
await filesStore.uploadFile(file, currentParentId())
|
||||
toast.add({ severity: 'success', summary: `${file.name} hochgeladen`, life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: `Fehler: ${file.name}`, detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
try {
|
||||
await filesStore.createFolder(newFolderName.value.trim(), currentParentId())
|
||||
showNewFolder.value = false
|
||||
newFolderName.value = ''
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(data) {
|
||||
const url = filesStore.downloadUrl(data.id)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = data.name
|
||||
a.click()
|
||||
}
|
||||
|
||||
function openRename(data) {
|
||||
renameTarget.value = data
|
||||
renameName.value = data.name
|
||||
showRename.value = true
|
||||
}
|
||||
|
||||
async function doRename() {
|
||||
if (!renameName.value.trim() || !renameTarget.value) return
|
||||
try {
|
||||
await filesStore.renameFile(renameTarget.value.id, renameName.value.trim())
|
||||
showRename.value = false
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function openShare(data) {
|
||||
shareFile.value = data
|
||||
sharePassword.value = ''
|
||||
shareExpiry.value = ''
|
||||
showShare.value = true
|
||||
try {
|
||||
shareLinks.value = await filesStore.getShareLinks(data.id)
|
||||
} catch {
|
||||
shareLinks.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function createShare() {
|
||||
if (!shareFile.value) return
|
||||
shareLoading.value = true
|
||||
try {
|
||||
const opts = {}
|
||||
if (sharePassword.value) opts.password = sharePassword.value
|
||||
if (shareExpiry.value) opts.expires_at = shareExpiry.value
|
||||
await filesStore.createShareLink(shareFile.value.id, opts)
|
||||
shareLinks.value = await filesStore.getShareLinks(shareFile.value.id)
|
||||
sharePassword.value = ''
|
||||
shareExpiry.value = ''
|
||||
toast.add({ severity: 'success', summary: 'Link erstellt', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
} finally {
|
||||
shareLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyLink(token) {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/share/${token}`)
|
||||
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
|
||||
}
|
||||
|
||||
async function removeShare(token) {
|
||||
try {
|
||||
await filesStore.deleteShareLink(token)
|
||||
shareLinks.value = shareLinks.value.filter(l => l.token !== token)
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(data) {
|
||||
deleteTarget.value = data
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
try {
|
||||
await filesStore.deleteFile(deleteTarget.value.id)
|
||||
showDeleteConfirm.value = false
|
||||
toast.add({ severity: 'success', summary: 'Geloescht', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.params.folderId, () => {
|
||||
filesStore.loadFiles(currentParentId())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
filesStore.loadFiles(currentParentId())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.breadcrumb { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.crumb { cursor: pointer; color: var(--p-primary-color); font-weight: 500; }
|
||||
.crumb:hover { text-decoration: underline; }
|
||||
.crumb-sep { font-size: 0.75rem; color: var(--p-surface-400); }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.file-name {
|
||||
display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
|
||||
}
|
||||
.file-icon { font-size: 1.125rem; width: 1.25rem; text-align: center; }
|
||||
.shared-tag { font-size: 0.7rem; }
|
||||
.row-actions { display: flex; gap: 0; }
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0.5rem; padding: 3rem; color: var(--p-text-muted-color);
|
||||
}
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.share-content h4 { margin: 0 0 1rem; }
|
||||
.share-form { margin-bottom: 1.5rem; }
|
||||
.existing-links { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; }
|
||||
.share-link-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100);
|
||||
}
|
||||
.link-info { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.link-info code { font-size: 0.8rem; word-break: break-all; }
|
||||
.link-info small { color: var(--p-text-muted-color); }
|
||||
.link-actions { display: flex; }
|
||||
.text-warn { color: var(--p-orange-500); font-size: 0.875rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<i class="pi pi-cloud" style="font-size: 2.5rem; color: var(--p-primary-color)"></i>
|
||||
<h1>Mini-Cloud</h1>
|
||||
<p>Anmelden</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="auth-form">
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<InputText
|
||||
id="username"
|
||||
v-model="username"
|
||||
placeholder="Benutzername"
|
||||
:invalid="!!error"
|
||||
autofocus
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="password"
|
||||
placeholder="Passwort"
|
||||
:feedback="false"
|
||||
:invalid="!!error"
|
||||
toggle-mask
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
label="Anmelden"
|
||||
:loading="loading"
|
||||
fluid
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<router-link to="/register">Noch kein Konto? Registrieren</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Anmeldung fehlgeschlagen'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--p-surface-50);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--p-surface-0);
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--p-text-muted-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form .field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-form .field label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-form button {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--p-primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Passwoerter</h2>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
|
||||
<Button icon="pi pi-plus" label="Neuer Eintrag" size="small" @click="openNewEntry" />
|
||||
<Button icon="pi pi-upload" label="KeePass Import" size="small" outlined @click="showImport = true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="passwords-layout">
|
||||
<aside class="folders-sidebar">
|
||||
<div class="folder-item" :class="{ active: selectedFolderId === null }" @click="selectedFolderId = null; loadEntries()">
|
||||
<i class="pi pi-key"></i>
|
||||
<span>Alle</span>
|
||||
</div>
|
||||
<div v-for="folder in folders" :key="folder.id"
|
||||
class="folder-item" :class="{ active: selectedFolderId === folder.id }"
|
||||
@click="selectedFolderId = folder.id; loadEntries()">
|
||||
<i :class="folder.icon || 'pi pi-folder'"></i>
|
||||
<span>{{ folder.name }}</span>
|
||||
<span v-if="folder.owner_name" class="shared-label">({{ folder.owner_name }})</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="entries-main">
|
||||
<div class="search-bar">
|
||||
<InputText v-model="searchQuery" placeholder="Passwoerter suchen..." fluid />
|
||||
</div>
|
||||
|
||||
<div class="entries-list">
|
||||
<div v-for="entry in filteredEntries" :key="entry.id"
|
||||
class="entry-item" @click="openEntry(entry)">
|
||||
<div class="entry-icon">
|
||||
<i class="pi pi-key"></i>
|
||||
</div>
|
||||
<div class="entry-info">
|
||||
<div class="entry-title">{{ decryptedEntries[entry.id]?.title || '(Verschluesselt)' }}</div>
|
||||
<div class="entry-url">{{ decryptedEntries[entry.id]?.username || '' }}</div>
|
||||
</div>
|
||||
<div class="entry-actions">
|
||||
<Button icon="pi pi-copy" text size="small" title="Passwort kopieren"
|
||||
@click.stop="copyPassword(entry)" />
|
||||
<Button v-if="decryptedEntries[entry.id]?.totp_secret" icon="pi pi-clock" text size="small"
|
||||
title="TOTP Code" @click.stop="showTotp(entry)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!filteredEntries.length" class="empty">
|
||||
<i class="pi pi-key" style="font-size: 2rem; color: var(--p-surface-400)"></i>
|
||||
<p>Keine Eintraege</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entry Dialog -->
|
||||
<Dialog v-model:visible="showEntryDialog" :header="editingEntry ? 'Eintrag bearbeiten' : 'Neuer Eintrag'" modal :style="{ width: '500px' }">
|
||||
<div class="field">
|
||||
<label>Titel</label>
|
||||
<InputText v-model="entryForm.title" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>URL</label>
|
||||
<InputText v-model="entryForm.url" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Benutzername</label>
|
||||
<InputText v-model="entryForm.username" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Passwort</label>
|
||||
<div class="password-field">
|
||||
<Password v-model="entryForm.password" :feedback="false" toggle-mask fluid />
|
||||
<Button icon="pi pi-sync" text size="small" title="Generieren" @click="generatePassword" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>TOTP Secret (optional)</label>
|
||||
<InputText v-model="entryForm.totp_secret" fluid placeholder="otpauth:// oder Secret" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ordner</label>
|
||||
<Select v-model="entryForm.folder_id" :options="folderOptions" optionLabel="name" optionValue="id" fluid placeholder="Kein Ordner" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Kategorie</label>
|
||||
<InputText v-model="entryForm.category" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notizen</label>
|
||||
<Textarea v-model="entryForm.notes" rows="3" fluid />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button v-if="editingEntry" label="Loeschen" severity="danger" text @click="deleteEntry" />
|
||||
<Button label="Abbrechen" text @click="showEntryDialog = false" />
|
||||
<Button :label="editingEntry ? 'Speichern' : 'Erstellen'" @click="saveEntry" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- New Folder -->
|
||||
<Dialog v-model:visible="showNewFolder" header="Neuer Ordner" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="newFolderName" fluid autofocus @keyup.enter="createFolder" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewFolder = false" />
|
||||
<Button label="Erstellen" @click="createFolder" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- KeePass Import -->
|
||||
<Dialog v-model:visible="showImport" header="KeePass Import" modal :style="{ width: '500px' }">
|
||||
<p>Waehle eine .kdbx-Datei und gib das KeePass-Passwort ein.</p>
|
||||
<div class="field">
|
||||
<label>KDBX-Datei</label>
|
||||
<input ref="kdbxInput" type="file" accept=".kdbx" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>KeePass-Passwort</label>
|
||||
<Password v-model="importPassword" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showImport = false" />
|
||||
<Button label="Importieren" @click="importKeePass" :loading="importing" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- TOTP Dialog -->
|
||||
<Dialog v-model:visible="showTotpDialog" header="TOTP Code" modal :style="{ width: '300px' }">
|
||||
<div class="totp-display">
|
||||
<div class="totp-code">{{ totpCode }}</div>
|
||||
<Button icon="pi pi-copy" text @click="copyToClipboard(totpCode)" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const folders = ref([])
|
||||
const entries = ref([])
|
||||
const decryptedEntries = ref({})
|
||||
const selectedFolderId = ref(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const showEntryDialog = ref(false)
|
||||
const editingEntry = ref(null)
|
||||
const entryForm = ref({ title: '', url: '', username: '', password: '', totp_secret: '', folder_id: null, category: '', notes: '' })
|
||||
|
||||
const showNewFolder = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const showImport = ref(false)
|
||||
const importPassword = ref('')
|
||||
const importing = ref(false)
|
||||
const kdbxInput = ref(null)
|
||||
|
||||
const showTotpDialog = ref(false)
|
||||
const totpCode = ref('')
|
||||
|
||||
const folderOptions = computed(() => [{ id: null, name: '(Kein Ordner)' }, ...folders.value])
|
||||
const filteredEntries = computed(() => {
|
||||
if (!searchQuery.value) return entries.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return entries.value.filter(e => {
|
||||
const d = decryptedEntries.value[e.id]
|
||||
if (!d) return false
|
||||
return (d.title || '').toLowerCase().includes(q) ||
|
||||
(d.username || '').toLowerCase().includes(q) ||
|
||||
(d.url || '').toLowerCase().includes(q)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Crypto helpers using Web Crypto API ---
|
||||
async function getMasterKey() {
|
||||
const salt = auth.masterKeySalt
|
||||
if (!salt) return null
|
||||
const enc = new TextEncoder()
|
||||
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(auth.user?.username + ':' + 'stored'), 'PBKDF2', false, ['deriveKey'])
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt: Uint8Array.from(atob(salt), c => c.charCodeAt(0)), iterations: 600000, hash: 'SHA-256' },
|
||||
keyMaterial, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
async function encryptText(text, key) {
|
||||
if (!text) return null
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
const enc = new TextEncoder()
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(text))
|
||||
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
|
||||
combined.set(iv)
|
||||
combined.set(new Uint8Array(ciphertext), iv.length)
|
||||
return btoa(String.fromCharCode(...combined))
|
||||
}
|
||||
|
||||
async function decryptText(b64, key) {
|
||||
if (!b64) return ''
|
||||
try {
|
||||
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
|
||||
const iv = raw.slice(0, 12)
|
||||
const ciphertext = raw.slice(12)
|
||||
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
|
||||
return new TextDecoder().decode(plaintext)
|
||||
} catch { return '(Entschluesselung fehlgeschlagen)' }
|
||||
}
|
||||
|
||||
async function decryptEntries() {
|
||||
const key = await getMasterKey()
|
||||
if (!key) return
|
||||
const result = {}
|
||||
for (const entry of entries.value) {
|
||||
result[entry.id] = {
|
||||
title: await decryptText(entry.title_encrypted, key),
|
||||
url: await decryptText(entry.url_encrypted, key),
|
||||
username: await decryptText(entry.username_encrypted, key),
|
||||
password: await decryptText(entry.password_encrypted, key),
|
||||
notes: await decryptText(entry.notes_encrypted, key),
|
||||
totp_secret: await decryptText(entry.totp_secret_encrypted, key),
|
||||
}
|
||||
}
|
||||
decryptedEntries.value = result
|
||||
}
|
||||
|
||||
// --- Data loading ---
|
||||
async function loadFolders() {
|
||||
const res = await apiClient.get('/passwords/folders')
|
||||
folders.value = res.data
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
const params = {}
|
||||
if (selectedFolderId.value !== null) params.folder_id = selectedFolderId.value
|
||||
const res = await apiClient.get('/passwords/entries', { params })
|
||||
entries.value = res.data
|
||||
await decryptEntries()
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
await apiClient.post('/passwords/folders', { name: newFolderName.value.trim() })
|
||||
showNewFolder.value = false
|
||||
newFolderName.value = ''
|
||||
await loadFolders()
|
||||
}
|
||||
|
||||
function openNewEntry() {
|
||||
editingEntry.value = null
|
||||
entryForm.value = { title: '', url: '', username: '', password: '', totp_secret: '', folder_id: selectedFolderId.value, category: '', notes: '' }
|
||||
showEntryDialog.value = true
|
||||
}
|
||||
|
||||
async function openEntry(entry) {
|
||||
editingEntry.value = entry
|
||||
const d = decryptedEntries.value[entry.id] || {}
|
||||
entryForm.value = {
|
||||
title: d.title || '',
|
||||
url: d.url || '',
|
||||
username: d.username || '',
|
||||
password: d.password || '',
|
||||
totp_secret: d.totp_secret || '',
|
||||
folder_id: entry.folder_id,
|
||||
category: entry.category || '',
|
||||
notes: d.notes || '',
|
||||
}
|
||||
showEntryDialog.value = true
|
||||
}
|
||||
|
||||
async function saveEntry() {
|
||||
const key = await getMasterKey()
|
||||
if (!key) { toast.add({ severity: 'error', summary: 'Kein Master-Key', life: 3000 }); return }
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
const ivB64 = btoa(String.fromCharCode(...iv))
|
||||
|
||||
const payload = {
|
||||
title_encrypted: await encryptText(entryForm.value.title, key),
|
||||
url_encrypted: await encryptText(entryForm.value.url, key),
|
||||
username_encrypted: await encryptText(entryForm.value.username, key),
|
||||
password_encrypted: await encryptText(entryForm.value.password, key),
|
||||
notes_encrypted: await encryptText(entryForm.value.notes, key),
|
||||
totp_secret_encrypted: await encryptText(entryForm.value.totp_secret, key),
|
||||
iv: ivB64,
|
||||
folder_id: entryForm.value.folder_id,
|
||||
category: entryForm.value.category,
|
||||
}
|
||||
|
||||
if (editingEntry.value) {
|
||||
await apiClient.put(`/passwords/entries/${editingEntry.value.id}`, payload)
|
||||
} else {
|
||||
await apiClient.post('/passwords/entries', payload)
|
||||
}
|
||||
showEntryDialog.value = false
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function deleteEntry() {
|
||||
if (!editingEntry.value) return
|
||||
await apiClient.delete(`/passwords/entries/${editingEntry.value.id}`)
|
||||
showEntryDialog.value = false
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function copyPassword(entry) {
|
||||
const d = decryptedEntries.value[entry.id]
|
||||
if (d?.password) {
|
||||
await navigator.clipboard.writeText(d.password)
|
||||
toast.add({ severity: 'info', summary: 'Passwort kopiert', life: 2000 })
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.add({ severity: 'info', summary: 'Kopiert', life: 2000 })
|
||||
}
|
||||
|
||||
function generatePassword() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-='
|
||||
const arr = new Uint8Array(20)
|
||||
crypto.getRandomValues(arr)
|
||||
entryForm.value.password = Array.from(arr, b => chars[b % chars.length]).join('')
|
||||
}
|
||||
|
||||
async function showTotp(entry) {
|
||||
const d = decryptedEntries.value[entry.id]
|
||||
if (!d?.totp_secret) return
|
||||
// Simple TOTP generation
|
||||
try {
|
||||
const secret = d.totp_secret.replace(/^otpauth:\/\/.*secret=/, '').replace(/&.*/, '')
|
||||
totpCode.value = await generateTOTP(secret)
|
||||
showTotpDialog.value = true
|
||||
} catch {
|
||||
toast.add({ severity: 'error', summary: 'TOTP-Fehler', life: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function generateTOTP(secret) {
|
||||
// Base32 decode
|
||||
const base32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
const bits = secret.toUpperCase().replace(/=+$/, '').split('').map(c => {
|
||||
const val = base32.indexOf(c)
|
||||
return val >= 0 ? val.toString(2).padStart(5, '0') : ''
|
||||
}).join('')
|
||||
const bytes = new Uint8Array(bits.match(/.{8}/g).map(b => parseInt(b, 2)))
|
||||
|
||||
const time = Math.floor(Date.now() / 30000)
|
||||
const timeBytes = new Uint8Array(8)
|
||||
let t = time
|
||||
for (let i = 7; i >= 0; i--) { timeBytes[i] = t & 0xff; t >>= 8 }
|
||||
|
||||
const key = await crypto.subtle.importKey('raw', bytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'])
|
||||
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, timeBytes))
|
||||
const offset = sig[sig.length - 1] & 0xf
|
||||
const code = ((sig[offset] & 0x7f) << 24 | sig[offset + 1] << 16 | sig[offset + 2] << 8 | sig[offset + 3]) % 1000000
|
||||
return code.toString().padStart(6, '0')
|
||||
}
|
||||
|
||||
async function importKeePass() {
|
||||
if (!kdbxInput.value?.files?.length || !importPassword.value) return
|
||||
importing.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', kdbxInput.value.files[0])
|
||||
formData.append('password', importPassword.value)
|
||||
const res = await apiClient.post('/passwords/import/keepass', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
const key = await getMasterKey()
|
||||
if (!key) return
|
||||
|
||||
// Create folders
|
||||
const folderMap = {}
|
||||
for (const group of res.data.groups) {
|
||||
const folder = await apiClient.post('/passwords/folders', { name: group.name })
|
||||
folderMap[group.uuid] = folder.data.id
|
||||
}
|
||||
|
||||
// Import entries
|
||||
for (const entry of res.data.entries) {
|
||||
const folderId = entry.group_uuid ? folderMap[entry.group_uuid] : null
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
await apiClient.post('/passwords/entries', {
|
||||
title_encrypted: await encryptText(entry.title, key),
|
||||
url_encrypted: await encryptText(entry.url, key),
|
||||
username_encrypted: await encryptText(entry.username, key),
|
||||
password_encrypted: await encryptText(entry.password, key),
|
||||
notes_encrypted: await encryptText(entry.notes, key),
|
||||
totp_secret_encrypted: await encryptText(entry.totp, key),
|
||||
iv: btoa(String.fromCharCode(...iv)),
|
||||
folder_id: folderId,
|
||||
})
|
||||
}
|
||||
|
||||
showImport.value = false
|
||||
toast.add({ severity: 'success', summary: `${res.data.count} Eintraege importiert`, life: 5000 })
|
||||
await loadFolders()
|
||||
await loadEntries()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Import-Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFolders()
|
||||
await loadEntries()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.view-header h2 { margin: 0; }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.passwords-layout { display: flex; gap: 1rem; }
|
||||
.folders-sidebar { width: 220px; flex-shrink: 0; }
|
||||
.folder-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
|
||||
.folder-item:hover { background: var(--p-surface-100); }
|
||||
.folder-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
|
||||
.shared-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.entries-main { flex: 1; }
|
||||
.search-bar { margin-bottom: 1rem; }
|
||||
.entries-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.entry-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--p-surface-0); border-radius: 6px; cursor: pointer; }
|
||||
.entry-item:hover { background: var(--p-surface-100); }
|
||||
.entry-icon { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: var(--p-primary-50); border-radius: 8px; color: var(--p-primary-color); }
|
||||
.entry-info { flex: 1; }
|
||||
.entry-title { font-weight: 500; font-size: 0.9rem; }
|
||||
.entry-url { font-size: 0.8rem; color: var(--p-text-muted-color); }
|
||||
.entry-actions { display: flex; gap: 0; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.password-field { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 3rem; color: var(--p-text-muted-color); }
|
||||
.totp-display { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1rem; }
|
||||
.totp-code { font-size: 2rem; font-weight: 700; letter-spacing: 0.25em; font-family: monospace; }
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<i class="pi pi-cloud" style="font-size: 2.5rem; color: var(--p-primary-color)"></i>
|
||||
<h1>Mini-Cloud</h1>
|
||||
<p>Registrieren</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleRegister" class="auth-form">
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<InputText
|
||||
id="username"
|
||||
v-model="username"
|
||||
placeholder="Benutzername (min. 3 Zeichen)"
|
||||
autofocus
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">E-Mail (optional)</label>
|
||||
<InputText
|
||||
id="email"
|
||||
v-model="email"
|
||||
placeholder="email@beispiel.de"
|
||||
type="email"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="password"
|
||||
placeholder="Passwort (min. 8 Zeichen)"
|
||||
toggle-mask
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password2">Passwort wiederholen</label>
|
||||
<Password
|
||||
id="password2"
|
||||
v-model="password2"
|
||||
placeholder="Passwort wiederholen"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
label="Registrieren"
|
||||
:loading="loading"
|
||||
fluid
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<router-link to="/login">Bereits ein Konto? Anmelden</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const password2 = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleRegister() {
|
||||
error.value = ''
|
||||
|
||||
if (password.value !== password2.value) {
|
||||
error.value = 'Passwoerter stimmen nicht ueberein'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.register(username.value, password.value, email.value || undefined)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Registrierung fehlgeschlagen'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--p-surface-50);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--p-surface-0);
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--p-text-muted-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form .field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.auth-form .field label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-form button {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--p-primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Einstellungen</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Profil</h3>
|
||||
<div class="settings-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Benutzername:</span>
|
||||
<span>{{ auth.user?.username }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">E-Mail:</span>
|
||||
<span>{{ auth.user?.email || 'Nicht angegeben' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Rolle:</span>
|
||||
<Tag :value="auth.user?.role" :severity="auth.user?.role === 'admin' ? 'danger' : 'info'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Passwort aendern</h3>
|
||||
<form @submit.prevent="handleChangePassword" class="password-form">
|
||||
<div class="field">
|
||||
<label>Aktuelles Passwort</label>
|
||||
<Password v-model="currentPassword" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Neues Passwort</label>
|
||||
<Password v-model="newPassword" toggle-mask fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Neues Passwort wiederholen</label>
|
||||
<Password v-model="newPassword2" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<Message v-if="pwError" severity="error" :closable="false">{{ pwError }}</Message>
|
||||
<Message v-if="pwSuccess" severity="success" :closable="false">{{ pwSuccess }}</Message>
|
||||
<Button type="submit" label="Passwort aendern" :loading="pwLoading" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import Password from 'primevue/password'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const newPassword2 = ref('')
|
||||
const pwError = ref('')
|
||||
const pwSuccess = ref('')
|
||||
const pwLoading = ref(false)
|
||||
|
||||
async function handleChangePassword() {
|
||||
pwError.value = ''
|
||||
pwSuccess.value = ''
|
||||
|
||||
if (newPassword.value !== newPassword2.value) {
|
||||
pwError.value = 'Neue Passwoerter stimmen nicht ueberein'
|
||||
return
|
||||
}
|
||||
|
||||
pwLoading.value = true
|
||||
try {
|
||||
await auth.changePassword(currentPassword.value, newPassword.value)
|
||||
pwSuccess.value = 'Passwort erfolgreich geaendert'
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
newPassword2.value = ''
|
||||
} catch (err) {
|
||||
pwError.value = err.response?.data?.error || 'Fehler beim Aendern des Passworts'
|
||||
} finally {
|
||||
pwLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header h2 { margin: 0 0 1.5rem; }
|
||||
.settings-section {
|
||||
background: var(--p-surface-0);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.settings-section h3 { margin: 0 0 1rem; font-size: 1.125rem; }
|
||||
.settings-info { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.info-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.info-row .label { font-weight: 500; min-width: 120px; }
|
||||
.password-form { max-width: 400px; }
|
||||
.password-form .field { margin-bottom: 1rem; }
|
||||
.password-form .field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="share-container">
|
||||
<div class="share-card">
|
||||
<i class="pi pi-cloud" style="font-size: 2rem; color: var(--p-primary-color)"></i>
|
||||
|
||||
<div v-if="loading" class="share-loading">
|
||||
<i class="pi pi-spin pi-spinner" style="font-size: 1.5rem"></i>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="share-error">
|
||||
<i class="pi pi-times-circle" style="font-size: 2rem; color: var(--p-red-500)"></i>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="fileInfo" class="share-info">
|
||||
<h2>{{ fileInfo.name }}</h2>
|
||||
<p class="file-size" v-if="fileInfo.size">{{ formatSize(fileInfo.size) }}</p>
|
||||
|
||||
<div v-if="fileInfo.has_password && !authenticated" class="password-form">
|
||||
<p>Diese Datei ist passwortgeschuetzt.</p>
|
||||
<div class="field">
|
||||
<Password v-model="password" placeholder="Passwort eingeben" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<Message v-if="authError" severity="error" :closable="false">{{ authError }}</Message>
|
||||
<Button label="Entsperren" @click="verifyPassword" :loading="verifying" fluid />
|
||||
</div>
|
||||
|
||||
<div v-else class="download-section">
|
||||
<Button
|
||||
label="Herunterladen"
|
||||
icon="pi pi-download"
|
||||
size="large"
|
||||
@click="downloadFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import Button from 'primevue/button'
|
||||
import Password from 'primevue/password'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
const route = useRoute()
|
||||
const token = route.params.token
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const fileInfo = ref(null)
|
||||
const password = ref('')
|
||||
const authenticated = ref(false)
|
||||
const authError = ref('')
|
||||
const verifying = ref(false)
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
let size = bytes
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
|
||||
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
async function loadInfo() {
|
||||
try {
|
||||
const res = await axios.get(`/api/share/${token}/info`)
|
||||
fileInfo.value = res.data
|
||||
if (!res.data.has_password) authenticated.value = true
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Link nicht gefunden oder abgelaufen'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPassword() {
|
||||
authError.value = ''
|
||||
verifying.value = true
|
||||
try {
|
||||
await axios.post(`/api/share/${token}/verify`, { password: password.value })
|
||||
authenticated.value = true
|
||||
} catch (err) {
|
||||
authError.value = err.response?.data?.error || 'Falsches Passwort'
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
let url = `/api/share/${token}/download`
|
||||
if (fileInfo.value?.has_password && password.value) {
|
||||
url += `?password=${encodeURIComponent(password.value)}`
|
||||
}
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
onMounted(loadInfo)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.share-container {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--p-surface-50);
|
||||
}
|
||||
.share-card {
|
||||
background: var(--p-surface-0); border-radius: 12px; padding: 3rem;
|
||||
text-align: center; max-width: 450px; width: 100%;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.share-card h2 { margin: 1rem 0 0.25rem; font-size: 1.25rem; }
|
||||
.file-size { color: var(--p-text-muted-color); margin-bottom: 1.5rem; }
|
||||
.password-form { text-align: left; margin-top: 1.5rem; }
|
||||
.password-form p { margin-bottom: 1rem; color: var(--p-text-muted-color); }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.download-section { margin-top: 1.5rem; }
|
||||
.share-loading, .share-error { margin-top: 1.5rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3100,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/dav': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ical': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user