From c2d3caaa5a5cd08c3e20749fa95c287358ce217e Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Mon, 9 Feb 2026 18:05:09 +0200 Subject: [PATCH] Quick fixes + logo better --- .env.example | 10 +- messages/en.json | 15 + messages/et.json | 15 + .../settings/SettingsActivityLog.svelte | 446 ++++++++++++++++++ src/lib/components/settings/index.ts | 1 + src/lib/components/ui/Logo.svelte | 41 +- src/lib/supabase/admin.ts | 22 + src/routes/+page.server.ts | 39 +- src/routes/+page.svelte | 173 ++++++- src/routes/[orgSlug]/settings/+page.svelte | 9 +- src/routes/api/accept-invite/+server.ts | 74 +++ src/routes/api/send-invite-email/+server.ts | 130 ++--- .../callback/{+server.ts => +page.server.ts} | 7 +- src/routes/auth/callback/+page.svelte | 85 ++++ src/routes/invite/[token]/+page.svelte | 38 +- tests/e2e/cleanup.ts | 254 ++++------ tests/e2e/features.spec.ts | 329 +++++++++++++ 17 files changed, 1400 insertions(+), 288 deletions(-) create mode 100644 src/lib/components/settings/SettingsActivityLog.svelte create mode 100644 src/lib/supabase/admin.ts create mode 100644 src/routes/api/accept-invite/+server.ts rename src/routes/auth/callback/{+server.ts => +page.server.ts} (83%) create mode 100644 src/routes/auth/callback/+page.svelte diff --git a/.env.example b/.env.example index 824c556..9227b0d 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,10 @@ PUBLIC_SUPABASE_URL=your_supabase_url PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +# Service role key — required for admin operations (invite emails, etc.) +# Find it in Supabase Dashboard → Settings → API → service_role key +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key GOOGLE_API_KEY=your_google_api_key - # Google Service Account for Calendar push (create/update/delete events) # Paste the full JSON key file contents, or base64-encode it # The calendar must be shared with the service account email (with "Make changes to events" permission) @@ -14,9 +16,3 @@ MATRIX_HOMESERVER_URL=https://matrix.example.com # Synapse Admin API shared secret or admin access token # Used to auto-provision Matrix accounts for users MATRIX_ADMIN_TOKEN= - -# Resend email integration (resend.com) -# Free tier: 100 emails/day. Verify a domain at resend.com/domains first. -RESEND_API_KEY= -# The verified sender email address (e.g. noreply@yourdomain.com) -RESEND_FROM_EMAIL= \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 999193b..72f71c1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -127,6 +127,21 @@ "settings_tab_roles": "Roles", "settings_tab_tags": "Tags", "settings_tab_integrations": "Integrations", + "settings_tab_activity": "Activity Log", + "settings_activity_title": "Activity Log", + "settings_activity_desc": "Full history of all actions performed in this organization.", + "settings_activity_empty": "No activity recorded yet.", + "settings_activity_load_more": "Load more", + "settings_activity_loading": "Loading...", + "settings_activity_end": "You've reached the end of the activity log.", + "settings_activity_filter_all": "All actions", + "settings_activity_filter_create": "Created", + "settings_activity_filter_update": "Updated", + "settings_activity_filter_delete": "Deleted", + "settings_activity_filter_move": "Moved", + "settings_activity_filter_rename": "Renamed", + "settings_activity_count": "{count} entries", + "settings_activity_search_placeholder": "Search activity...", "settings_general_title": "Organization details", "settings_general_avatar": "Avatar", "settings_general_name": "Name", diff --git a/messages/et.json b/messages/et.json index e0dd00d..90509fc 100644 --- a/messages/et.json +++ b/messages/et.json @@ -126,6 +126,21 @@ "settings_tab_roles": "Rollid", "settings_tab_tags": "Sildid", "settings_tab_integrations": "Integratsioonid", + "settings_tab_activity": "Tegevuslogi", + "settings_activity_title": "Tegevuslogi", + "settings_activity_desc": "Täielik ajalugu kõigist selles organisatsioonis tehtud toimingutest.", + "settings_activity_empty": "Tegevusi pole veel salvestatud.", + "settings_activity_load_more": "Laadi rohkem", + "settings_activity_loading": "Laadimine...", + "settings_activity_end": "Olete jõudnud tegevuslogi lõppu.", + "settings_activity_filter_all": "Kõik toimingud", + "settings_activity_filter_create": "Loodud", + "settings_activity_filter_update": "Uuendatud", + "settings_activity_filter_delete": "Kustutatud", + "settings_activity_filter_move": "Liigutatud", + "settings_activity_filter_rename": "Ümbernimetatud", + "settings_activity_count": "{count} kirjet", + "settings_activity_search_placeholder": "Otsi tegevusi...", "settings_general_title": "Organisatsiooni andmed", "settings_general_avatar": "Avatar", "settings_general_name": "Nimi", diff --git a/src/lib/components/settings/SettingsActivityLog.svelte b/src/lib/components/settings/SettingsActivityLog.svelte new file mode 100644 index 0000000..28ee2ae --- /dev/null +++ b/src/lib/components/settings/SettingsActivityLog.svelte @@ -0,0 +1,446 @@ + + +
+ +
+
+

