fix(manacore): auth flow and dashboard widget API fixes

Auth fixes:
- Update fetchInterceptor skip patterns for ManaCore auth endpoints
- Fix URL matching to compare full origins instead of partial matches
- Update token manager state after successful login
- Remove Supabase session dependency from layouts
- Use authStore for auth state in route layouts

Dashboard fixes:
- Add network error detection in base-client to prevent infinite retries
- Update all 9 dashboard widgets to not retry on service unavailable
- Add /api/v1 prefix to all backend service URLs (chat, calendar, contacts, todo, zitare, picture, manadeck)

Commands:
- Add dev:manacore:backends to start all 9 dashboard backends
- Add dev:manacore:full to start web + all backends together
- Update COMMANDS.md with new commands and backend port table

Auth service:
- Fix TypeScript error: crossApp → cross_app in referrals schema

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-07 14:44:58 +01:00
parent ee52f6c144
commit a6cc0b83aa
33 changed files with 2634 additions and 68 deletions

View file

@ -9,7 +9,8 @@ declare global {
user: User | null;
}
interface PageData {
session: Session | null;
// Auth is handled by Mana Core Auth (@manacore/shared-auth), not Supabase
// Supabase is used for database operations only
supabase?: SupabaseClient;
}
// interface Error {}

View file

