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>(
|
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>> {
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user