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:
2026-03-13 05:09:05 +00:00
parent 0768696ead
commit bd0051561a
4 changed files with 236 additions and 9 deletions

View File

@@ -19,11 +19,10 @@ class ApiClient {
private async request<T>( private async request<T>(
method: string, method: string,
endpoint: string, endpoint: string,
data?: unknown data?: unknown,
options?: { headers?: Record<string, string> }
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
const headers: Record<string, string> = { const headers: Record<string, string> = {}
'Content-Type': 'application/json',
}
const token = this.getToken() const token = this.getToken()
if (token) { if (token) {
@@ -35,8 +34,24 @@ class ApiClient {
headers, 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) 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) const response = await fetch(`${this.baseUrl}${endpoint}`, config)
@@ -90,8 +105,8 @@ class ApiClient {
return this.request<T>('GET', endpoint) return this.request<T>('GET', endpoint)
} }
post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> { post<T>(endpoint: string, data?: unknown, options?: { headers?: Record<string, string> }): Promise<ApiResponse<T>> {
return this.request<T>('POST', endpoint, data) return this.request<T>('POST', endpoint, data, options)
} }
put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> { put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {

View File

@@ -76,7 +76,16 @@ function isActive(href: string) {
> >
<!-- Logo --> <!-- Logo -->
<div class="h-16 flex items-center px-6 border-b border-gray-200 dark:border-gray-700"> <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> </div>
<!-- Navigation --> <!-- Navigation -->

View File

@@ -11,8 +11,17 @@ export interface User {
org_id: string 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', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null) const user = ref<User | null>(null)
const organization = ref<Organization | null>(null)
const accessToken = ref<string | null>(localStorage.getItem('accessToken')) const accessToken = ref<string | null>(localStorage.getItem('accessToken'))
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken')) const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
const orgSlug = ref<string>(localStorage.getItem('orgSlug') || '') const orgSlug = ref<string>(localStorage.getItem('orgSlug') || '')
@@ -25,6 +34,8 @@ export const useAuthStore = defineStore('auth', () => {
const canManageUsers = computed(() => isChef.value || isDisponent.value) const canManageUsers = computed(() => isChef.value || isDisponent.value)
const canManageOrders = 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 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) { function setTokens(access: string, refresh: string) {
accessToken.value = access accessToken.value = access
@@ -40,6 +51,7 @@ export const useAuthStore = defineStore('auth', () => {
function clearAuth() { function clearAuth() {
user.value = null user.value = null
organization.value = null
accessToken.value = null accessToken.value = null
refreshToken.value = null refreshToken.value = null
localStorage.removeItem('accessToken') localStorage.removeItem('accessToken')
@@ -85,11 +97,28 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const response = await api.get('/auth/me') const response = await api.get('/auth/me')
user.value = response.data.user user.value = response.data.user
// Also fetch organization info
await fetchOrganization()
} catch { } catch {
clearAuth() 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() { async function refreshAccessToken() {
if (!refreshToken.value) { if (!refreshToken.value) {
throw new Error('No refresh token') throw new Error('No refresh token')
@@ -107,6 +136,7 @@ export const useAuthStore = defineStore('auth', () => {
return { return {
user, user,
organization,
accessToken, accessToken,
refreshToken, refreshToken,
orgSlug, orgSlug,
@@ -118,11 +148,15 @@ export const useAuthStore = defineStore('auth', () => {
canManageUsers, canManageUsers,
canManageOrders, canManageOrders,
fullName, fullName,
orgName,
orgLogo,
login, login,
register, register,
logout, logout,
fetchCurrentUser, fetchCurrentUser,
fetchOrganization,
refreshAccessToken, refreshAccessToken,
setOrgSlug setOrgSlug,
setOrgLogo
} }
}) })

View File

@@ -14,6 +14,14 @@ const message = ref('')
const error = ref('') const error = ref('')
const showSecuritySettings = ref(false) 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 // App lock status
const lockMethod = ref<string | null>(null) const lockMethod = ref<string | null>(null)
@@ -23,8 +31,91 @@ onMounted(() => {
if (authStore.user?.email) { if (authStore.user?.email) {
localStorage.setItem('userEmail', 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(() => { const lockStatusText = computed(() => {
if (!lockMethod.value || lockMethod.value === 'none') return 'Deaktiviert' if (!lockMethod.value || lockMethod.value === 'none') return 'Deaktiviert'
if (lockMethod.value === 'biometric') return 'Fingerabdruck / Face ID' if (lockMethod.value === 'biometric') return 'Fingerabdruck / Face ID'
@@ -96,6 +187,84 @@ function handleSecurityClose() {
<label class="block text-sm text-gray-500">Rolle</label> <label class="block text-sm text-gray-500">Rolle</label>
<p class="font-medium capitalize">{{ authStore.user?.role }}</p> <p class="font-medium capitalize">{{ authStore.user?.role }}</p>
</div> </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>
</div> </div>