@ -39,6 +39,21 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Check if error is a network/connection error that shouldn't be retried
*/
function isNetworkError(error: Error): boolean {
const message = error.message.toLowerCase();
return (
message.includes('failed to fetch') ||
message.includes('network') ||
message.includes('connection refused') ||
message.includes('err_connection_refused') ||
message.includes('econnrefused') ||
message.includes('load failed')
);
}
/**
* Fetch with authentication and retry logic
*
@ -93,7 +108,16 @@ export async function fetchWithRetry<T>(
const data = await response.json();
return { data, error: null };
} catch (e) {
lastError = e instanceof Error ? e.message : 'Unknown error';
const error = e instanceof Error ? e : new Error('Unknown error');
lastError = error.message;
// Don't retry on network errors (service unavailable)
if (isNetworkError(error)) {
return {
data: null,
error: 'Service nicht erreichbar',
};
}
// Don't retry on last attempt
if (attempt < config.maxRetries) {

View file

@ -7,7 +7,7 @@
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const CALENDAR_API_URL = import.meta.env.PUBLIC_CALENDAR_API_URL || 'http://localhost:3014';
const CALENDAR_API_URL = import.meta.env.PUBLIC_CALENDAR_API_URL || 'http://localhost:3014/api/v1';
const client = createApiClient(CALENDAR_API_URL);

View file

@ -7,7 +7,7 @@
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const CHAT_API_URL = import.meta.env.PUBLIC_CHAT_API_URL || 'http://localhost:3002';
const CHAT_API_URL = import.meta.env.PUBLIC_CHAT_API_URL || 'http://localhost:3002/api/v1';
const client = createApiClient(CHAT_API_URL);

View file

@ -7,7 +7,7 @@
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const CONTACTS_API_URL = import.meta.env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
const CONTACTS_API_URL = import.meta.env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015/api/v1';
const client = createApiClient(CONTACTS_API_URL);

View file

@ -7,7 +7,7 @@
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const MANADECK_API_URL = import.meta.env.PUBLIC_MANADECK_API_URL || 'http://localhost:3009';
const MANADECK_API_URL = import.meta.env.PUBLIC_MANADECK_API_URL || 'http://localhost:3009/api/v1';
const client = createApiClient(MANADECK_API_URL);
@ -64,21 +64,21 @@ export const manadeckService = {
* Get user's decks
*/
async getDecks(): Promise<ApiResult<Deck[]>> {
return client.get<Deck[]>('/api/decks');
return client.get<Deck[]>('/decks');
},
/**
* Get learning progress
*/
async getLearningProgress(): Promise<ApiResult<LearningProgress>> {
return client.get<LearningProgress>('/api/progress');
return client.get<LearningProgress>('/progress');
},
/**
* Get cards due for review today
*/
async getDueCards(limit = 10): Promise<ApiResult<Card[]>> {
return client.get<Card[]>(`/api/cards/due?limit=${limit}`);
return client.get<Card[]>(`/cards/due?limit=${limit}`);
},
/**

View file

@ -7,7 +7,7 @@
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const PICTURE_API_URL = import.meta.env.PUBLIC_PICTURE_API_URL || 'http://localhost:3006';
const PICTURE_API_URL = import.meta.env.PUBLIC_PICTURE_API_URL || 'http://localhost:3006/api/v1';
const client = createApiClient(PICTURE_API_URL);
@ -49,21 +49,21 @@ export const pictureService = {
* Get user's recent generations
*/
async getRecentGenerations(limit = 6): Promise<ApiResult<GeneratedImage[]>> {
return client.get<GeneratedImage[]>(`/api/generations?limit=${limit}&sort=createdAt:desc`);
return client.get<GeneratedImage[]>(`/generations?limit=${limit}&sort=createdAt:desc`);
},
/**
* Get user's favorite images
*/
async getFavorites(limit = 6): Promise<ApiResult<GeneratedImage[]>> {
return client.get<GeneratedImage[]>(`/api/generations?favorite=true&limit=${limit}`);
return client.get<GeneratedImage[]>(`/generations?favorite=true&limit=${limit}`);
},
/**
* Get generation statistics
*/
async getStats(): Promise<ApiResult<GenerationStats>> {
return client.get<GenerationStats>('/api/stats');
return client.get<GenerationStats>('/stats');
},
/**

View file

@ -7,7 +7,7 @@
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const TODO_API_URL = import.meta.env.PUBLIC_TODO_API_URL || 'http://localhost:3017';
const TODO_API_URL = import.meta.env.PUBLIC_TODO_API_URL || 'http://localhost:3017/api/v1';
const client = createApiClient(TODO_API_URL);

View file

@ -7,7 +7,7 @@
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const ZITARE_API_URL = import.meta.env.PUBLIC_ZITARE_API_URL || 'http://localhost:3007';
const ZITARE_API_URL = import.meta.env.PUBLIC_ZITARE_API_URL || 'http://localhost:3007/api/v1';
const client = createApiClient(ZITARE_API_URL);

View file

@ -31,7 +31,9 @@
error = result.error;
state = 'error';
if (retryCount < 3) {
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}

View file

@ -31,7 +31,9 @@
error = result.error;
state = 'error';
if (retryCount < 3) {
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}

View file

@ -37,7 +37,9 @@
error = timersResult.error || alarmsResult.error || statsResult.error;
state = 'error';
if (retryCount < 3) {
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}

View file

@ -36,7 +36,9 @@
error = result.error;
state = 'error';
if (retryCount < 3) {
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
@ -75,7 +77,7 @@
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">=Ç</div>
<div class="mb-2 text-3xl">=<EFBFBD></div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.contacts.empty')}
</p>
@ -127,7 +129,7 @@
rel="noopener"
class="mt-3 block text-center text-sm text-primary hover:underline"
>
{$_('dashboard.widgets.contacts.view_all')}
{$_('dashboard.widgets.contacts.view_all')} <EFBFBD>
</a>
{/if}
</div>

View file

@ -34,7 +34,9 @@
error = progressResult.error || decksResult.error;
state = 'error';
if (retryCount < 3) {
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}

View file

@ -31,7 +31,9 @@
error = result.error;
state = 'error';
if (retryCount < 3) {
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}

View file

@ -31,8 +31,9 @@
error = result.error;
state = 'error';
// Auto-retry up to 3 times
if (retryCount < 3) {
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}

View file

@ -31,7 +31,9 @@
error = result.error;
state = 'error';
if (retryCount < 3) {
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}

View file

@ -34,7 +34,9 @@
error = result.error;
state = 'error';
if (retryCount < 3) {
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
@ -53,7 +55,7 @@
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>=¡</span>
<span>=<EFBFBD></span>
{$_('dashboard.widgets.zitare.title')}
</h3>
{#if state === 'success' && data}
@ -108,7 +110,7 @@
rel="noopener"
class="block text-center text-sm text-primary hover:underline"
>
{$_('dashboard.widgets.zitare.view_all')}
{$_('dashboard.widgets.zitare.view_all')} <EFBFBD>
</a>
</div>
{/if}

View file

@ -94,6 +94,15 @@ export const authStore = {
const userData = await authService.getUserFromToken();
user = userData;
// Mark as initialized so initialize() doesn't override
initialized = true;
loading = false;
// Update token manager state to reflect valid token
if (_tokenManager) {
await _tokenManager.getValidToken();
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';

View file

@ -137,6 +137,9 @@
});
onMount(async () => {
// Initialize auth store first
await authStore.initialize();
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('manacore-nav-sidebar');
if (savedSidebar === 'true') {
@ -151,19 +154,19 @@
collapsedStore.set(true);
}
// Load user settings from server
// Load user settings from server (don't await - let it load in background)
if (authStore.isAuthenticated) {
await userSettings.load();
// Redirect to start page if on /dashboard and a custom start page is set
const currentPath = window.location.pathname;
if (
currentPath === '/dashboard' &&
userSettings.startPage &&
userSettings.startPage !== '/dashboard'
) {
goto(userSettings.startPage, { replaceState: true });
}
userSettings.load().then(() => {
// Redirect to start page if on /dashboard and a custom start page is set
const currentPath = window.location.pathname;
if (
currentPath === '/dashboard' &&
userSettings.startPage &&
userSettings.startPage !== '/dashboard'
) {
goto(userSettings.startPage, { replaceState: true });
}
});
}
loading = false;

View file

@ -1,11 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
let { data, children }: { data: any; children: Snippet } = $props();
let { children }: { children: Snippet } = $props();
// Redirect authenticated users to dashboard
// Auth state is managed by Mana Core Auth via authStore
$effect(() => {
if (data.session) {
if (authStore.initialized && !authStore.loading && authStore.isAuthenticated) {
goto('/dashboard');
}
});

View file

@ -12,6 +12,8 @@ export const load: LayoutLoad = async ({ data, depends }) => {
*/
depends('supabase:auth');
// Create Supabase client for database operations only
// Auth is handled by Mana Core Auth (@manacore/shared-auth)
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch,
@ -27,14 +29,7 @@ export const load: LayoutLoad = async ({ data, depends }) => {
},
});
/**
* It's fine to use `getSession` here, because on the client, `getSession` is
* safe, and on the server, it reads `session` from the `LayoutData`, which
* safely checked the session using `safeGetSession`.
*/
const {
data: { session },
} = await supabase.auth.getSession();
return { session, supabase };
// Note: Auth session is managed by Mana Core Auth via authStore,
// not Supabase auth. Supabase is used for database operations only.
return { supabase };
};