fix(frontend): E2E-Test-Suite vollständig grün machen

- Robuster gegen undefined API-Daten in Mail, ContactDetail, ContactsList, Settings, Sidebar
- E2E-Mocks korrigiert für Kontakt-Detail, Mail-Liste/Folders und Plugin-Toggle
- Auth-Store mit persist-Middleware für E2E-Login
- test-results/ in .gitignore aufgenommen

Playwright E2E: 34/34 passed
This commit is contained in:
Agent Zero
2026-08-07 22:03:11 +02:00
parent 8d2aa58665
commit fdabd2e74c
18 changed files with 368 additions and 401 deletions
+1
View File
@@ -7,3 +7,4 @@ dist/
coverage/ coverage/
.vitest/ .vitest/
*.log *.log
test-results/
+250 -306
View File
@@ -66,37 +66,62 @@ export const MOCK_MAIL_ACCOUNTS = [
smtp_host: 'smtp.test.test', smtp_host: 'smtp.test.test',
smtp_port: 587, smtp_port: 587,
is_shared: false, is_shared: false,
is_active: true,
}, },
]; ];
export const MOCK_MAIL_FOLDERS = [ export const MOCK_MAIL_FOLDERS = [
{ id: 'folder-001', name: 'INBOX', unread_count: 3, account_id: 'acc-001' }, { id: 'folder-001', name: 'INBOX', imap_name: 'INBOX', parent_id: null, unread_count: 3, total_count: 2, account_id: 'acc-001' },
{ id: 'folder-002', name: 'Sent', unread_count: 0, account_id: 'acc-001' }, { id: 'folder-002', name: 'Sent', imap_name: 'Sent', parent_id: null, unread_count: 0, total_count: 0, account_id: 'acc-001' },
{ id: 'folder-003', name: 'Drafts', unread_count: 0, account_id: 'acc-001' }, { id: 'folder-003', name: 'Drafts', imap_name: 'Drafts', parent_id: null, unread_count: 0, total_count: 0, account_id: 'acc-001' },
]; ];
export const MOCK_MAILS = [ export const MOCK_MAILS = [
{ {
id: 'mail-001', id: 'mail-001',
folder_id: 'folder-001',
account_id: 'acc-001',
subject: 'Welcome to LeoCRM', subject: 'Welcome to LeoCRM',
from: 'noreply@leocrm.test', from_address: 'noreply@leocrm.test',
to: 'test@leocrm.test', from_name: 'LeoCRM',
to_addresses: ['test@leocrm.test'],
cc_addresses: [],
bcc_addresses: [],
date: '2026-07-23T10:00:00Z', date: '2026-07-23T10:00:00Z',
body_text: 'Welcome to LeoCRM! Your account is ready.', body_text: 'Welcome to LeoCRM! Your account is ready.',
is_read: false, body_html: null,
flags: [], sanitized_html: null,
is_seen: false,
is_flagged: false,
flag_type: null,
is_draft: false,
is_answered: false,
has_attachments: false,
attachments: [], attachments: [],
labels: [],
}, },
{ {
id: 'mail-002', id: 'mail-002',
folder_id: 'folder-001',
account_id: 'acc-001',
subject: 'Meeting Tomorrow', subject: 'Meeting Tomorrow',
from: 'boss@leocrm.test', from_address: 'boss@leocrm.test',
to: 'test@leocrm.test', from_name: 'Boss',
to_addresses: ['test@leocrm.test'],
cc_addresses: [],
bcc_addresses: [],
date: '2026-07-23T09:00:00Z', date: '2026-07-23T09:00:00Z',
body_text: 'Don\'t forget the meeting tomorrow at 10 AM.', body_text: 'Don\'t forget the meeting tomorrow at 10 AM.',
is_read: true, body_html: null,
flags: [], sanitized_html: null,
is_seen: true,
is_flagged: false,
flag_type: null,
is_draft: false,
is_answered: false,
has_attachments: false,
attachments: [], attachments: [],
labels: [],
}, },
]; ];
@@ -188,13 +213,38 @@ export const MOCK_SEARCH_RESULTS = {
* Call this in beforeEach to set up a fully mocked environment. * Call this in beforeEach to set up a fully mocked environment.
*/ */
export async function setupApiMocks(page: Page) { export async function setupApiMocks(page: Page) {
// Auth: login await page.route('**/api/v1/**', (route) => {
await page.route('**/api/v1/auth/login', (route) => { const req = route.request();
route.fulfill({ const url = new URL(req.url());
status: 200, const method = req.method();
contentType: 'application/json', const pathname = url.pathname;
body: JSON.stringify({
user: { // Auth endpoints
if (pathname === '/api/v1/auth/login') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: {
id: TEST_USER.id,
email: TEST_USER.email,
first_name: TEST_USER.firstName,
last_name: TEST_USER.lastName,
role: TEST_USER.role,
tenants: [TEST_TENANT],
permissions: ['*'],
is_system_admin: true,
},
csrf_token: 'mock-csrf-token',
}),
});
}
if (pathname === '/api/v1/auth/me') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: TEST_USER.id, id: TEST_USER.id,
email: TEST_USER.email, email: TEST_USER.email,
first_name: TEST_USER.firstName, first_name: TEST_USER.firstName,
@@ -203,320 +253,214 @@ export async function setupApiMocks(page: Page) {
tenants: [TEST_TENANT], tenants: [TEST_TENANT],
permissions: ['*'], permissions: ['*'],
is_system_admin: true, is_system_admin: true,
},
csrf_token: 'mock-csrf-token',
}),
});
});
// Auth: me (current user)
await page.route('**/api/v1/auth/me', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: TEST_USER.id,
email: TEST_USER.email,
first_name: TEST_USER.firstName,
last_name: TEST_USER.lastName,
role: TEST_USER.role,
tenants: [TEST_TENANT],
permissions: ['*'],
is_system_admin: true,
}),
});
});
// Auth: logout
await page.route('**/api/v1/auth/logout', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
// Contacts list
await page.route('**/api/v1/contacts*', (route) => {
const url = route.request().url();
if (url.includes('/contact_persons')) {
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
return;
}
if (route.request().method() === 'GET') {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_CONTACTS),
});
} else if (route.request().method() === 'POST') {
const body = route.request().postDataJSON();
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'contact-new',
contact_persons: [],
displayname: body?.name || `${body?.firstname || ''} ${body?.surname || ''}`.trim(),
...body,
}), }),
}); });
} else {
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
} }
});
// Contact detail if (pathname === '/api/v1/auth/logout') {
await page.route('**/api/v1/contacts/*', (route) => { return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
if (route.request().method() === 'GET') {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_CONTACTS.items[0]),
});
} else if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON();
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...MOCK_CONTACTS.items[0], ...body }),
});
} else if (route.request().method() === 'DELETE') {
route.fulfill({ status: 204, body: '' });
} else {
route.continue();
} }
});
// Mail signatures // Contacts
await page.route('**/api/v1/mail/signatures*', (route) => { if (pathname.startsWith('/api/v1/contacts')) {
if (route.request().method() === 'GET') { if (pathname.includes('/contact_persons')) {
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
} else { }
route.continue(); // Contact detail: /api/v1/contacts/<id>
const contactsDetailMatch = pathname.match(/^\/api\/v1\/contacts\/([^/]+)$/);
if (contactsDetailMatch && method === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...MOCK_CONTACTS.items[0], id: contactsDetailMatch[1], contact_persons: [] }),
});
}
if (method === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_CONTACTS),
});
}
if (method === 'POST') {
const body = req.postDataJSON() || {};
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'contact-new',
contact_persons: [],
displayname: body.name || `${body.firstname || ''} ${body.surname || ''}`.trim(),
...body,
}),
});
}
if (method === 'PUT') {
const body = req.postDataJSON() || {};
const id = pathname.split('/').pop() || 'contact-001';
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...MOCK_CONTACTS.items[0], id, ...body }),
});
}
if (method === 'DELETE') {
return route.fulfill({ status: 204, body: '' });
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
} }
});
// Mail accounts // Mail
await page.route('**/api/v1/mail/accounts*', (route) => { if (pathname === '/api/v1/mail/accounts') {
if (route.request().method() === 'GET') { if (method === 'GET') {
route.fulfill({ return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_MAIL_ACCOUNTS) });
status: 200, }
contentType: 'application/json', const body = req.postDataJSON() || {};
body: JSON.stringify(MOCK_MAIL_ACCOUNTS), return route.fulfill({
});
} else if (route.request().method() === 'POST') {
const body = route.request().postDataJSON();
route.fulfill({
status: 201, status: 201,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ id: 'acc-new', display_name: body.display_name || body.email, ...body }),
});
}
if (pathname === '/api/v1/mail/folders') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_MAIL_FOLDERS) });
}
if (pathname === '/api/v1/mail') {
// fetchMails — /api/v1/mail?folder_id=...
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ mails: MOCK_MAILS, total: MOCK_MAILS.length, page: 1, page_size: 25 }),
});
}
if (pathname === '/api/v1/mail/search') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ mails: MOCK_MAILS, total: MOCK_MAILS.length, page: 1, page_size: 25 }),
});
}
if (pathname.startsWith('/api/v1/mail/signatures')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
}
const singleMailMatch = pathname.match(/^\/api\/v1\/mail\/([^/]+)$/);
if (singleMailMatch && method === 'GET') {
const mailId = singleMailMatch[1];
const mail = MOCK_MAILS.find((m) => m.id === mailId) || MOCK_MAILS[0];
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mail) });
}
if (pathname.startsWith('/api/v1/mail/')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
}
// DMS
if (pathname === '/api/v1/dms/folders') {
if (method === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DMS_FOLDERS) });
}
const body = req.postDataJSON() || {};
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 'folder-new', name: body.name, parent_id: body.parent_id ?? null }),
});
}
if (pathname === '/api/v1/dms/files') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DMS_FILES) });
}
if (pathname.startsWith('/api/v1/dms/')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
}
// Calendar
if (pathname === '/api/v1/calendars') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CALENDARS) });
}
if (pathname === '/api/v1/calendar/entries') {
if (method === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CALENDAR_ENTRIES) });
}
const body = req.postDataJSON() || {};
return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: 'entry-new', ...body }) });
}
if (pathname === '/api/v1/calendar/kanban') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify({
id: 'acc-new', open: MOCK_CALENDAR_ENTRIES,
display_name: body?.display_name || body?.email, in_progress: [],
...body, done: [],
cancelled: [],
}), }),
}); });
} else {
route.continue();
} }
});
// Mail folders (API calls /mail/folders?account_id=...) // Plugins
await page.route('**/api/v1/mail/folders*', (route) => { if (pathname === '/api/v1/plugins/active-manifests' || pathname === '/api/v1/plugins') {
if (route.request().method() === 'GET') { if (method === 'GET') {
route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify(MOCK_MAIL_FOLDERS), body: JSON.stringify({ plugins: MOCK_PLUGINS, total: MOCK_PLUGINS.length }),
}); });
} else { }
route.continue(); return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
} }
});
// Mail list (API calls /mail/mails?account_id=...&folder_id=...) const pluginToggleMatch = pathname.match(/^\/api\/v1\/plugins\/([^/]+)\/(activate|deactivate)$/);
await page.route('**/api/v1/mail/mails*', (route) => { if (pluginToggleMatch) {
if (route.request().method() === 'GET') { const [, slug, action] = pluginToggleMatch;
route.fulfill({ const plugin = MOCK_PLUGINS.find((p) => p.name === slug);
status: 200, if (plugin) {
contentType: 'application/json', plugin.active = action === 'activate';
body: JSON.stringify({ items: MOCK_MAILS, total: MOCK_MAILS.length }), plugin.status = action === 'activate' ? 'active' : 'inactive';
}); }
} else { return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
route.continue();
} }
});
// Mail detail // Search
await page.route('**/api/v1/mail/*', (route) => { if (pathname === '/api/v1/search') {
if (route.request().method() === 'GET') { if (method === 'POST') {
route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify(MOCK_MAILS[0]), body: JSON.stringify({
}); results: [
} else { { type: 'contact', id: 'contact-001', name: 'TechCorp GmbH', description: 'Company in Berlin', url: '/contacts/contact-001' },
route.continue(); { type: 'mail', id: 'mail-001', name: 'Welcome to LeoCRM', description: 'Welcome email', url: '/mail/mail-001' },
],
facets: {},
summary: '2 results',
}),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ results: [], facets: {}, summary: '' }) });
} }
});
// DMS folders // User preferences
await page.route('**/api/v1/dms/folders*', (route) => { if (pathname.startsWith('/api/v1/user/preferences')) {
if (route.request().method() === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_DMS_FOLDERS),
});
} else if (route.request().method() === 'POST') {
const body = route.request().postDataJSON();
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 'folder-new', name: body?.name, parent_id: body?.parent_id ?? null }),
});
} else {
route.continue();
} }
});
// DMS files // Fallback: empty array/object for any other API call
await page.route('**/api/v1/dms/files*', (route) => { return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
if (route.request().method() === 'GET') {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_DMS_FILES),
});
} else {
route.continue();
}
});
// DMS shared files
await page.route('**/api/v1/dms/shared*', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
});
// Calendar list (API calls /calendars, not /calendar/calendars)
await page.route('**/api/v1/calendars*', (route) => {
if (route.request().method() === 'GET') {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_CALENDARS),
});
} else {
route.continue();
}
});
// Calendar entries
await page.route('**/api/v1/calendar/entries*', (route) => {
if (route.request().method() === 'GET') {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_CALENDAR_ENTRIES),
});
} else if (route.request().method() === 'POST') {
const body = route.request().postDataJSON();
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 'entry-new', ...body }),
});
} else {
route.continue();
}
});
// Calendar kanban
await page.route('**/api/v1/calendar/kanban*', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
open: MOCK_CALENDAR_ENTRIES,
in_progress: [],
done: [],
cancelled: [],
}),
});
});
// Plugins
await page.route('**/api/v1/plugins*', (route) => {
const url = route.request().url();
if (url.includes('/active-manifests')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ plugins: MOCK_PLUGINS, total: MOCK_PLUGINS.length }),
});
return;
}
if (route.request().method() === 'GET') {
// GET /plugins returns { plugins: Plugin[], total: number }
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ plugins: MOCK_PLUGINS, total: MOCK_PLUGINS.length }),
});
} else if (route.request().method() === 'POST') {
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
} else {
route.continue();
}
});
// Plugin activate/deactivate
await page.route('**/api/v1/plugins/*/activate', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
await page.route('**/api/v1/plugins/*/deactivate', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
// Search (API uses POST to /search)
await page.route('**/api/v1/search*', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: [
{ type: 'contact', id: 'contact-001', name: 'TechCorp GmbH', description: 'Company in Berlin', url: '/contacts/contact-001' },
{ type: 'mail', id: 'mail-001', name: 'Welcome to LeoCRM', description: 'Welcome email', url: '/mail/mail-001' },
], facets: {}, summary: '2 results' }),
});
} else if (route.request().method() === 'GET') {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: [], facets: {}, summary: '' }),
});
} else {
route.continue();
}
});
// User preferences
await page.route('**/api/v1/user/preferences*', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
// Generic catch-all for other API endpoints
await page.route('**/api/v1/**', (route) => {
if (!route.request().url().includes('/auth/') &&
!route.request().url().includes('/contacts') &&
!route.request().url().includes('/mail/') &&
!route.request().url().includes('/dms/') &&
!route.request().url().includes('/calendar/') &&
!route.request().url().includes('/plugins') &&
!route.request().url().includes('/search')) {
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
} else {
route.continue();
}
}); });
} }
/**
* Perform login via the login form.}
/** /**
* Perform login via the login form. * Perform login via the login form.
* Assumes API mocks are already set up. * Assumes API mocks are already set up.
+7 -6
View File
@@ -11,14 +11,15 @@ test.describe('Mail E2E', () => {
await navigateTo(page, '/mail'); await navigateTo(page, '/mail');
// Mail page should load - check for folder tree or loading state // Mail page should load - check for folder tree or loading state
await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]')).toBeVisible({ timeout: 15_000 }); // .first() because the responsive layout renders the tree in both desktop and mobile panes.
await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]').first()).toBeVisible({ timeout: 15_000 });
}); });
test('mail list shows emails when folder is selected', async ({ page }) => { test('mail list shows emails when folder is selected', async ({ page }) => {
await navigateTo(page, '/mail'); await navigateTo(page, '/mail');
// Wait for folder tree // Wait for folder tree
await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]')).toBeVisible({ timeout: 15_000 }); await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]').first()).toBeVisible({ timeout: 15_000 });
// Click on first folder if available // Click on first folder if available
const folder = page.locator('[data-testid^="folder-"]').first(); const folder = page.locator('[data-testid^="folder-"]').first();
@@ -26,7 +27,7 @@ test.describe('Mail E2E', () => {
await folder.click(); await folder.click();
// Mail list should appear // Mail list should appear
await expect(page.locator('[data-testid="mail-list"], [data-testid="mail-list-loading"], [data-testid="mail-list-empty"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('[data-testid="mail-list"], [data-testid="mail-list-loading"], [data-testid="mail-list-empty"]').first()).toBeVisible({ timeout: 10_000 });
} }
}); });
@@ -34,14 +35,14 @@ test.describe('Mail E2E', () => {
await navigateTo(page, '/mail'); await navigateTo(page, '/mail');
// Wait for folder tree and click first folder // Wait for folder tree and click first folder
await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]')).toBeVisible({ timeout: 15_000 }); await expect(page.locator('[data-testid="folder-tree-list"], [data-testid="folder-tree-loading"], [data-testid="folder-tree-empty"]').first()).toBeVisible({ timeout: 15_000 });
const folder = page.locator('[data-testid^="folder-"]').first(); const folder = page.locator('[data-testid^="folder-"]').first();
if (await folder.isVisible({ timeout: 5_000 })) { if (await folder.isVisible({ timeout: 5_000 })) {
await folder.click(); await folder.click();
// Wait for mail list // Wait for mail list
await expect(page.locator('[data-testid="mail-list"], [data-testid="mail-list-loading"], [data-testid="mail-list-empty"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('[data-testid="mail-list"], [data-testid="mail-list-loading"], [data-testid="mail-list-empty"]').first()).toBeVisible({ timeout: 10_000 });
// Click first mail if available // Click first mail if available
const mailItem = page.locator('[data-testid^="mail-item-"]').first(); const mailItem = page.locator('[data-testid^="mail-item-"]').first();
@@ -49,7 +50,7 @@ test.describe('Mail E2E', () => {
await mailItem.click(); await mailItem.click();
// Mail detail should appear // Mail detail should appear
await expect(page.locator('[data-testid="mail-detail"], [data-testid="mail-detail-loading"], [data-testid="mail-detail-empty"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('[data-testid="mail-detail"], [data-testid="mail-detail-loading"], [data-testid="mail-detail-empty"]').first()).toBeVisible({ timeout: 10_000 });
} }
} }
}); });
+1 -1
View File
@@ -62,6 +62,6 @@ test.describe('Plugin Toggle E2E', () => {
await expect(page.locator('[data-testid="settings-plugins-page"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('[data-testid="settings-plugins-page"]')).toBeVisible({ timeout: 10_000 });
// Upload and URL install options should be present // Upload and URL install options should be present
await expect(page.locator('[data-testid="plugin-zip-upload-input"], [data-testid="plugin-upload-btn"], [data-testid="plugin-url-input"], [data-testid="plugin-install-url-btn"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('[data-testid="plugin-zip-upload-input"], [data-testid="plugin-upload-btn"], [data-testid="plugin-url-input"], [data-testid="plugin-install-url-btn"]').first()).toBeVisible({ timeout: 10_000 });
}); });
}); });
+1 -1
View File
@@ -25,7 +25,7 @@ test.describe('Global Search E2E', () => {
await page.locator('[data-testid="search-submit-btn"]').click(); await page.locator('[data-testid="search-submit-btn"]').click();
// Wait for results // Wait for results
await expect(page.locator('[data-testid="search-results-list"], [data-testid="search-results-all"]')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('[data-testid="search-results-list"], [data-testid="search-results-all"]').first()).toBeVisible({ timeout: 10_000 });
// Should show at least one result (or empty state if mock doesn't match exactly) // Should show at least one result (or empty state if mock doesn't match exactly)
const resultItem = page.locator('[data-testid^="search-result-"]').first(); const resultItem = page.locator('[data-testid^="search-result-"]').first();
@@ -33,6 +33,7 @@ export interface ContactDetailProps {
loading?: boolean; loading?: boolean;
onEdit?: () => void; onEdit?: () => void;
onDeleted: () => void; onDeleted: () => void;
dataTestId?: string;
} }
function Field({ label, value, fieldName }: { label: string; value?: string | null; fieldName?: string }) { function Field({ label, value, fieldName }: { label: string; value?: string | null; fieldName?: string }) {
@@ -156,7 +157,7 @@ function getIcon(name: string): React.ReactNode {
return Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.FileText className="h-4 w-4" />; return Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.FileText className="h-4 w-4" />;
} }
export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDetailProps) { export function ContactDetail({ contact, loading, onEdit, onDeleted, dataTestId = 'contact-detail' }: ContactDetailProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const deleteMutation = useDeleteUnifiedContact(); const deleteMutation = useDeleteUnifiedContact();
@@ -173,9 +174,9 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
const [activeTab, setActiveTab] = useState('details'); const [activeTab, setActiveTab] = useState('details');
const manifests = usePluginStore(s => s.manifests); const manifests = usePluginStore(s => s.manifests);
const pluginTabs = useMemo( const pluginTabs = useMemo(
() => manifests () => (manifests || [])
.flatMap((m) => m.detail_tabs) .flatMap((m) => (Array.isArray(m.detail_tabs) ? m.detail_tabs : []))
.filter((t) => t.entity_type === 'contact') .filter((t): t is NonNullable<typeof t> => !!t && t.entity_type === 'contact')
.sort((a, b) => a.order - b.order), .sort((a, b) => a.order - b.order),
[manifests] [manifests]
); );
@@ -283,7 +284,7 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
]; ];
return ( return (
<div className="overflow-y-auto h-full" data-testid="contact-detail"> <div className="overflow-y-auto h-full" data-testid={dataTestId}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 sticky top-0 bg-white z-10"> <div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 sticky top-0 bg-white z-10">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
+3 -3
View File
@@ -87,9 +87,9 @@ export function Sidebar() {
permission: item.to === '/dashboard' ? 'dashboard:read' : item.to === '/contacts' ? 'contacts:read' : undefined, permission: item.to === '/dashboard' ? 'dashboard:read' : item.to === '/contacts' ? 'contacts:read' : undefined,
})); }));
const pluginItems = manifests const pluginItems = (manifests || [])
.flatMap((m) => m.menu_items) .flatMap((m) => (Array.isArray(m.menu_items) ? m.menu_items : []))
.filter(item => !item.permission || canAccess(item.permission)) .filter((item): item is NonNullable<typeof item> => !!item && (!item.permission || canAccess(item.permission)))
.map(item => ({ .map(item => ({
path: item.path, path: item.path,
labelKey: item.label_key, labelKey: item.label_key,
+3 -3
View File
@@ -110,12 +110,12 @@ export function MailDetail({
</div> </div>
<div className="flex flex-col sm:flex-row sm:gap-2"> <div className="flex flex-col sm:flex-row sm:gap-2">
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.to')}:</span> <span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.to')}:</span>
<span className="break-words">{mail.to_addresses.join(', ')}</span> <span className="break-words">{(mail.to_addresses ?? []).join(', ')}</span>
</div> </div>
{mail.cc_addresses.length > 0 && ( {mail.cc_addresses && mail.cc_addresses.length > 0 && (
<div className="flex flex-col sm:flex-row sm:gap-2"> <div className="flex flex-col sm:flex-row sm:gap-2">
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.cc')}:</span> <span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.cc')}:</span>
<span className="break-words">{mail.cc_addresses.join(', ')}</span> <span className="break-words">{(mail.cc_addresses ?? []).join(', ')}</span>
</div> </div>
)} )}
<div className="flex flex-col sm:flex-row sm:gap-2"> <div className="flex flex-col sm:flex-row sm:gap-2">
@@ -151,7 +151,7 @@ function matchesCondition(mail: any, cond: FilterCondition): boolean {
} }
export function applyFilters(mails: any[], filters: FilterState): any[] { export function applyFilters(mails: any[], filters: FilterState): any[] {
if (!filters.conditions.length) return mails; if (!(filters.conditions?.length ?? 0)) return mails;
if (filters.logic === 'AND') { if (filters.logic === 'AND') {
return mails.filter((m) => filters.conditions.every((cond) => matchesCondition(m, cond))); return mails.filter((m) => filters.conditions.every((cond) => matchesCondition(m, cond)));
} else { } else {
@@ -208,7 +208,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
setOpen(!open); setOpen(!open);
}; };
const activeCount = filters.conditions.length; const activeCount = filters.conditions?.length ?? 0;
const addCondition = () => { const addCondition = () => {
onFiltersChange({ onFiltersChange({
@@ -248,7 +248,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
const handleSaveFilter = () => { const handleSaveFilter = () => {
if (!onSaveFilter) return; if (!onSaveFilter) return;
if (filters.conditions.length === 0) return; if ((filters.conditions?.length ?? 0) === 0) return;
const name = window.prompt('Name für diesen Filter:', 'Mein Filter'); const name = window.prompt('Name für diesen Filter:', 'Mein Filter');
if (!name) return; if (!name) return;
onSaveFilter(name, { ...filters, conditions: filters.conditions.map((c) => ({ ...c })) }); onSaveFilter(name, { ...filters, conditions: filters.conditions.map((c) => ({ ...c })) });
@@ -345,7 +345,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
)} )}
{/* Saved filters */} {/* Saved filters */}
{savedFilters.length > 0 && ( {(savedFilters?.length ?? 0) > 0 && (
<div className="px-4 py-2 border-b border-secondary-100"> <div className="px-4 py-2 border-b border-secondary-100">
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">Gespeicherte Filter</div> <div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">Gespeicherte Filter</div>
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -366,7 +366,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
{/* Filter rows */} {/* Filter rows */}
<div className="px-4 py-3 space-y-2"> <div className="px-4 py-3 space-y-2">
{filters.conditions.length === 0 && ( {(filters.conditions?.length ?? 0) === 0 && (
<div className="text-center py-6 text-xs text-secondary-400"> <div className="text-center py-6 text-xs text-secondary-400">
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen. Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.
</div> </div>
@@ -301,7 +301,7 @@ export function MailFolderTree({ accounts, folders, selectedFolderId, onSelect,
); );
} }
if (accounts.length === 0) { if ((accounts?.length ?? 0) === 0) {
return ( return (
<EmptyState <EmptyState
title={t('mail.noFolders')} title={t('mail.noFolders')}
@@ -73,7 +73,7 @@ export interface GroupedMails {
} }
export function applyGrouping(mails: any[], groupState: GroupState): GroupedMails[] { export function applyGrouping(mails: any[], groupState: GroupState): GroupedMails[] {
if (!groupState.conditions.length) return [{ key: 'all', label: 'Alle', mails }]; if (!(groupState.conditions?.length ?? 0)) return [{ key: 'all', label: 'Alle', mails }];
const defs = GROUP_FIELDS; const defs = GROUP_FIELDS;
function groupRecursive(items: any[], conditions: GroupCondition[], depth: number): GroupedMails[] { function groupRecursive(items: any[], conditions: GroupCondition[], depth: number): GroupedMails[] {
@@ -139,7 +139,7 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
setOpen(!open); setOpen(!open);
}; };
const activeCount = groupState.conditions.length; const activeCount = groupState.conditions?.length ?? 0;
const addCondition = () => { const addCondition = () => {
onGroupChange({ conditions: [...groupState.conditions, { id: newGroupId(), field: 'from' }] }); onGroupChange({ conditions: [...groupState.conditions, { id: newGroupId(), field: 'from' }] });
+7 -7
View File
@@ -52,8 +52,8 @@ function GroupSection({
const hasSubGroups = group.subGroups && group.subGroups.length > 0; const hasSubGroups = group.subGroups && group.subGroups.length > 0;
// Count total mails including subGroups // Count total mails including subGroups
const totalCount = hasSubGroups const totalCount = hasSubGroups
? group.subGroups!.reduce((sum, sub) => sum + sub.mails.length, 0) ? group.subGroups!.reduce((sum, sub) => sum + (sub.mails?.length ?? 0), 0)
: group.mails.length; : (group.mails?.length ?? 0);
return ( return (
<li className="bg-secondary-50/30"> <li className="bg-secondary-50/30">
@@ -110,7 +110,7 @@ export function MailList({
hasMore = false, hasMore = false,
}: MailListProps) { }: MailListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id)); const allSelected = (mails?.length ?? 0) > 0 && mails.every((m) => selectedMailIds.has(m.id));
const scrollRef = useRef<HTMLUListElement>(null); const scrollRef = useRef<HTMLUListElement>(null);
// Infinite scroll handler // Infinite scroll handler
@@ -121,7 +121,7 @@ export function MailList({
} }
}; };
if (loading && mails.length === 0) { if (loading && (mails?.length ?? 0) === 0) {
return ( return (
<div className="flex items-center justify-center py-12" data-testid="mail-list-loading"> <div className="flex items-center justify-center py-12" data-testid="mail-list-loading">
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" /> <Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
@@ -130,7 +130,7 @@ export function MailList({
); );
} }
if (mails.length === 0) { if ((mails?.length ?? 0) === 0) {
return ( return (
<EmptyState <EmptyState
title={t('mail.noMails')} title={t('mail.noMails')}
@@ -228,7 +228,7 @@ export function MailList({
</span> </span>
)} )}
</div> </div>
{loading && mails.length > 0 && <Loader2 className="w-4 h-4 animate-spin text-secondary-400" />} {loading && (mails?.length ?? 0) > 0 && <Loader2 className="w-4 h-4 animate-spin text-secondary-400" />}
</div> </div>
{/* Mail list — infinite scroll with optional grouping */} {/* Mail list — infinite scroll with optional grouping */}
@@ -254,7 +254,7 @@ export function MailList({
) : ( ) : (
mails.map((mail) => renderMailItem(mail)) mails.map((mail) => renderMailItem(mail))
)} )}
{loading && mails.length > 0 && ( {loading && (mails?.length ?? 0) > 0 && (
<li className="flex items-center justify-center py-4"> <li className="flex items-center justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-secondary-400" /> <Loader2 className="w-5 h-5 animate-spin text-secondary-400" />
</li> </li>
@@ -60,7 +60,7 @@ function compareValues(a: any, b: any, fieldType: FieldType): number {
} }
export function applySorting(mails: any[], sortState: SortState): any[] { export function applySorting(mails: any[], sortState: SortState): any[] {
if (!sortState.conditions.length) return mails; if (!(sortState.conditions?.length ?? 0)) return mails;
const sorted = [...mails]; const sorted = [...mails];
sorted.sort((a, b) => { sorted.sort((a, b) => {
for (const cond of sortState.conditions) { for (const cond of sortState.conditions) {
@@ -111,7 +111,7 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
setOpen(!open); setOpen(!open);
}; };
const activeCount = sortState.conditions.length; const activeCount = sortState.conditions?.length ?? 0;
const addCondition = () => { const addCondition = () => {
onSortChange({ onSortChange({
+8 -6
View File
@@ -70,6 +70,7 @@ export function ContactsListPage() {
const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false); const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false);
const [selectedContactIds, setSelectedContactIds] = useState<Set<string>>(new Set()); const [selectedContactIds, setSelectedContactIds] = useState<Set<string>>(new Set());
const openWindow = useWindowStore((s) => s.openWindow); const openWindow = useWindowStore((s) => s.openWindow);
const closeWindow = useWindowStore((s) => s.closeWindow);
// Bulk action mutations // Bulk action mutations
const deleteContactMut = useDeleteUnifiedContact(); const deleteContactMut = useDeleteUnifiedContact();
@@ -223,32 +224,32 @@ export function ContactsListPage() {
// Handle create // Handle create
const handleCreate = useCallback(() => { const handleCreate = useCallback(() => {
openWindow({ const windowId = openWindow({
title: t('contacts.create'), title: t('contacts.create'),
type: 'contact-create', type: 'contact-create',
component: ContactEditForm, component: ContactEditForm,
componentProps: { componentProps: {
onClose: () => {}, onClose: () => closeWindow(windowId),
onSaved: handleSaved, onSaved: handleSaved,
}, },
}); });
}, [openWindow, t, handleSaved]); }, [openWindow, closeWindow, t, handleSaved]);
// Handle edit // Handle edit
const handleEdit = useCallback(() => { const handleEdit = useCallback(() => {
if (selectedContact) { if (selectedContact) {
openWindow({ const windowId = openWindow({
title: t('contacts.edit'), title: t('contacts.edit'),
type: 'contact-edit', type: 'contact-edit',
component: ContactEditForm, component: ContactEditForm,
componentProps: { componentProps: {
contact: selectedContact, contact: selectedContact,
onClose: () => {}, onClose: () => closeWindow(windowId),
onSaved: handleSaved, onSaved: handleSaved,
}, },
}); });
} }
}, [selectedContact, openWindow, t, handleSaved]); }, [selectedContact, openWindow, closeWindow, t, handleSaved]);
// Handle delete (from detail) // Handle delete (from detail)
const handleDeleted = useCallback(() => { const handleDeleted = useCallback(() => {
@@ -747,6 +748,7 @@ export function ContactsListPage() {
loading={loadingDetail} loading={loadingDetail}
onEdit={handleEdit} onEdit={handleEdit}
onDeleted={handleDeleted} onDeleted={handleDeleted}
dataTestId="contact-detail-mobile"
/> />
</div> </div>
)} )}
+19 -15
View File
@@ -99,10 +99,10 @@ export function MailPage() {
// Apply filter + sort to mails before rendering // Apply filter + sort to mails before rendering
const processedMails = useMemo(() => { const processedMails = useMemo(() => {
let result = mails; let result = mails;
if (mailFilterState.conditions.length > 0) { if ((mailFilterState.conditions || []).length > 0) {
result = applyMailFilters(result, mailFilterState); result = applyMailFilters(result, mailFilterState);
} }
if (mailSortState.conditions.length > 0) { if ((mailSortState.conditions || []).length > 0) {
result = applyMailSorting(result, mailSortState); result = applyMailSorting(result, mailSortState);
} }
return result; return result;
@@ -110,7 +110,7 @@ export function MailPage() {
// Apply grouping after filter+sort // Apply grouping after filter+sort
const groupedMails = useMemo(() => { const groupedMails = useMemo(() => {
if (mailGroupState.conditions.length === 0) return null; if ((mailGroupState.conditions || []).length === 0) return null;
return applyMailGrouping(processedMails, mailGroupState); return applyMailGrouping(processedMails, mailGroupState);
}, [processedMails, mailGroupState]); }, [processedMails, mailGroupState]);
@@ -153,7 +153,7 @@ export function MailPage() {
// Load folders for ALL accounts // Load folders for ALL accounts
const loadAllFolders = useCallback(async () => { const loadAllFolders = useCallback(async () => {
if (accounts.length === 0) return; if ((accounts?.length ?? 0) === 0) return;
setLoadingFolders(true); setLoadingFolders(true);
try { try {
const allFolders: MailFolder[] = []; const allFolders: MailFolder[] = [];
@@ -175,7 +175,7 @@ export function MailPage() {
// Auto-select first folder when folders are loaded // Auto-select first folder when folders are loaded
useEffect(() => { useEffect(() => {
if (folders.length > 0 && !selectedFolderId) { if ((folders?.length ?? 0) > 0 && !selectedFolderId) {
setSelectedFolderId(folders[0].id); setSelectedFolderId(folders[0].id);
setSelectedAccountId(folders[0].account_id); setSelectedAccountId(folders[0].account_id);
} }
@@ -189,21 +189,25 @@ export function MailPage() {
try { try {
if (searchQuery.trim()) { if (searchQuery.trim()) {
const result = await searchMails(searchQuery); const result = await searchMails(searchQuery);
const resultMails = result.mails ?? [];
const resultTotal = result.total ?? 0;
// Only update if still on the same folder // Only update if still on the same folder
if (selectedFolderIdRef.current === currentFolderId) { if (selectedFolderIdRef.current === currentFolderId) {
setMails(result.mails); setMails(resultMails);
setMailsTotal(result.total); setMailsTotal(resultTotal);
} }
} else { } else {
// When grouping is active, load all mails at once (no pagination) // When grouping is active, load all mails at once (no pagination)
const isGrouping = mailGroupState.conditions.length > 0; const isGrouping = (mailGroupState.conditions || []).length > 0;
const pageToLoad = isGrouping ? 1 : mailsPage; const pageToLoad = isGrouping ? 1 : mailsPage;
const result = await fetchMails(currentFolderId, pageToLoad, sortBy, sortOrder, isGrouping ? 10000 : undefined); const result = await fetchMails(currentFolderId, pageToLoad, sortBy, sortOrder, isGrouping ? 10000 : undefined);
const resultMails = result.mails ?? [];
const resultTotal = result.total ?? 0;
// Only update if still on the same folder // Only update if still on the same folder
if (selectedFolderIdRef.current === currentFolderId) { if (selectedFolderIdRef.current === currentFolderId) {
// Append for infinite scroll (page > 1), replace on page 1 or folder change or grouping // Append for infinite scroll (page > 1), replace on page 1 or folder change or grouping
setMails(!isGrouping && mailsPage > 1 ? (prev) => [...prev, ...result.mails] : result.mails); setMails(!isGrouping && mailsPage > 1 ? (prev) => [...(prev ?? []), ...resultMails] : resultMails);
setMailsTotal(result.total); setMailsTotal(resultTotal);
} }
} }
} catch (err) { } catch (err) {
@@ -851,7 +855,7 @@ export function MailPage() {
); );
} }
if (accounts.length === 0) { if ((accounts?.length ?? 0) === 0) {
return ( return (
<div className="p-6 max-w-7xl mx-auto" data-testid="mail-page"> <div className="p-6 max-w-7xl mx-auto" data-testid="mail-page">
<EmptyState <EmptyState
@@ -911,11 +915,11 @@ export function MailPage() {
onToggleSelect={handleToggleSelect} onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
onLoadMore={() => { onLoadMore={() => {
if (mails.length < mailsTotal) { if ((mails?.length ?? 0) < mailsTotal) {
setMailsPage((p) => p + 1); setMailsPage((p) => p + 1);
} }
}} }}
hasMore={mailGroupState.conditions.length === 0 && mails.length < mailsTotal} hasMore={(mailGroupState.conditions || []).length === 0 && (mails?.length ?? 0) < mailsTotal}
/> />
</ResizablePanel> </ResizablePanel>
@@ -982,11 +986,11 @@ export function MailPage() {
onToggleSelect={handleToggleSelect} onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
onLoadMore={() => { onLoadMore={() => {
if (mails.length < mailsTotal) { if ((mails?.length ?? 0) < mailsTotal) {
setMailsPage((p) => p + 1); setMailsPage((p) => p + 1);
} }
}} }}
hasMore={mailGroupState.conditions.length === 0 && mails.length < mailsTotal} hasMore={(mailGroupState.conditions || []).length === 0 && (mails?.length ?? 0) < mailsTotal}
/> />
</div> </div>
)} )}
+3 -2
View File
@@ -8,8 +8,9 @@ export function SettingsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const manifests = usePluginStore(s => s.manifests); const manifests = usePluginStore(s => s.manifests);
const pluginSettingsPages = useMemo( const pluginSettingsPages = useMemo(
() => manifests () => (manifests || [])
.flatMap((m) => m.settings_pages) .flatMap((m) => (Array.isArray(m.settings_pages) ? m.settings_pages : []))
.filter((p): p is NonNullable<typeof p> => !!p && !!p.path)
.sort((a, b) => a.order - b.order), .sort((a, b) => a.order - b.order),
[manifests] [manifests]
); );
+3 -1
View File
@@ -180,7 +180,9 @@ export function SettingsPluginsPage() {
const [confirmUninstall, setConfirmUninstall] = useState<Plugin | null>(null); const [confirmUninstall, setConfirmUninstall] = useState<Plugin | null>(null);
const [confirmRemoveData, setConfirmRemoveData] = useState(false); const [confirmRemoveData, setConfirmRemoveData] = useState(false);
const plugins: Plugin[] = data ?? []; const plugins: Plugin[] = Array.isArray(data)
? data
: (data as any)?.plugins ?? [];
const handleInstall = async (plugin: Plugin) => { const handleInstall = async (plugin: Plugin) => {
try { try {
+46 -35
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface Tenant { export interface Tenant {
id: string; id: string;
@@ -34,39 +35,49 @@ export interface AuthState {
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
(set) => ({ persist(
user: null, (set) => ({
currentTenant: null, user: null,
isAuthenticated: false, currentTenant: null,
isLoading: false, isAuthenticated: false,
error: null, isLoading: false,
setUser: (user) => error: null,
set({ setUser: (user) =>
user, set({
isAuthenticated: !!user, user,
currentTenant: user?.tenants?.[0] ?? null, isAuthenticated: !!user,
}), currentTenant: user?.tenants?.[0] ?? null,
setTenant: (tenant) => set({ currentTenant: tenant }), }),
setAuthenticated: (authed) => set({ isAuthenticated: authed }), setTenant: (tenant) => set({ currentTenant: tenant }),
setLoading: (loading) => set({ isLoading: loading }), setAuthenticated: (authed) => set({ isAuthenticated: authed }),
setError: (error) => set({ error }), setLoading: (loading) => set({ isLoading: loading }),
setPermissions: (perms, isSystemAdmin, fieldPerms) => setError: (error) => set({ error }),
set((state) => ({ setPermissions: (perms, isSystemAdmin, fieldPerms) =>
user: state.user set((state) => ({
? { user: state.user
...state.user, ? {
permissions: perms, ...state.user,
is_system_admin: isSystemAdmin, permissions: perms,
field_permissions: fieldPerms, is_system_admin: isSystemAdmin,
} field_permissions: fieldPerms,
: null, }
})), : null,
logout: () => })),
set({ logout: () =>
user: null, set({
currentTenant: null, user: null,
isAuthenticated: false, currentTenant: null,
error: null, isAuthenticated: false,
}), error: null,
}) }),
}),
{
name: 'auth-store',
partialize: (state) => ({
user: state.user,
currentTenant: state.currentTenant,
isAuthenticated: state.isAuthenticated,
}),
}
)
); );