diff --git a/apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte b/apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte index 1c9d974ca..5484da169 100644 --- a/apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte +++ b/apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte @@ -11,7 +11,7 @@ import { SPACE_TYPES, SPACE_TYPE_LABELS, SPACE_TYPE_DESCRIPTIONS } from '@mana/shared-branding'; import type { SpaceType } from '@mana/shared-types'; - import { loadActiveSpace } from '$lib/data/scope'; + import { loadActiveSpace, authFetch } from '$lib/data/scope'; interface Props { open: boolean; @@ -74,10 +74,8 @@ if (legalEntity.trim()) metadata.legalEntity = legalEntity.trim(); try { - const res = await fetch('/api/auth/organization/create', { + const res = await authFetch('/api/auth/organization/create', { method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: name.trim(), slug: derivedSlug || undefined, @@ -90,10 +88,8 @@ } const created = (await res.json()) as { id: string }; // Activate the new space so the user lands inside it on reload. - await fetch('/api/auth/organization/set-active', { + await authFetch('/api/auth/organization/set-active', { method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ organizationId: created.id }), }); await loadActiveSpace({ force: true }); diff --git a/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte b/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte index f03dd9df6..3ffb24491 100644 --- a/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte +++ b/apps/mana/apps/web/src/lib/components/layout/SpaceSwitcher.svelte @@ -11,7 +11,7 @@ */ import { onDestroy } from 'svelte'; - import { getActiveSpace, loadActiveSpace, type ActiveSpace } from '$lib/data/scope'; + import { getActiveSpace, loadActiveSpace, authFetch, type ActiveSpace } from '$lib/data/scope'; import { SPACE_TYPE_LABELS } from '@mana/shared-branding'; import { isSpaceType, isSpaceTier } from '@mana/shared-types'; import SpaceCreateDialog from './SpaceCreateDialog.svelte'; @@ -57,7 +57,7 @@ loadingList = true; loadError = null; try { - const res = await fetch('/api/auth/organization/list', { credentials: 'include' }); + const res = await authFetch('/api/auth/organization/list'); if (!res.ok) throw new Error(`list failed: ${res.status}`); const raw = (await res.json()) as Array<{ id: string; @@ -97,10 +97,8 @@ } switching = true; try { - const res = await fetch('/api/auth/organization/set-active', { + const res = await authFetch('/api/auth/organization/set-active', { method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ organizationId: id }), }); if (!res.ok) throw new Error(`set-active failed: ${res.status}`); diff --git a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts index 23f78527c..a2d9c7160 100644 --- a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts +++ b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts @@ -11,6 +11,7 @@ import type { SpaceType, SpaceTier } from '@mana/shared-types'; import { isSpaceType, isSpaceTier } from '@mana/shared-types'; +import { authFetch } from './auth-fetch'; export interface ActiveSpace { id: string; @@ -129,8 +130,11 @@ export const __endpoints = { }; async function fetchActiveMember(): Promise { - const res = await fetch(__endpoints.active, { credentials: 'include' }); - if (res.status === 404) return null; // no active org + const res = await authFetch(__endpoints.active); + // Better Auth returns 400/404 when no organization is active yet — + // treat both as "not active" so the bootstrap can fall through to + // auto-activation. + if (res.status === 404 || res.status === 400) return null; if (!res.ok) throw new Error(`get-active-member failed: ${res.status}`); const raw = (await res.json()) as { role?: string; @@ -141,17 +145,15 @@ async function fetchActiveMember(): Promise { } async function fetchOrganizations(): Promise { - const res = await fetch(__endpoints.list, { credentials: 'include' }); + const res = await authFetch(__endpoints.list); if (!res.ok) throw new Error(`organization/list failed: ${res.status}`); const raws = (await res.json()) as RawOrg[]; return raws.map((r) => rawToActiveSpace(r, 'owner')); } async function setActiveOnServer(organizationId: string): Promise { - const res = await fetch(__endpoints.setActive, { + const res = await authFetch(__endpoints.setActive, { method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ organizationId }), }); if (!res.ok) throw new Error(`organization/set-active failed: ${res.status}`); diff --git a/apps/mana/apps/web/src/lib/data/scope/auth-fetch.ts b/apps/mana/apps/web/src/lib/data/scope/auth-fetch.ts new file mode 100644 index 000000000..e344c13b1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/scope/auth-fetch.ts @@ -0,0 +1,41 @@ +/** + * Thin wrapper around fetch that prepends the mana-auth base URL and + * always includes credentials. The web app does NOT proxy /api/auth + * through SvelteKit — every Better Auth request goes directly to + * mana-auth at http://localhost:3001 (dev) or https://auth.mana.how + * (prod). Using a relative `/api/auth/...` path 404s against the + * SvelteKit dev server. + * + * Resolution order (same as packages/shared-auth/authService): + * 1. window.__PUBLIC_MANA_AUTH_URL__ — set by the page loader + * 2. process.env.PUBLIC_MANA_AUTH_URL — build-time env + * 3. http://localhost:3001 — dev fallback + */ + +import { browser } from '$app/environment'; + +export function authBaseUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_AUTH_URL__?: string }) + .__PUBLIC_MANA_AUTH_URL__; + if (injected) return injected.replace(/\/$/, ''); + } + return (process.env.PUBLIC_MANA_AUTH_URL || 'http://localhost:3001').replace(/\/$/, ''); +} + +/** + * Fetch against the mana-auth server. Always sends cookies (credentials + * include) so Better Auth can resolve the session. Path must start with + * a slash — e.g. `/api/auth/organization/list`. + */ +export function authFetch(path: string, init: RequestInit = {}): Promise { + const url = `${authBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`; + return fetch(url, { + credentials: 'include', + ...init, + headers: { + ...(init.body ? { 'content-type': 'application/json' } : {}), + ...(init.headers ?? {}), + }, + }); +} diff --git a/apps/mana/apps/web/src/lib/data/scope/index.ts b/apps/mana/apps/web/src/lib/data/scope/index.ts index c85fdb066..4b4622c27 100644 --- a/apps/mana/apps/web/src/lib/data/scope/index.ts +++ b/apps/mana/apps/web/src/lib/data/scope/index.ts @@ -33,3 +33,5 @@ export { } from './scoped-db'; export { applyVisibility, isVisibleToCurrentUser, type Visibility } from './visibility'; + +export { authFetch, authBaseUrl } from './auth-fetch'; diff --git a/apps/mana/apps/web/src/routes/(app)/spaces/members/+page.svelte b/apps/mana/apps/web/src/routes/(app)/spaces/members/+page.svelte index b8c9d31f2..3fb942481 100644 --- a/apps/mana/apps/web/src/routes/(app)/spaces/members/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/spaces/members/+page.svelte @@ -12,7 +12,7 @@ */ import { onMount } from 'svelte'; - import { getActiveSpace } from '$lib/data/scope'; + import { getActiveSpace, authFetch } from '$lib/data/scope'; import { SPACE_TYPE_LABELS } from '@mana/shared-branding'; interface Member { @@ -52,13 +52,11 @@ loadError = null; try { const [memRes, invRes] = await Promise.all([ - fetch( - `/api/auth/organization/list-members?organizationId=${encodeURIComponent(activeSpace.id)}`, - { credentials: 'include' } + authFetch( + `/api/auth/organization/list-members?organizationId=${encodeURIComponent(activeSpace.id)}` ), - fetch( - `/api/auth/organization/list-invitations?organizationId=${encodeURIComponent(activeSpace.id)}`, - { credentials: 'include' } + authFetch( + `/api/auth/organization/list-invitations?organizationId=${encodeURIComponent(activeSpace.id)}` ), ]); if (memRes.ok) { @@ -92,10 +90,8 @@ inviteError = null; inviteSuccess = null; try { - const res = await fetch('/api/auth/organization/invite-member', { + const res = await authFetch('/api/auth/organization/invite-member', { method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole, @@ -117,10 +113,8 @@ } async function cancelInvitation(id: string) { - const res = await fetch('/api/auth/organization/cancel-invitation', { + const res = await authFetch('/api/auth/organization/cancel-invitation', { method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ invitationId: id }), }); if (res.ok) await refresh(); @@ -129,10 +123,8 @@ async function removeMember(userId: string) { if (!activeSpace) return; if (!confirm('Mitglied wirklich entfernen?')) return; - const res = await fetch('/api/auth/organization/remove-member', { + const res = await authFetch('/api/auth/organization/remove-member', { method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ memberIdOrEmail: userId, organizationId: activeSpace.id }), }); if (res.ok) await refresh(); diff --git a/apps/mana/apps/web/src/routes/accept-invitation/+page.svelte b/apps/mana/apps/web/src/routes/accept-invitation/+page.svelte index 7d0a21ce7..4f02fc5e8 100644 --- a/apps/mana/apps/web/src/routes/accept-invitation/+page.svelte +++ b/apps/mana/apps/web/src/routes/accept-invitation/+page.svelte @@ -16,7 +16,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { SPACE_TYPE_LABELS } from '@mana/shared-branding'; import { isSpaceType, type SpaceType } from '@mana/shared-types'; - import { loadActiveSpace } from '$lib/data/scope'; + import { loadActiveSpace, authFetch } from '$lib/data/scope'; interface InvitationPayload { id: string; @@ -52,9 +52,8 @@ loading = true; loadError = null; try { - const res = await fetch( - `/api/auth/organization/get-invitation?id=${encodeURIComponent(invitationId)}`, - { credentials: 'include' } + const res = await authFetch( + `/api/auth/organization/get-invitation?id=${encodeURIComponent(invitationId)}` ); if (!res.ok) { throw new Error(`Einladung nicht gefunden (${res.status})`); @@ -82,20 +81,16 @@ submitting = true; actionError = null; try { - const res = await fetch('/api/auth/organization/accept-invitation', { + const res = await authFetch('/api/auth/organization/accept-invitation', { method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ invitationId }), }); if (!res.ok) { throw new Error(await res.text()); } // Activate the newly-joined space so the dashboard opens inside it. - await fetch('/api/auth/organization/set-active', { + await authFetch('/api/auth/organization/set-active', { method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ organizationId: invitation.organizationId }), }); await loadActiveSpace({ force: true }); @@ -111,10 +106,8 @@ submitting = true; actionError = null; try { - const res = await fetch('/api/auth/organization/reject-invitation', { + const res = await authFetch('/api/auth/organization/reject-invitation', { method: 'POST', - credentials: 'include', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ invitationId }), }); if (!res.ok) throw new Error(await res.text());