fix: stabilize post-MVP travel-agent and itinerary workflows

This commit is contained in:
2026-03-08 16:51:19 +00:00
parent fb2347345f
commit 2fd11dbd26
27 changed files with 2533 additions and 794 deletions

View File

@@ -51,9 +51,7 @@
if (browser) {
init({
fallbackLocale: locales.includes(navigator.language.split('-')[0])
? navigator.language.split('-')[0]
: 'en',
fallbackLocale: 'en',
initialLocale: data.locale
});
// get the locale cookie if it exists and set it as the initial locale if it exists

View File

@@ -16,6 +16,14 @@ type MFAAuthenticatorResponse = {
}[];
};
type UserAPIKey = {
id: string;
provider: string;
masked_api_key: string;
created_at: string;
updated_at: string;
};
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) {
return redirect(302, '/');
@@ -85,6 +93,21 @@ export const load: PageServerLoad = async (event) => {
let wandererEnabled = integrations.wanderer.exists as boolean;
let wandererExpired = integrations.wanderer.expired as boolean;
let apiKeys: UserAPIKey[] = [];
let apiKeysConfigError: string | null = null;
let apiKeysFetch = await fetch(`${endpoint}/api/integrations/api-keys/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (apiKeysFetch.ok) {
apiKeys = (await apiKeysFetch.json()) as UserAPIKey[];
} else if (apiKeysFetch.status === 503) {
const errorBody = (await apiKeysFetch.json()) as { detail?: string };
apiKeysConfigError = errorBody.detail ?? 'API key storage is currently unavailable.';
}
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
let publicUrl = '';
if (!publicUrlFetch.ok) {
@@ -101,10 +124,13 @@ export const load: PageServerLoad = async (event) => {
authenticators,
immichIntegration,
publicUrl,
mcpTokenHeaderFormat: 'Authorization: Token <token>',
socialProviders,
googleMapsEnabled,
stravaGlobalEnabled,
stravaUserEnabled,
apiKeys,
apiKeysConfigError,
wandererEnabled,
wandererExpired
}

View File

@@ -16,7 +16,6 @@
import WandererLogoSrc from '$lib/assets/wanderer.svg';
export let data: PageData;
console.log(data);
let user: User;
let emails: typeof data.props.emails;
if (data.user) {
@@ -37,6 +36,21 @@
let stravaUserEnabled = data.props.stravaUserEnabled;
let wandererEnabled = data.props.wandererEnabled;
let wandererExpired = data.props.wandererExpired;
type UserAPIKey = {
id: string;
provider: string;
masked_api_key: string;
created_at: string;
updated_at: string;
};
let userApiKeys: UserAPIKey[] = data.props.apiKeys ?? [];
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
let newApiKeyProvider = 'google_maps';
let newApiKeyValue = '';
let isSavingApiKey = false;
let deletingApiKeyId: string | null = null;
let mcpToken: string | null = null;
let isLoadingMcpToken = false;
let activeSection: string = 'profile';
// typed alias for social providers to satisfy TypeScript
@@ -82,6 +96,7 @@
{ id: 'security', icon: '🔒', label: () => $t('settings.security') },
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
{ id: 'ai_api_keys', icon: '🤖', label: () => $t('settings.ai_api_keys') },
{ id: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') },
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
@@ -401,6 +416,162 @@
newWandererIntegration.password = '';
}
}
function getApiKeysErrorMessage(errorBody: any): string {
if (errorBody?.detail) {
return errorBody.detail;
}
if (errorBody?.api_key?.[0]) {
return errorBody.api_key[0];
}
if (errorBody?.provider?.[0]) {
return errorBody.provider[0];
}
return $t('settings.api_keys_generic_error');
}
async function addUserApiKey(event: SubmitEvent) {
event.preventDefault();
if (!newApiKeyValue.trim()) {
addToast('error', $t('settings.api_keys_value_required'));
return;
}
isSavingApiKey = true;
try {
const res = await fetch('/api/integrations/api-keys/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
provider: newApiKeyProvider,
api_key: newApiKeyValue
})
});
let payload: any = null;
try {
payload = await res.json();
} catch {
payload = null;
}
if (res.ok && payload) {
const existingIndex = userApiKeys.findIndex((key) => key.provider === payload.provider);
if (existingIndex >= 0) {
const updated = [...userApiKeys];
updated[existingIndex] = payload;
userApiKeys = updated.sort((a, b) => a.provider.localeCompare(b.provider));
} else {
userApiKeys = [...userApiKeys, payload].sort((a, b) => a.provider.localeCompare(b.provider));
}
newApiKeyValue = '';
apiKeysConfigError = null;
addToast('success', $t('settings.api_keys_saved'));
return;
}
if (res.status === 503) {
apiKeysConfigError = getApiKeysErrorMessage(payload);
addToast('error', $t('settings.api_keys_config_unavailable'));
return;
}
addToast('error', getApiKeysErrorMessage(payload));
} catch {
addToast('error', $t('settings.api_keys_generic_error'));
} finally {
isSavingApiKey = false;
}
}
async function deleteUserApiKey(apiKey: UserAPIKey) {
deletingApiKeyId = apiKey.id;
try {
const res = await fetch(`/api/integrations/api-keys/${apiKey.id}/`, {
method: 'DELETE'
});
if (res.ok || res.status === 204) {
userApiKeys = userApiKeys.filter((key) => key.id !== apiKey.id);
addToast('success', $t('settings.api_keys_deleted'));
return;
}
let payload: any = null;
try {
payload = await res.json();
} catch {
payload = null;
}
if (res.status === 503) {
apiKeysConfigError = getApiKeysErrorMessage(payload);
addToast('error', $t('settings.api_keys_config_unavailable'));
return;
}
addToast('error', getApiKeysErrorMessage(payload));
} catch {
addToast('error', $t('settings.api_keys_generic_error'));
} finally {
deletingApiKeyId = null;
}
}
function getMaskedMcpToken(token: string): string {
if (token.length <= 8) {
return '••••••••';
}
return `${token.slice(0, 4)}••••••••${token.slice(-4)}`;
}
async function fetchOrCreateMcpToken() {
isLoadingMcpToken = true;
try {
const res = await fetch('/auth/mcp-token/', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!res.ok) {
addToast('error', $t('settings.generic_error'));
return;
}
const payload = (await res.json()) as { token?: string };
if (!payload.token) {
addToast('error', $t('settings.generic_error'));
return;
}
mcpToken = payload.token;
addToast('success', 'MCP token ready.');
} catch {
addToast('error', $t('settings.generic_error'));
} finally {
isLoadingMcpToken = false;
}
}
async function copyMcpAuthHeader() {
if (!mcpToken) {
addToast('error', 'Generate token first.');
return;
}
const authHeader = `Authorization: Token ${mcpToken}`;
try {
await navigator.clipboard.writeText(authHeader);
addToast('success', $t('adventures.copied_to_clipboard'));
} catch {
addToast('error', $t('adventures.copy_failed'));
}
}
</script>
{#if isMFAModalOpen}
@@ -1292,6 +1463,189 @@
</div>
{/if}
<!-- AI API Keys Section -->
{#if activeSection === 'ai_api_keys'}
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
<div class="flex items-center gap-4 mb-6">
<div class="p-3 bg-primary/10 rounded-xl">
<span class="text-2xl">🤖</span>
</div>
<div>
<h2 class="text-2xl font-bold">{$t('settings.ai_api_keys')}</h2>
<p class="text-base-content/70">
{$t('settings.ai_api_keys_desc')}
</p>
</div>
</div>
{#if apiKeysConfigError}
<div class="alert alert-warning mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<div>
<p class="font-semibold">{$t('settings.api_keys_config_unavailable')}</p>
<p class="text-sm">{apiKeysConfigError}</p>
<p class="text-sm mt-1">{$t('settings.api_keys_config_guidance')}</p>
</div>
</div>
{/if}
<div class="alert alert-info mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="font-semibold">{$t('settings.travel_agent_help_title')}</p>
<p class="text-sm">{$t('settings.travel_agent_help_body')}</p>
<p class="text-sm mt-1 flex flex-wrap gap-3">
<a class="link link-primary" href="/collections"
>{$t('settings.travel_agent_help_open_collections')}</a
>
<a
class="link link-primary"
href="https://voyage.app/docs/usage/usage.html"
target="_blank"
rel="noopener noreferrer"
>{$t('settings.travel_agent_help_setup_guide')}</a
>
</p>
</div>
</div>
<div class="p-6 bg-base-200 rounded-xl mb-6">
<h3 class="text-lg font-semibold mb-2">MCP Access Token</h3>
<p class="text-sm text-base-content/70 mb-4">
Create or fetch your personal token for MCP clients. The same token is reused if one
already exists.
</p>
<div class="flex flex-wrap gap-3 mb-4">
<button
class="btn btn-primary"
on:click={fetchOrCreateMcpToken}
disabled={isLoadingMcpToken}
>
{#if isLoadingMcpToken}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{mcpToken ? 'Refresh token' : 'Get MCP token'}
</button>
<button
class="btn btn-outline"
on:click={copyMcpAuthHeader}
disabled={!mcpToken}
>
{$t('settings.copy')}
</button>
</div>
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-base-content/60">Token</div>
<div class="font-mono text-sm p-3 rounded-lg bg-base-100 border border-base-300">
{mcpToken ? getMaskedMcpToken(mcpToken) : 'Not generated yet'}
</div>
</div>
<div class="mt-4 p-4 bg-base-100 rounded-lg border border-base-300">
<div class="text-sm font-medium mb-1">Use this exact auth header format</div>
<div class="font-mono text-sm">{data.props.mcpTokenHeaderFormat}</div>
</div>
</div>
<div class="p-6 bg-base-200 rounded-xl mb-6">
<h3 class="text-lg font-semibold mb-4">{$t('settings.saved_api_keys')}</h3>
{#if userApiKeys.length === 0}
<p class="text-base-content/70">{$t('settings.no_api_keys_saved')}</p>
{:else}
<div class="space-y-3">
{#each userApiKeys as apiKey}
<div class="flex items-center justify-between gap-4 p-4 bg-base-100 rounded-lg">
<div>
<div class="font-medium">{apiKey.provider}</div>
<div class="text-sm text-base-content/70 font-mono">
{apiKey.masked_api_key}
</div>
</div>
<button
class="btn btn-sm btn-error"
on:click={() => deleteUserApiKey(apiKey)}
disabled={deletingApiKeyId === apiKey.id}
>
{#if deletingApiKeyId === apiKey.id}
<span class="loading loading-spinner loading-xs"></span>
{/if}
{$t('adventures.remove')}
</button>
</div>
{/each}
</div>
{/if}
</div>
<div class="p-6 bg-base-200 rounded-xl">
<h3 class="text-lg font-semibold mb-4">{$t('settings.add_api_key')}</h3>
<form class="space-y-4" on:submit={addUserApiKey}>
<div class="form-control">
<label class="label" for="api-key-provider">
<span class="label-text font-medium">{$t('settings.provider')}</span>
</label>
<select
id="api-key-provider"
class="select select-bordered select-primary w-full"
bind:value={newApiKeyProvider}
>
<option value="google_maps">Google Maps</option>
</select>
</div>
<div class="form-control">
<label class="label" for="api-key-value">
<span class="label-text font-medium">{$t('settings.api_key_value')}</span>
</label>
<input
id="api-key-value"
type="password"
class="input input-bordered input-primary focus:input-primary"
bind:value={newApiKeyValue}
placeholder={$t('settings.api_key_value_placeholder')}
required
autocomplete="off"
/>
<p class="text-sm text-base-content/70 mt-1">
{$t('settings.api_key_write_only_hint')}
</p>
</div>
<button class="btn btn-primary" type="submit" disabled={isSavingApiKey}>
{#if isSavingApiKey}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{$t('settings.save_api_key')}
</button>
</form>
</div>
</div>
{/if}
<!-- import export -->
{#if activeSection === 'import_export'}
<div class="bg-base-100 rounded-2xl shadow-xl p-8">