feat: Add organization logo support
- Add Organization interface to auth store - Fetch org info including logo_url on login - Update sidebar to show org logo or name - Add logo upload in Settings (chef only) - Support FormData uploads in API client
This commit is contained in:
@@ -19,11 +19,10 @@ class ApiClient {
|
||||
private async request<T>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
data?: unknown
|
||||
data?: unknown,
|
||||
options?: { headers?: Record<string, string> }
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
const token = this.getToken()
|
||||
if (token) {
|
||||
@@ -35,8 +34,24 @@ class ApiClient {
|
||||
headers,
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// Handle FormData (multipart/form-data) vs JSON
|
||||
if (data instanceof FormData) {
|
||||
config.body = data
|
||||
// Don't set Content-Type for FormData - browser will set it with boundary
|
||||
} else if (data) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
config.body = JSON.stringify(data)
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
// Merge custom headers
|
||||
if (options?.headers) {
|
||||
Object.assign(headers, options.headers)
|
||||
// Remove Content-Type if it's multipart (let browser handle it)
|
||||
if (options.headers['Content-Type']?.includes('multipart')) {
|
||||
delete headers['Content-Type']
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, config)
|
||||
@@ -90,8 +105,8 @@ class ApiClient {
|
||||
return this.request<T>('GET', endpoint)
|
||||
}
|
||||
|
||||
post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('POST', endpoint, data)
|
||||
post<T>(endpoint: string, data?: unknown, options?: { headers?: Record<string, string> }): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('POST', endpoint, data, options)
|
||||
}
|
||||
|
||||
put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||
|
||||
@@ -76,7 +76,16 @@ function isActive(href: string) {
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="h-16 flex items-center px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<span class="text-2xl font-bold text-primary-600">🔐 SeCu</span>
|
||||
<template v-if="authStore.orgLogo">
|
||||
<img
|
||||
:src="authStore.orgLogo"
|
||||
:alt="authStore.orgName"
|
||||
class="h-10 max-w-[180px] object-contain"
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-2xl font-bold text-primary-600">🔐 {{ authStore.orgName || 'SeCu' }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
|
||||
@@ -11,8 +11,17 @@ export interface User {
|
||||
org_id: string
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo_url: string | null
|
||||
settings?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const organization = ref<Organization | null>(null)
|
||||
const accessToken = ref<string | null>(localStorage.getItem('accessToken'))
|
||||
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
|
||||
const orgSlug = ref<string>(localStorage.getItem('orgSlug') || '')
|
||||
@@ -25,6 +34,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const canManageUsers = computed(() => isChef.value || isDisponent.value)
|
||||
const canManageOrders = computed(() => isChef.value || isDisponent.value)
|
||||
const fullName = computed(() => user.value ? `${user.value.first_name} ${user.value.last_name}` : '')
|
||||
const orgName = computed(() => organization.value?.name || '')
|
||||
const orgLogo = computed(() => organization.value?.logo_url || null)
|
||||
|
||||
function setTokens(access: string, refresh: string) {
|
||||
accessToken.value = access
|
||||
@@ -40,6 +51,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
function clearAuth() {
|
||||
user.value = null
|
||||
organization.value = null
|
||||
accessToken.value = null
|
||||
refreshToken.value = null
|
||||
localStorage.removeItem('accessToken')
|
||||
@@ -85,11 +97,28 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
try {
|
||||
const response = await api.get('/auth/me')
|
||||
user.value = response.data.user
|
||||
// Also fetch organization info
|
||||
await fetchOrganization()
|
||||
} catch {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOrganization() {
|
||||
try {
|
||||
const response = await api.get('/organizations/current')
|
||||
organization.value = response.data.organization
|
||||
} catch {
|
||||
// Ignore errors - org info is optional
|
||||
}
|
||||
}
|
||||
|
||||
function setOrgLogo(logoUrl: string | null) {
|
||||
if (organization.value) {
|
||||
organization.value.logo_url = logoUrl
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (!refreshToken.value) {
|
||||
throw new Error('No refresh token')
|
||||
@@ -107,6 +136,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
return {
|
||||
user,
|
||||
organization,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
orgSlug,
|
||||
@@ -118,11 +148,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
canManageUsers,
|
||||
canManageOrders,
|
||||
fullName,
|
||||
orgName,
|
||||
orgLogo,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
fetchCurrentUser,
|
||||
fetchOrganization,
|
||||
refreshAccessToken,
|
||||
setOrgSlug
|
||||
setOrgSlug,
|
||||
setOrgLogo
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,6 +14,14 @@ const message = ref('')
|
||||
const error = ref('')
|
||||
const showSecuritySettings = ref(false)
|
||||
|
||||
// Logo upload
|
||||
const logoFile = ref<File | null>(null)
|
||||
const logoPreview = ref<string | null>(null)
|
||||
const logoLoading = ref(false)
|
||||
const logoMessage = ref('')
|
||||
const logoError = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// App lock status
|
||||
const lockMethod = ref<string | null>(null)
|
||||
|
||||
@@ -23,8 +31,91 @@ onMounted(() => {
|
||||
if (authStore.user?.email) {
|
||||
localStorage.setItem('userEmail', authStore.user.email)
|
||||
}
|
||||
// Set logo preview from current org logo
|
||||
if (authStore.orgLogo) {
|
||||
logoPreview.value = authStore.orgLogo
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogoSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
|
||||
if (!file) return
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
logoError.value = 'Nur JPG, PNG, WebP oder SVG erlaubt'
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
logoError.value = 'Datei zu groß (max 5MB)'
|
||||
return
|
||||
}
|
||||
|
||||
logoFile.value = file
|
||||
logoError.value = ''
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
logoPreview.value = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
async function uploadLogo() {
|
||||
if (!logoFile.value) return
|
||||
|
||||
logoLoading.value = true
|
||||
logoError.value = ''
|
||||
logoMessage.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('logo', logoFile.value)
|
||||
|
||||
const response = await api.post('/uploads/logo', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
|
||||
logoMessage.value = 'Logo erfolgreich hochgeladen'
|
||||
logoFile.value = null
|
||||
|
||||
// Update store with new logo URL
|
||||
authStore.setOrgLogo(response.data.logo_url)
|
||||
logoPreview.value = response.data.logo_url
|
||||
} catch (e) {
|
||||
logoError.value = e instanceof Error ? e.message : 'Fehler beim Hochladen'
|
||||
} finally {
|
||||
logoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLogo() {
|
||||
if (!confirm('Logo wirklich löschen?')) return
|
||||
|
||||
logoLoading.value = true
|
||||
logoError.value = ''
|
||||
|
||||
try {
|
||||
await api.delete('/uploads/logo')
|
||||
logoMessage.value = 'Logo gelöscht'
|
||||
logoPreview.value = null
|
||||
logoFile.value = null
|
||||
authStore.setOrgLogo(null)
|
||||
} catch (e) {
|
||||
logoError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
|
||||
} finally {
|
||||
logoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const lockStatusText = computed(() => {
|
||||
if (!lockMethod.value || lockMethod.value === 'none') return 'Deaktiviert'
|
||||
if (lockMethod.value === 'biometric') return 'Fingerabdruck / Face ID'
|
||||
@@ -96,6 +187,84 @@ function handleSecurityClose() {
|
||||
<label class="block text-sm text-gray-500">Rolle</label>
|
||||
<p class="font-medium capitalize">{{ authStore.user?.role }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-500">Organisation</label>
|
||||
<p class="font-medium">{{ authStore.orgName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organization Logo (Chef only) -->
|
||||
<div v-if="authStore.isChef" class="card">
|
||||
<h2 class="text-lg font-semibold mb-4">🏢 Firmenlogo</h2>
|
||||
<p class="text-gray-600 text-sm mb-4">
|
||||
Lade ein Logo für deine Organisation hoch. Deine Mitarbeiter sehen es in der Sidebar.
|
||||
</p>
|
||||
|
||||
<div class="flex items-start gap-6">
|
||||
<!-- Logo Preview -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-32 h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center overflow-hidden bg-gray-50 dark:bg-gray-700">
|
||||
<img
|
||||
v-if="logoPreview"
|
||||
:src="logoPreview"
|
||||
alt="Logo Vorschau"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
>
|
||||
<span v-else class="text-4xl text-gray-400">🏢</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Controls -->
|
||||
<div class="flex-1 space-y-4">
|
||||
<div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/svg+xml"
|
||||
class="hidden"
|
||||
@change="handleLogoSelect"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
📁 Datei auswählen
|
||||
</button>
|
||||
<span v-if="logoFile" class="ml-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ logoFile.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
JPG, PNG, WebP oder SVG • max. 5MB • empfohlen: 200x80px
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="logoFile"
|
||||
type="button"
|
||||
:disabled="logoLoading"
|
||||
class="btn btn-primary"
|
||||
@click="uploadLogo"
|
||||
>
|
||||
{{ logoLoading ? 'Hochladen...' : '⬆️ Hochladen' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="authStore.orgLogo"
|
||||
type="button"
|
||||
:disabled="logoLoading"
|
||||
class="btn btn-secondary text-red-600 hover:bg-red-50"
|
||||
@click="deleteLogo"
|
||||
>
|
||||
🗑️ Logo löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="logoError" class="text-red-600 text-sm">{{ logoError }}</div>
|
||||
<div v-if="logoMessage" class="text-green-600 text-sm">{{ logoMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user