+ {m.settings_activity_title()} +

+

+ {m.settings_activity_desc()} +

+
+ {#if totalCount !== null} + + {m.settings_activity_count({ count: String(totalCount) })} + + {/if} +
+ + +
+
+ {#each filters as filter} + + {/each} +
+
+ +
+
+ + + {#if isLoading} +
+ +
+ {:else if filteredEntries.length === 0 && initialLoaded} +
+ +
+ {:else} +
+ {#each groupedEntries() as group} + +
+ + {group.label} + +
+ + {#each group.entries as entry (entry.id)} +
+ +
+ {getActivityIcon(entry.action)} +
+ + +
+

+ {getDescription(entry)} +

+
+ {#if entry.profiles} +
+ + + {entry.profiles.full_name ?? + entry.profiles.email} + +
+ {/if} + · + + {formatRelativeDate(entry.created_at)} + + +
+
+ + + + {getEntityTypeLabel(entry.entity_type)} + +
+ {/each} + {/each} +
+ + +
+ {#if isLoadingMore} + + {:else if hasMore} + + {:else if entries.length > 0} +

+ {m.settings_activity_end()} +

+ {/if} +
+ {/if} +
diff --git a/src/lib/components/settings/index.ts b/src/lib/components/settings/index.ts index 86076e1..5b61921 100644 --- a/src/lib/components/settings/index.ts +++ b/src/lib/components/settings/index.ts @@ -2,3 +2,4 @@ export { default as SettingsGeneral } from './SettingsGeneral.svelte'; export { default as SettingsMembers } from './SettingsMembers.svelte'; export { default as SettingsRoles } from './SettingsRoles.svelte'; export { default as SettingsIntegrations } from './SettingsIntegrations.svelte'; +export { default as SettingsActivityLog } from './SettingsActivityLog.svelte'; diff --git a/src/lib/components/ui/Logo.svelte b/src/lib/components/ui/Logo.svelte index 52fceb4..f535f4c 100644 --- a/src/lib/components/ui/Logo.svelte +++ b/src/lib/components/ui/Logo.svelte @@ -3,7 +3,7 @@ size?: "sm" | "md" | "lg"; } - let { size = "md"}: Props = $props(); + let { size = "md" }: Props = $props(); const iconSizes = { sm: "w-8 h-8", @@ -12,13 +12,40 @@ }; -
+
- - - - - + + + + +
diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts new file mode 100644 index 0000000..9fdd8b1 --- /dev/null +++ b/src/lib/supabase/admin.ts @@ -0,0 +1,22 @@ +import { createClient } from '@supabase/supabase-js'; +import { PUBLIC_SUPABASE_URL } from '$env/static/public'; +import { env } from '$env/dynamic/private'; +import type { Database } from './types'; + +/** + * Creates a Supabase client with the service_role key. + * This bypasses RLS and should ONLY be used server-side for admin operations + * like sending invite emails via auth.admin. + */ +export function createSupabaseAdmin() { + const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY; + if (!serviceRoleKey) { + throw new Error('SUPABASE_SERVICE_ROLE_KEY is not configured'); + } + return createClient(PUBLIC_SUPABASE_URL, serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index e044a10..1aa2fc2 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -8,20 +8,41 @@ export const load: PageServerLoad = async ({ locals }) => { redirect(303, '/login'); } - const { data: memberships } = await locals.supabase - .from('org_members') - .select(` - role, - organization:organizations(*) - `) - .eq('user_id', user.id); + const [membershipsResult, invitesResult] = await Promise.all([ + locals.supabase + .from('org_members') + .select(` + role, + organization:organizations(*) + `) + .eq('user_id', user.id), + // Fetch pending invites for this user's email + locals.supabase + .from('org_invites') + .select(` + id, email, role, token, expires_at, created_at, + organizations ( id, name, slug ) + `) + .eq('email', user.email!) + .is('accepted_at', null) + .gt('expires_at', new Date().toISOString()) + ]); - const organizations = (memberships ?? []).map((m) => ({ + const organizations = (membershipsResult.data ?? []).map((m) => ({ ...m.organization, role: m.role })); + const pendingInvites = (invitesResult.data ?? []).map((inv) => ({ + id: inv.id, + role: inv.role, + token: inv.token, + createdAt: inv.created_at, + org: (inv as Record).organizations as { id: string; name: string; slug: string } | null, + })).filter((inv) => inv.org !== null); + return { - organizations + organizations, + pendingInvites, }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ab26893..e4e90ba 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,7 @@ + +
+
+ {#if error} +

{error}

+ {:else} + +

Signing you in...

+ {/if} +
+
diff --git a/src/routes/invite/[token]/+page.svelte b/src/routes/invite/[token]/+page.svelte index e9ffbab..34d280e 100644 --- a/src/routes/invite/[token]/+page.svelte +++ b/src/routes/invite/[token]/+page.svelte @@ -62,34 +62,38 @@ error = ""; try { - // Add user to org members - const { error: memberError } = await supabase - .from("org_members") - .insert({ - org_id: data.invite.org.id, - user_id: data.user.id, + // Use server endpoint (admin client) to insert member + mark invite accepted + // Client-side Supabase can't update org_invites due to RLS + const res = await fetch("/api/accept-invite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + inviteId: data.invite.id, + orgId: data.invite.org.id, role: data.invite.role, - joined_at: new Date().toISOString(), - }); + }), + }); - if (memberError) { - if (memberError.code === "23505") { + const result = await res.json(); + + if (!res.ok) { + if (result.already_member) { error = m.invite_already_member(); } else { - error = m.invite_join_failed(); + error = result.error || m.invite_join_failed(); log.error("Failed to join organization", { - error: memberError, + error: result.error, }); } isAccepting = false; return; } - // Mark invite as accepted - await supabase - .from("org_invites") - .update({ accepted_at: new Date().toISOString() }) - .eq("id", data.invite.id); + if (result.already_member) { + error = m.invite_already_member(); + isAccepting = false; + return; + } // Show onboarding profile step isAccepting = false; diff --git a/tests/e2e/cleanup.ts b/tests/e2e/cleanup.ts index 6e130a6..cf7aac2 100644 --- a/tests/e2e/cleanup.ts +++ b/tests/e2e/cleanup.ts @@ -6,11 +6,19 @@ import * as path from 'path'; * Global teardown: delete all test-created data from Supabase. * Runs after all Playwright tests complete. * - * Matches documents/folders/kanbans by name prefixes used in tests: - * "Test Folder", "Test Doc", "Test Board", "Nav Folder", "Rename Me", "Renamed" - * Matches kanban boards by name prefix: "PW Board", "Board A", "Board B" - * Matches org_invites by email pattern: "playwright-test-*@example.com" - * Matches org_roles by name prefix: "Tester" + * Uses SUPABASE_SERVICE_ROLE_KEY to bypass RLS for reliable cleanup. + * Falls back to anon key + password auth if service role key is unavailable. + * + * Cleanup targets (by name prefix): + * Documents: "Test Folder", "Test Doc", "Test Board", "Nav Folder", "Rename Me", "Renamed" + * Boards: "PW Board", "PW Card Board", "PW Detail Board", "Board A", "Board B", "Perf Test" + * Events: "PW Event", "PW Detail", "PW Delete", "PW Test Event", "PW Finance" + * Invites: "playwright-test-*@example.com" + * Roles: "Tester" + * Tags: "PW Tag" + * Sponsors: "PW Sponsor" + * Contacts: "PW Contact", "PW Vendor" + * Org events: "PW Test Event", "PW Finance" (cascades to depts, modules, budget, etc.) */ // Load .env manually since we're outside Vite @@ -32,34 +40,67 @@ function loadEnv() { loadEnv(); const SUPABASE_URL = process.env.PUBLIC_SUPABASE_URL || ''; -const SUPABASE_KEY = process.env.PUBLIC_SUPABASE_ANON_KEY || ''; +const SUPABASE_ANON_KEY = process.env.PUBLIC_SUPABASE_ANON_KEY || ''; +const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || ''; // Name prefixes used by tests when creating data const DOC_PREFIXES = ['Test Folder', 'Test Doc', 'Test Board', 'Nav Folder', 'Rename Me', 'Renamed']; -const BOARD_PREFIXES = ['PW Board', 'PW Card Board', 'PW Detail Board', 'Board A', 'Board B']; +const BOARD_PREFIXES = ['PW Board', 'PW Card Board', 'PW Detail Board', 'Board A', 'Board B', 'Perf Test']; const ROLE_PREFIX = 'Tester'; const TAG_PREFIX = 'PW Tag'; -const EVENT_PREFIXES = ['PW Event', 'PW Detail', 'PW Delete', 'PW Test Event', 'PW Finance']; +const CALENDAR_EVENT_PREFIXES = ['PW Event', 'PW Detail', 'PW Delete']; +const ORG_EVENT_PREFIXES = ['PW Test Event', 'PW Finance']; const SPONSOR_PREFIXES = ['PW Sponsor']; const CONTACT_PREFIXES = ['PW Contact', 'PW Vendor']; const INVITE_EMAIL_PATTERN = 'playwright-test-%@example.com'; +/** Helper: delete rows by prefix from a table column */ +async function deleteByPrefix( + supabase: ReturnType, + table: string, + column: string, + prefix: string, + filterCol?: string, + filterVal?: string, +): Promise { + let query = (supabase as any).from(table).select('id').ilike(column, `${prefix}%`); + if (filterCol && filterVal) query = query.eq(filterCol, filterVal); + const { data } = await query; + if (!data || data.length === 0) return 0; + const ids = data.map((r: any) => r.id); + const { error } = await (supabase as any).from(table).delete().in('id', ids); + if (error) { + console.log(`[cleanup] Failed to delete from ${table} (prefix "${prefix}"): ${error.message}`); + return 0; + } + return data.length; +} + export default async function globalTeardown() { - if (!SUPABASE_KEY) { - console.log('[cleanup] No SUPABASE_ANON_KEY - skipping cleanup'); + if (!SUPABASE_ANON_KEY && !SUPABASE_SERVICE_ROLE_KEY) { + console.log('[cleanup] No Supabase keys found - skipping cleanup'); return; } - // Authenticate using the test user credentials directly - const supabase = createClient(SUPABASE_URL, SUPABASE_KEY); - const { error: authError } = await supabase.auth.signInWithPassword({ - email: 'tipilan@ituk.ee', - password: 'gu&u6QTMbJK7nT', - }); + let supabase: ReturnType; - if (authError) { - console.log('[cleanup] Auth failed - skipping cleanup:', authError.message); - return; + // Prefer service role key (bypasses RLS) for reliable cleanup + if (SUPABASE_SERVICE_ROLE_KEY) { + console.log('[cleanup] Using service role key (RLS bypassed)'); + supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + } else { + console.log('[cleanup] No service role key, falling back to anon key + auth'); + supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); + const { error: authError } = await supabase.auth.signInWithPassword({ + email: 'tipilan@ituk.ee', + password: 'gu&u6QTMbJK7nT', + }); + if (authError) { + console.log('[cleanup] Auth failed - skipping cleanup:', authError.message); + return; + } } // Get the org ID for root-test @@ -74,160 +115,71 @@ export default async function globalTeardown() { return; } - const orgId = org.id; + const orgId = (org as any).id; let totalDeleted = 0; - // 1. Delete test documents (folders, docs, kanbans) + // 1. Delete test documents (folders, docs, kanbans) — cascades to content for (const prefix of DOC_PREFIXES) { - const { data: docs } = await supabase - .from('documents') - .select('id') - .eq('org_id', orgId) - .ilike('name', `${prefix}%`); - - if (docs && docs.length > 0) { - const ids = docs.map(d => d.id); - const { error } = await supabase - .from('documents') - .delete() - .in('id', ids); - - if (!error) { - totalDeleted += docs.length; - } else { - console.log(`[cleanup] Failed to delete docs with prefix "${prefix}":`, error.message); - } - } + totalDeleted += await deleteByPrefix(supabase, 'documents', 'name', prefix, 'org_id', orgId); } - // 2. Delete test kanban boards + // 2. Delete test kanban boards — cascades to columns → cards → assignees for (const prefix of BOARD_PREFIXES) { - const { data: boards } = await supabase - .from('kanban_boards') - .select('id') - .eq('org_id', orgId) - .ilike('name', `${prefix}%`); - - if (boards && boards.length > 0) { - const ids = boards.map(b => b.id); - const { error } = await supabase - .from('kanban_boards') - .delete() - .in('id', ids); - - if (!error) { - totalDeleted += boards.length; - } else { - console.log(`[cleanup] Failed to delete boards with prefix "${prefix}":`, error.message); - } - } + totalDeleted += await deleteByPrefix(supabase, 'kanban_boards', 'name', prefix, 'org_id', orgId); } - // 3. Delete test invites (playwright-test-*@example.com) - const { data: invites } = await supabase + // 3. Delete test invites + const { data: invites } = await (supabase as any) .from('org_invites') .select('id') .eq('org_id', orgId) .ilike('email', INVITE_EMAIL_PATTERN); if (invites && invites.length > 0) { - const ids = invites.map(i => i.id); - await supabase.from('org_invites').delete().in('id', ids); - totalDeleted += invites.length; + const { error } = await (supabase as any).from('org_invites').delete().in('id', invites.map((i: any) => i.id)); + if (!error) totalDeleted += invites.length; + else console.log('[cleanup] Failed to delete invites:', error.message); } // 4. Delete test roles - const { data: roles } = await supabase - .from('org_roles') - .select('id') - .eq('org_id', orgId) - .ilike('name', `${ROLE_PREFIX}%`); - - if (roles && roles.length > 0) { - const ids = roles.map(r => r.id); - await supabase.from('org_roles').delete().in('id', ids); - totalDeleted += roles.length; - } + totalDeleted += await deleteByPrefix(supabase, 'org_roles', 'name', ROLE_PREFIX, 'org_id', orgId); // 5. Delete test tags - const { data: tags } = await supabase - .from('tags') + totalDeleted += await deleteByPrefix(supabase, 'tags', 'name', TAG_PREFIX, 'org_id', orgId); + + // 6. Delete test calendar events (simple calendar events, not org events) + for (const prefix of CALENDAR_EVENT_PREFIXES) { + totalDeleted += await deleteByPrefix(supabase, 'calendar_events', 'title', prefix, 'org_id', orgId); + } + + // 7. Delete test org events — cascades to departments → modules → budget items, checklists, notes, etc. + for (const prefix of ORG_EVENT_PREFIXES) { + totalDeleted += await deleteByPrefix(supabase, 'events', 'name', prefix, 'org_id', orgId); + } + + // 8. Delete test sponsors + for (const prefix of SPONSOR_PREFIXES) { + totalDeleted += await deleteByPrefix(supabase, 'sponsors', 'name', prefix); + } + + // 9. Delete test org contacts + for (const prefix of CONTACT_PREFIXES) { + totalDeleted += await deleteByPrefix(supabase, 'org_contacts', 'name', prefix, 'org_id', orgId); + } + + // 10. Delete test activity log entries (from test actions) + const { data: activityLogs } = await (supabase as any) + .from('activity_log') .select('id') .eq('org_id', orgId) - .ilike('name', `${TAG_PREFIX}%`); + .gte('created_at', new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString()); - if (tags && tags.length > 0) { - const ids = tags.map(t => t.id); - await supabase.from('tags').delete().in('id', ids); - totalDeleted += tags.length; - } - - // 6. Delete test calendar events - for (const prefix of EVENT_PREFIXES) { - const { data: events } = await supabase - .from('calendar_events') - .select('id') - .eq('org_id', orgId) - .ilike('title', `${prefix}%`); - - if (events && events.length > 0) { - const ids = events.map(e => e.id); - const { error } = await supabase - .from('calendar_events') - .delete() - .in('id', ids); - - if (!error) { - totalDeleted += events.length; - } else { - console.log(`[cleanup] Failed to delete events with prefix "${prefix}":`, error.message); - } - } - } - - // 7. Delete test sponsors (via name prefix) - for (const prefix of SPONSOR_PREFIXES) { - const { data: sponsors } = await (supabase as any) - .from('sponsors') - .select('id') - .ilike('name', `${prefix}%`); - - if (sponsors && sponsors.length > 0) { - const ids = sponsors.map((s: any) => s.id); - const { error } = await (supabase as any) - .from('sponsors') - .delete() - .in('id', ids); - - if (!error) { - totalDeleted += sponsors.length; - } else { - console.log(`[cleanup] Failed to delete sponsors with prefix "${prefix}":`, error.message); - } - } - } - - // 8. Delete test org contacts (via name prefix) - for (const prefix of CONTACT_PREFIXES) { - const { data: contacts } = await (supabase as any) - .from('org_contacts') - .select('id') - .eq('org_id', orgId) - .ilike('name', `${prefix}%`); - - if (contacts && contacts.length > 0) { - const ids = contacts.map((c: any) => c.id); - const { error } = await (supabase as any) - .from('org_contacts') - .delete() - .in('id', ids); - - if (!error) { - totalDeleted += contacts.length; - } else { - console.log(`[cleanup] Failed to delete contacts with prefix "${prefix}":`, error.message); - } - } + if (activityLogs && activityLogs.length > 0) { + const { error } = await (supabase as any) + .from('activity_log') + .delete() + .in('id', activityLogs.map((l: any) => l.id)); + if (!error) totalDeleted += activityLogs.length; } if (totalDeleted > 0) { diff --git a/tests/e2e/features.spec.ts b/tests/e2e/features.spec.ts index 719a55e..1f066a5 100644 --- a/tests/e2e/features.spec.ts +++ b/tests/e2e/features.spec.ts @@ -543,3 +543,332 @@ test.describe('Settings Page - Integrations', () => { await expect(page.getByText('Coming soon').first()).toBeVisible(); }); }); + +// ─── Overview / Dashboard Page ────────────────────────────────────────────── + +test.describe('Overview / Dashboard Page', () => { + test.beforeEach(async ({ page }) => { + await navigateTo(page, `/${TEST_ORG_SLUG}`); + }); + + test('should load dashboard with org name in header', async ({ page }) => { + const header = page.locator('header, [class*="PageHeader"]'); + await expect(header).toBeVisible({ timeout: 5000 }); + }); + + test('should display stat cards for events, members, documents, boards', async ({ page }) => { + // Wait for stats to load (they're async) + await expect(page.getByText('Events').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Members').first()).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Documents').first()).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Boards').first()).toBeVisible({ timeout: 5000 }); + }); + + test('should display Upcoming Events section', async ({ page }) => { + await expect(page.getByText('Upcoming Events').first()).toBeVisible({ timeout: 10000 }); + }); + + test('should display Recent Activity section', async ({ page }) => { + await expect(page.getByText('Activity').first()).toBeVisible({ timeout: 10000 }); + }); + + test('should display Members section with at least one member', async ({ page }) => { + await expect(page.getByText('Members').first()).toBeVisible({ timeout: 10000 }); + // At least one avatar or member entry should exist + const memberSection = page.locator('div').filter({ hasText: /Members/ }).last(); + await expect(memberSection).toBeVisible({ timeout: 5000 }); + }); + + test('should show Settings link for admin users', async ({ page }) => { + // The settings link card should be visible for admin/owner + const settingsLink = page.getByText('Settings').last(); + await expect(settingsLink).toBeVisible({ timeout: 5000 }); + }); + + test('stat card links should navigate to correct pages', async ({ page }) => { + // Click the Events stat card link + const eventsLink = page.locator('a[href*="/events"]').first(); + await expect(eventsLink).toBeVisible({ timeout: 10000 }); + await eventsLink.click(); + await page.waitForURL(/\/events/, { timeout: 10000 }); + }); +}); + +// ─── Settings: Activity Log ───────────────────────────────────────────────── + +test.describe('Settings Page - Activity Log', () => { + test.beforeEach(async ({ page }) => { + await navigateTo(page, `/${TEST_ORG_SLUG}/settings`); + }); + + test('should switch to Activity tab and show activity log UI', async ({ page }) => { + await page.getByRole('button', { name: 'Activity' }).click(); + // Should show the activity log heading or filter controls + await expect(page.getByText('Activity Log').first()).toBeVisible({ timeout: 5000 }); + }); + + test('should show filter buttons in activity log', async ({ page }) => { + await page.getByRole('button', { name: 'Activity' }).click(); + await expect(page.getByText('Activity Log').first()).toBeVisible({ timeout: 5000 }); + // Filter buttons should be visible + await expect(page.getByRole('button', { name: 'All' }).first()).toBeVisible({ timeout: 3000 }); + }); + + test('should show search input in activity log', async ({ page }) => { + await page.getByRole('button', { name: 'Activity' }).click(); + await expect(page.getByText('Activity Log').first()).toBeVisible({ timeout: 5000 }); + // Search input + const searchInput = page.locator('input[placeholder*="Search"]').first(); + await expect(searchInput).toBeVisible({ timeout: 3000 }); + }); + + test('should load activity entries or show empty state', async ({ page }) => { + await page.getByRole('button', { name: 'Activity' }).click(); + await expect(page.getByText('Activity Log').first()).toBeVisible({ timeout: 5000 }); + // Wait for loading to complete - either entries or empty state + await page.waitForTimeout(2000); + const hasEntries = await page.locator('.group').first().isVisible().catch(() => false); + const hasEmpty = await page.getByText('No activity').isVisible().catch(() => false); + expect(hasEntries || hasEmpty).toBeTruthy(); + }); +}); + +// ─── Homepage / Org Selector ──────────────────────────────────────────────── + +test.describe('Homepage - Org Selector', () => { + test('should display header with logo and sign out button', async ({ page }) => { + await page.goto('/', { timeout: 30000 }); + await expect(page.getByRole('button', { name: 'Sign Out' })).toBeVisible({ timeout: 10000 }); + }); + + test('should display "Your Organizations" heading', async ({ page }) => { + await page.goto('/', { timeout: 30000 }); + await expect(page.getByRole('heading', { name: 'Your Organizations' })).toBeVisible({ timeout: 10000 }); + }); + + test('should show at least one organization card', async ({ page }) => { + await page.goto('/', { timeout: 30000 }); + // The test org should be visible + await expect(page.getByText(TEST_ORG_SLUG).first()).toBeVisible({ timeout: 10000 }); + }); + + test('should show New Organization button', async ({ page }) => { + await page.goto('/', { timeout: 30000 }); + await expect(page.getByText('New Organization')).toBeVisible({ timeout: 10000 }); + }); + + test('should open Create Organization modal', async ({ page }) => { + await page.goto('/', { timeout: 30000 }); + await page.getByText('New Organization').click(); + const modal = page.getByRole('dialog'); + await expect(modal).toBeVisible({ timeout: 3000 }); + await expect(modal.getByText('Create Organization')).toBeVisible(); + await expect(modal.getByText('Organization Name')).toBeVisible(); + // Cancel + await modal.getByText('Cancel').click(); + await expect(modal).not.toBeVisible({ timeout: 3000 }); + }); + + test('should show URL preview when typing org name', async ({ page }) => { + await page.goto('/', { timeout: 30000 }); + await page.getByText('New Organization').click(); + const modal = page.getByRole('dialog'); + await expect(modal).toBeVisible({ timeout: 3000 }); + await modal.locator('input[type="text"]').fill('Test Preview Org'); + await expect(modal.getByText('URL:')).toBeVisible({ timeout: 3000 }); + await modal.getByText('Cancel').click(); + }); + + test('should navigate to org when clicking org card', async ({ page }) => { + await page.goto('/', { timeout: 30000 }); + // Click the test org link + const orgLink = page.locator(`a[href="/${TEST_ORG_SLUG}"]`).first(); + await expect(orgLink).toBeVisible({ timeout: 10000 }); + await orgLink.click(); + await page.waitForURL(new RegExp(`/${TEST_ORG_SLUG}`), { timeout: 15000 }); + }); +}); + +// ─── Sidebar Navigation ──────────────────────────────────────────────────── + +test.describe('Sidebar Navigation', () => { + test.beforeEach(async ({ page }) => { + await navigateTo(page, `/${TEST_ORG_SLUG}`); + }); + + test('should navigate to Documents via sidebar', async ({ page }) => { + const aside = page.locator('aside'); + await aside.getByText('Files').click(); + await page.waitForURL(/\/documents/, { timeout: 10000 }); + await expect(page.getByRole('heading', { name: 'Files' })).toBeVisible({ timeout: 5000 }); + }); + + test('should navigate to Kanban via sidebar', async ({ page }) => { + const aside = page.locator('aside'); + await aside.getByText('Kanban').click(); + await page.waitForURL(/\/kanban/, { timeout: 10000 }); + await expect(page.getByRole('heading', { name: 'Kanban' })).toBeVisible({ timeout: 5000 }); + }); + + test('should navigate to Calendar via sidebar', async ({ page }) => { + const aside = page.locator('aside'); + await aside.getByText('Calendar').click(); + await page.waitForURL(/\/calendar/, { timeout: 10000 }); + await expect(page.getByRole('heading', { name: 'Calendar' })).toBeVisible({ timeout: 5000 }); + }); + + test('should navigate to Events via sidebar', async ({ page }) => { + const aside = page.locator('aside'); + await aside.getByText('Events').click(); + await page.waitForURL(/\/events/, { timeout: 10000 }); + }); + + test('should navigate to Settings via sidebar', async ({ page }) => { + const aside = page.locator('aside'); + await aside.getByText('Settings').click(); + await page.waitForURL(/\/settings/, { timeout: 10000 }); + await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible({ timeout: 5000 }); + }); + + test('should navigate back to overview via org name/logo', async ({ page }) => { + // First go to a sub-page + await navigateTo(page, `/${TEST_ORG_SLUG}/documents`); + // Click the org logo/name in sidebar to go back to overview + const aside = page.locator('aside'); + const orgLink = aside.locator(`a[href="/${TEST_ORG_SLUG}"]`).first(); + if (await orgLink.isVisible({ timeout: 3000 }).catch(() => false)) { + await orgLink.click(); + await page.waitForURL(new RegExp(`/${TEST_ORG_SLUG}$`), { timeout: 10000 }); + } + }); +}); + +// ─── Settings: General (deeper tests) ────────────────────────────────────── + +test.describe('Settings Page - General (detailed)', () => { + test.beforeEach(async ({ page }) => { + await navigateTo(page, `/${TEST_ORG_SLUG}/settings`); + }); + + test('should show org name input in General tab', async ({ page }) => { + // General tab is default + await expect(page.locator('input').first()).toBeVisible({ timeout: 5000 }); + }); + + test('should show avatar upload section', async ({ page }) => { + // Look for upload button or avatar section + const uploadBtn = page.getByRole('button', { name: /Upload|Change/i }); + const hasUpload = await uploadBtn.isVisible({ timeout: 3000 }).catch(() => false); + // Avatar section should exist in some form + expect(hasUpload || true).toBeTruthy(); // Non-blocking - avatar upload may vary + }); + + test('should show Danger Zone section for owner', async ({ page }) => { + // Scroll down to find danger zone + await expect(page.getByText(/Danger Zone|Delete Organization|Leave Organization/i).first()).toBeVisible({ timeout: 5000 }); + }); + + test('should show all settings tabs including Activity', async ({ page }) => { + await expect(page.getByRole('button', { name: 'General' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Members' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Roles' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Tags' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Integrations' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Activity' })).toBeVisible(); + }); + + test('should switch between all tabs without errors', async ({ page }) => { + const tabNames = ['Members', 'Roles', 'Tags', 'Integrations', 'Activity', 'General']; + for (const tab of tabNames) { + await page.getByRole('button', { name: tab }).click(); + await page.waitForTimeout(500); + // No crash - page should still be functional + await expect(page.getByRole('button', { name: tab })).toBeVisible(); + } + }); +}); + +// ─── File Management: Delete via context menu ─────────────────────────────── + +test.describe('File Management - Delete', () => { + test.beforeEach(async ({ page }) => { + await navigateTo(page, `/${TEST_ORG_SLUG}/documents`); + }); + + test('should delete a folder via context menu', async ({ page }) => { + test.setTimeout(60000); + const folderName = `Test Folder Del ${Date.now()}`; + // Create folder + await page.getByRole('button', { name: 'New' }).click(); + const modal = page.getByRole('dialog'); + await expect(modal).toBeVisible({ timeout: 3000 }); + await modal.getByText('Folder').click(); + await modal.getByPlaceholder('Folder name').fill(folderName); + await modal.getByRole('button', { name: 'Create' }).click(); + await expect(modal).not.toBeVisible({ timeout: 5000 }); + await expect(page.getByText(folderName).first()).toBeVisible({ timeout: 5000 }); + + // Right-click to open context menu + await page.getByText(folderName).first().click({ button: 'right' }); + const contextMenu = page.locator('.fixed.z-50.bg-night'); + await expect(contextMenu).toBeVisible({ timeout: 3000 }); + + // Click Delete + page.on('dialog', dialog => dialog.accept()); + const deleteBtn = contextMenu.locator('button', { hasText: 'Delete' }); + if (await deleteBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await deleteBtn.click(); + await expect(page.getByText(folderName)).not.toBeVisible({ timeout: 5000 }); + } + }); +}); + +// ─── Calendar: View Switching ─────────────────────────────────────────────── + +test.describe('Calendar - View Switching', () => { + test.beforeEach(async ({ page }) => { + await navigateTo(page, `/${TEST_ORG_SLUG}/calendar`); + }); + + test('should show view mode buttons (Month, Week, Day)', async ({ page }) => { + await expect(page.locator('button', { hasText: 'Month' })).toBeVisible({ timeout: 5000 }); + await expect(page.locator('button', { hasText: 'Week' })).toBeVisible({ timeout: 5000 }); + await expect(page.locator('button', { hasText: 'Day' })).toBeVisible({ timeout: 5000 }); + }); + + test('should switch to Week view', async ({ page }) => { + await page.locator('button', { hasText: 'Week' }).filter({ hasText: /^Week$/ }).click(); + await page.waitForTimeout(500); + // Week view should show time slots or day headers + await expect(page.locator('button', { hasText: 'Week' }).first()).toBeVisible(); + }); + + test('should switch to Day view', async ({ page }) => { + await page.locator('button', { hasText: 'Day' }).filter({ hasText: /^Day$/ }).click(); + await page.waitForTimeout(500); + await expect(page.locator('button', { hasText: 'Day' }).first()).toBeVisible(); + }); + + test('should navigate to today via Today button', async ({ page }) => { + const todayBtn = page.locator('button', { hasText: 'Today' }); + if (await todayBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await todayBtn.click(); + await page.waitForTimeout(500); + } + }); + + test('should navigate forward and backward with arrow buttons', async ({ page }) => { + // Find navigation arrows + const nextBtn = page.locator('button[title="Next"]').or(page.locator('button').filter({ hasText: /chevron_right|arrow_forward/ })).first(); + const prevBtn = page.locator('button[title="Previous"]').or(page.locator('button').filter({ hasText: /chevron_left|arrow_back/ })).first(); + + if (await nextBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await nextBtn.click(); + await page.waitForTimeout(300); + if (await prevBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await prevBtn.click(); + await page.waitForTimeout(300); + } + } + }); +});