feat: add plugin system (#1034)
* feat: add plugin system * fix db docker build * fix hammerhead readme, add strava subscription news to docs * fixes and sdk improvements * fix: reduce Meilisearch load, debounce federation sync (#1012) * optimize meili trail index * several fixes --------- Co-authored-by: Flomp <Flomp@users.noreply.github.com> * Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032) Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0. - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte) --- updated-dependencies: - dependency-name: svelte dependency-version: 5.56.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Flomp <Flomp@users.noreply.github.com> * Release v0.19.2 (#1035) * chore: release v0.19.2 * add changelog --------- Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com> Co-authored-by: Christian Beutel <> * speed up plugin sync and several small fixes * concepts for security improvements and process stability * improve concept * security concept implemented * remove insecure TLS * worker concept implemented * fixes and cleanup * fixes * docu * mermaid, namings * WASM plugin host improvements, plugin logging * fix db migration * Improve plugin config and category mapping UI * fixes * further fixes * remove manual test sync * fix db migration and strava mapping * type added, UI improvements * fix plugin card toggle clickable area * optimize synch status card layout * plugin type 'trails' instead of 'integration' * session auth validation in UI * fix komoot date and waypoints * improve category mapping * fix send to hammerhead: trail name * plugin setup error handling improved * fix review findings * re-mapping added * rename remote_category --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Flomp <Flomp@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
This commit is contained in:
561
web/src/routes/settings/plugins/+page.svelte
Normal file
561
web/src/routes/settings/plugins/+page.svelte
Normal file
@@ -0,0 +1,561 @@
|
||||
<script lang="ts">
|
||||
import ConfirmModal from "$lib/components/confirm_modal.svelte";
|
||||
import PluginCard from "$lib/components/settings/plugins/plugin_card.svelte";
|
||||
import PluginInstanceSettingsModal from "$lib/components/settings/plugins/plugin_instance_settings_modal.svelte";
|
||||
import type { Category } from "$lib/models/category.js";
|
||||
import type { PluginInstance } from "$lib/models/plugin_instance.js";
|
||||
import type { PluginProvider } from "$lib/models/plugin_provider.js";
|
||||
import {
|
||||
plugin_category_remap_apply,
|
||||
plugin_category_remap_preview,
|
||||
plugin_instances_create,
|
||||
plugin_instances_update,
|
||||
} from "$lib/stores/plugin_instance_store.js";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte.js";
|
||||
import {
|
||||
pluginDescription as localizedPluginDescription,
|
||||
pluginTitle as localizedPluginTitle,
|
||||
} from "$lib/util/plugin_i18n";
|
||||
import { translatePluginError } from "$lib/util/plugin_error_i18n";
|
||||
import { onMount, tick, untrack } from "svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let instances: PluginInstance[] = $state(
|
||||
untrack(() => data.pluginInstances ?? []),
|
||||
);
|
||||
let plugins: PluginProvider[] = $state(
|
||||
untrack(() => data.pluginProviders ?? []),
|
||||
);
|
||||
let categories: Category[] = $state(untrack(() => data.categories ?? []));
|
||||
|
||||
let pluginSettingsModal: PluginInstanceSettingsModal | undefined = $state();
|
||||
let categoryRemapConfirmModal: ConfirmModal | undefined = $state();
|
||||
let selectedPlugin: PluginProvider | undefined = $state();
|
||||
let pendingCategoryRemap:
|
||||
| {
|
||||
instanceId: string;
|
||||
pluginTitle: string;
|
||||
count: number;
|
||||
backfilledSinceMapping: number;
|
||||
backfilledOnly: boolean;
|
||||
beforeSave?: boolean;
|
||||
onresolve?: (confirmed: boolean) => void;
|
||||
}
|
||||
| undefined = $state();
|
||||
let currentTheme: "dark" | "light" = $state("light");
|
||||
const applyRemapAfterSave = new Set<string>();
|
||||
const suppressRemapPromptAfterSave = new Set<string>();
|
||||
let pluginGroups = $derived.by(() => {
|
||||
const groups: { type: PluginProvider["type"]; plugins: PluginProvider[] }[] = [];
|
||||
for (const plugin of plugins) {
|
||||
let group = groups.find((candidate) => candidate.type === plugin.type);
|
||||
if (!group) {
|
||||
group = { type: plugin.type, plugins: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.plugins.push(plugin);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
queueMicrotask(() => {
|
||||
void maybePromptBackfilledCategoryRemap();
|
||||
});
|
||||
return theme.subscribe((value) => {
|
||||
currentTheme = value;
|
||||
});
|
||||
});
|
||||
|
||||
async function savePluginInstance(instance: Partial<PluginInstance>) {
|
||||
let saved: PluginInstance;
|
||||
try {
|
||||
if (instance.id) {
|
||||
saved = await plugin_instances_update(
|
||||
instance as PluginInstance,
|
||||
);
|
||||
} else {
|
||||
saved = await plugin_instances_create(instance);
|
||||
}
|
||||
|
||||
instances = [
|
||||
...instances.filter((existing) => existing.id != saved.id),
|
||||
saved,
|
||||
];
|
||||
|
||||
show_toast({
|
||||
text: $_("settings-saved"),
|
||||
icon: "check",
|
||||
type: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: $_("error-setting-up-plugin", {
|
||||
values: { provider: instance.plugin_id },
|
||||
}),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (saved.id && applyRemapAfterSave.has(saved.id)) {
|
||||
applyRemapAfterSave.delete(saved.id);
|
||||
try {
|
||||
await applyCategoryRemap(saved.id);
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: $_("plugin-category-remap-error"),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
} else if (saved.id && suppressRemapPromptAfterSave.has(saved.id)) {
|
||||
suppressRemapPromptAfterSave.delete(saved.id);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
async function onPluginToggle(
|
||||
plugin: PluginProvider,
|
||||
instance: PluginInstance | undefined,
|
||||
value: boolean,
|
||||
) {
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
if (value && pluginRequiresConnection(plugin, instance)) {
|
||||
show_toast({
|
||||
text: $_("plugin-connect-before-enabling"),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await plugin_instances_update({
|
||||
...instance,
|
||||
enabled: value,
|
||||
status: plugin.auth.type === "oauth2" || value ? "configured" : "disabled",
|
||||
});
|
||||
instances = [
|
||||
...instances.filter((existing) => existing.id != saved.id),
|
||||
saved,
|
||||
];
|
||||
if (value) {
|
||||
await maybePromptCategoryRemap(saved, plugin);
|
||||
}
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: $_("error-setting-up-plugin", { values: { provider: pluginTitle(plugin) } }),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
show_toast({
|
||||
text: pluginTitle(plugin) + " " + $_(`plugin-${value ? "enabled" : "disabled"}`),
|
||||
icon: "check",
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
|
||||
async function maybePromptCategoryRemap(
|
||||
instance: PluginInstance,
|
||||
plugin: PluginProvider | undefined,
|
||||
options: { backfilledOnly?: boolean } = {},
|
||||
) {
|
||||
if (!instance.id || !plugin) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const preview = await plugin_category_remap_preview(instance.id);
|
||||
const backfilledSinceMapping = preview.backfilledSinceMapping ?? 0;
|
||||
if (preview.count <= 0 || (options.backfilledOnly && backfilledSinceMapping <= 0)) {
|
||||
return;
|
||||
}
|
||||
pendingCategoryRemap = {
|
||||
instanceId: instance.id,
|
||||
pluginTitle: pluginTitle(plugin),
|
||||
count: preview.count,
|
||||
backfilledSinceMapping,
|
||||
backfilledOnly: options.backfilledOnly ?? false,
|
||||
};
|
||||
await tick();
|
||||
categoryRemapConfirmModal?.openModal();
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: $_("plugin-category-remap-preview-error"),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCategoryRemap(instanceId: string) {
|
||||
const result = await plugin_category_remap_apply(instanceId);
|
||||
show_toast({
|
||||
text: $_("plugin-category-remap-success", {
|
||||
values: { count: result.remapped ?? 0 },
|
||||
}),
|
||||
icon: "check",
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmCategoryRemap() {
|
||||
if (!pendingCategoryRemap) {
|
||||
return;
|
||||
}
|
||||
if (pendingCategoryRemap.beforeSave) {
|
||||
if (pendingCategoryRemap.count > 0) {
|
||||
applyRemapAfterSave.add(pendingCategoryRemap.instanceId);
|
||||
} else {
|
||||
suppressRemapPromptAfterSave.add(pendingCategoryRemap.instanceId);
|
||||
}
|
||||
pendingCategoryRemap.onresolve?.(true);
|
||||
pendingCategoryRemap = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await applyCategoryRemap(pendingCategoryRemap.instanceId);
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: $_("plugin-category-remap-error"),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
pendingCategoryRemap = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function continueWithoutCategoryRemap() {
|
||||
if (!pendingCategoryRemap?.beforeSave) {
|
||||
return;
|
||||
}
|
||||
suppressRemapPromptAfterSave.add(pendingCategoryRemap.instanceId);
|
||||
pendingCategoryRemap.onresolve?.(true);
|
||||
pendingCategoryRemap = undefined;
|
||||
}
|
||||
|
||||
async function dismissCategoryRemap() {
|
||||
if (!pendingCategoryRemap) {
|
||||
return;
|
||||
}
|
||||
if (pendingCategoryRemap.beforeSave) {
|
||||
pendingCategoryRemap.onresolve?.(false);
|
||||
pendingCategoryRemap = undefined;
|
||||
return;
|
||||
}
|
||||
const instance = instances.find(
|
||||
(candidate) => candidate.id === pendingCategoryRemap?.instanceId,
|
||||
);
|
||||
if (!instance || !pendingCategoryRemap.backfilledOnly) {
|
||||
pendingCategoryRemap = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await plugin_instances_update({
|
||||
...instance,
|
||||
config: {
|
||||
...(instance.config ?? {}),
|
||||
host: {
|
||||
...((instance.config?.host as Record<string, unknown> | undefined) ?? {}),
|
||||
categoryRemapDismissedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
instances = [
|
||||
...instances.filter((existing) => existing.id != saved.id),
|
||||
saved,
|
||||
];
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: $_("plugin-category-remap-dismiss-error"),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
pendingCategoryRemap = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function instanceError(instance: PluginInstance | undefined) {
|
||||
if (!instance?.last_error) {
|
||||
return "";
|
||||
}
|
||||
const code = instance.last_error.code?.trim();
|
||||
const message = instance.last_error.message?.trim();
|
||||
if (!code && !message) {
|
||||
return "";
|
||||
}
|
||||
return translatePluginError(code, message);
|
||||
}
|
||||
|
||||
function instanceForPlugin(plugin: PluginProvider) {
|
||||
return instances.find((instance) => instance.plugin_id == plugin.id);
|
||||
}
|
||||
|
||||
function pluginForInstance(instance: PluginInstance) {
|
||||
return plugins.find((plugin) => plugin.id === instance.plugin_id) ?? selectedPlugin;
|
||||
}
|
||||
|
||||
async function maybePromptBackfilledCategoryRemap() {
|
||||
for (const instance of instances) {
|
||||
if (pendingCategoryRemap) {
|
||||
return;
|
||||
}
|
||||
const hostConfig = (instance.config?.host as Record<string, unknown> | undefined) ?? {};
|
||||
const mappingUpdatedAt = typeof hostConfig.categoryMappingUpdatedAt === "string"
|
||||
? Date.parse(hostConfig.categoryMappingUpdatedAt)
|
||||
: 0;
|
||||
const dismissedAt = typeof hostConfig.categoryRemapDismissedAt === "string"
|
||||
? Date.parse(hostConfig.categoryRemapDismissedAt)
|
||||
: 0;
|
||||
if (dismissedAt > mappingUpdatedAt) {
|
||||
continue;
|
||||
}
|
||||
const plugin = pluginForInstance(instance);
|
||||
if (!plugin || plugin.status !== "available") {
|
||||
continue;
|
||||
}
|
||||
await maybePromptCategoryRemap(instance, plugin, { backfilledOnly: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCategoryMappingSave(candidate: Partial<PluginInstance>) {
|
||||
if (!candidate.id) {
|
||||
return true;
|
||||
}
|
||||
const plugin = plugins.find((item) => item.id === candidate.plugin_id) ?? selectedPlugin;
|
||||
if (!plugin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await plugin_category_remap_preview(candidate.id, candidate.config);
|
||||
|
||||
let resolvePrompt: (confirmed: boolean) => void = () => {};
|
||||
const result = new Promise<boolean>((resolve) => {
|
||||
resolvePrompt = resolve;
|
||||
});
|
||||
pendingCategoryRemap = {
|
||||
instanceId: candidate.id,
|
||||
pluginTitle: pluginTitle(plugin),
|
||||
count: preview.count,
|
||||
backfilledSinceMapping: 0,
|
||||
backfilledOnly: false,
|
||||
beforeSave: true,
|
||||
onresolve: resolvePrompt,
|
||||
};
|
||||
await tick();
|
||||
categoryRemapConfirmModal?.openModal();
|
||||
return await result;
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: $_("plugin-category-remap-preview-error"),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function categoryRemapConfirmText() {
|
||||
if (!pendingCategoryRemap) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (pendingCategoryRemap.backfilledOnly) {
|
||||
return $_("plugin-category-remap-backfilled-confirm", {
|
||||
values: {
|
||||
count: pendingCategoryRemap.count,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (pendingCategoryRemap.beforeSave && pendingCategoryRemap.count <= 0) {
|
||||
return $_("plugin-category-remap-confirm-unspecified");
|
||||
}
|
||||
|
||||
const confirmText = $_("plugin-category-remap-confirm", {
|
||||
values: {
|
||||
count: pendingCategoryRemap.count,
|
||||
},
|
||||
});
|
||||
if (pendingCategoryRemap.backfilledSinceMapping <= 0) {
|
||||
return confirmText;
|
||||
}
|
||||
return `${confirmText} ${$_("plugin-category-remap-sync-hint")}`;
|
||||
}
|
||||
|
||||
function categoryRemapAction() {
|
||||
if (pendingCategoryRemap?.beforeSave && pendingCategoryRemap.count <= 0) {
|
||||
return "save";
|
||||
}
|
||||
return "plugin-category-remap-action";
|
||||
}
|
||||
|
||||
function categoryRemapAlternative() {
|
||||
if (!pendingCategoryRemap?.beforeSave || pendingCategoryRemap.count <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return "plugin-category-remap-continue-without-remap";
|
||||
}
|
||||
|
||||
async function openPluginSettings(plugin: PluginProvider) {
|
||||
selectedPlugin = plugin;
|
||||
await tick();
|
||||
pluginSettingsModal?.openModal();
|
||||
}
|
||||
|
||||
function pluginLogo(plugin: PluginProvider) {
|
||||
if (currentTheme === "dark" && plugin.iconDark) {
|
||||
return plugin.iconDark;
|
||||
}
|
||||
return plugin.icon || undefined;
|
||||
}
|
||||
|
||||
function pluginTitle(plugin: PluginProvider) {
|
||||
return localizedPluginTitle(plugin, $locale);
|
||||
}
|
||||
|
||||
function pluginDescription(plugin: PluginProvider) {
|
||||
return localizedPluginDescription(plugin, $locale);
|
||||
}
|
||||
|
||||
function pluginTypeTitle(type: PluginProvider["type"]) {
|
||||
return $_(`plugin-type-${type}`);
|
||||
}
|
||||
|
||||
function pluginTypeDescription(type: PluginProvider["type"]) {
|
||||
return $_(`plugin-type-${type}-description`);
|
||||
}
|
||||
|
||||
function pluginCardError(
|
||||
plugin: PluginProvider,
|
||||
instance: PluginInstance | undefined,
|
||||
) {
|
||||
if (plugin.status != "available") {
|
||||
return plugin.error ?? "";
|
||||
}
|
||||
return instanceError(instance);
|
||||
}
|
||||
|
||||
function pluginRequiresConnection(
|
||||
plugin: PluginProvider,
|
||||
instance: PluginInstance | undefined,
|
||||
) {
|
||||
return plugin.auth.type === "oauth2" && instance?.status !== "configured";
|
||||
}
|
||||
|
||||
function pluginSettingsModalKey(plugin: PluginProvider) {
|
||||
const instance = instanceForPlugin(plugin);
|
||||
return `${plugin.id}:${instance?.id ?? "new"}:${JSON.stringify(instance?.config ?? {})}`;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("settings")} | wanderer</title>
|
||||
</svelte:head>
|
||||
|
||||
<h3 class="text-2xl font-semibold">{$_("plugins")}</h3>
|
||||
<hr class="mt-4 mb-6 border-input-border" />
|
||||
|
||||
{#if pluginGroups.length === 0}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-input-border bg-input-background px-6 py-10 text-center"
|
||||
>
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-input-border text-xl text-gray-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i class="fa fa-plug"></i>
|
||||
</div>
|
||||
<h4 class="text-lg font-semibold">{$_("plugins-empty-title")}</h4>
|
||||
<p class="mx-auto mt-2 max-w-xl text-sm text-gray-500">
|
||||
{$_("plugins-empty-description")}
|
||||
</p>
|
||||
<a
|
||||
class="btn-secondary mt-6 inline-flex items-center justify-center gap-2"
|
||||
href="https://wanderer.to/run/installation/plugins"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<i class="fa fa-book" aria-hidden="true"></i>
|
||||
{$_("plugins-empty-docs-link")}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-8">
|
||||
{#each pluginGroups as group (group.type)}
|
||||
<section>
|
||||
<div class="mb-4 space-y-2">
|
||||
<h4 class="text-xl font-medium">{pluginTypeTitle(group.type)}</h4>
|
||||
<p class="text-sm text-gray-500 max-w-3xl">
|
||||
{pluginTypeDescription(group.type)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each group.plugins as plugin (plugin.id)}
|
||||
{@const instance = instanceForPlugin(plugin)}
|
||||
{@const settingsDisabled = plugin.status != "available"}
|
||||
<PluginCard
|
||||
img={pluginLogo(plugin)}
|
||||
title={pluginTitle(plugin)}
|
||||
description={pluginDescription(plugin)}
|
||||
{settingsDisabled}
|
||||
toggleDisabled={!instance || settingsDisabled || pluginRequiresConnection(plugin, instance)}
|
||||
active={instance?.enabled ?? false}
|
||||
lastSyncAt={instance?.last_sync_at}
|
||||
error={pluginCardError(plugin, instance)}
|
||||
onclick={() => openPluginSettings(plugin)}
|
||||
ontoggle={(value) => onPluginToggle(plugin, instance, value)}
|
||||
></PluginCard>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingCategoryRemap}
|
||||
<ConfirmModal
|
||||
bind:this={categoryRemapConfirmModal}
|
||||
id="plugin-category-remap-confirm"
|
||||
title={$_("plugin-category-remap-title")}
|
||||
text={categoryRemapConfirmText()}
|
||||
action={categoryRemapAction()}
|
||||
deny={pendingCategoryRemap.backfilledOnly
|
||||
? "plugin-category-remap-ignore"
|
||||
: pendingCategoryRemap.beforeSave
|
||||
? "plugin-category-remap-back-to-settings"
|
||||
: "cancel"}
|
||||
alternative={categoryRemapAlternative()}
|
||||
onconfirm={confirmCategoryRemap}
|
||||
oncancel={dismissCategoryRemap}
|
||||
onalternative={continueWithoutCategoryRemap}
|
||||
></ConfirmModal>
|
||||
{/if}
|
||||
|
||||
{#if selectedPlugin}
|
||||
{#key pluginSettingsModalKey(selectedPlugin)}
|
||||
<PluginInstanceSettingsModal
|
||||
bind:this={pluginSettingsModal}
|
||||
plugin={selectedPlugin}
|
||||
instance={instanceForPlugin(selectedPlugin)}
|
||||
categories={categories}
|
||||
onbeforecategorymappingsave={confirmCategoryMappingSave}
|
||||
onsave={savePluginInstance}
|
||||
></PluginInstanceSettingsModal>
|
||||
{/key}
|
||||
{/if}
|
||||
13
web/src/routes/settings/plugins/+page.ts
Normal file
13
web/src/routes/settings/plugins/+page.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { plugin_instances_index } from "$lib/stores/plugin_instance_store";
|
||||
import { plugins_index } from "$lib/stores/plugin_store";
|
||||
import { categories_index } from "$lib/stores/category_store";
|
||||
import { type Load } from "@sveltejs/kit";
|
||||
|
||||
export const load: Load = async ({ fetch }) => {
|
||||
const [pluginInstances, pluginProviders, categories] = await Promise.all([
|
||||
plugin_instances_index(fetch),
|
||||
plugins_index(fetch),
|
||||
categories_index(fetch),
|
||||
]);
|
||||
return { pluginInstances, pluginProviders, categories };
|
||||
};
|
||||
47
web/src/routes/settings/plugins/oauth/callback/+page.svelte
Normal file
47
web/src/routes/settings/plugins/oauth/callback/+page.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { plugin_oauth_callback } from "$lib/stores/plugin_instance_store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { onMount } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
let error = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
const code = page.url.searchParams.get("code") ?? "";
|
||||
const state = page.url.searchParams.get("state") ?? "";
|
||||
const providerError = page.url.searchParams.get("error");
|
||||
const instanceId = sessionStorage.getItem(`wanderer_plugin_oauth_${state}`) ?? "";
|
||||
sessionStorage.removeItem(`wanderer_plugin_oauth_${state}`);
|
||||
|
||||
if (providerError) {
|
||||
error = providerError;
|
||||
return;
|
||||
}
|
||||
if (!code || !state || !instanceId) {
|
||||
error = "invalid_oauth_callback";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await plugin_oauth_callback({ instanceId, code, state });
|
||||
await goto("/settings/plugins");
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "oauth_error";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("plugins")} | wanderer</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md py-12">
|
||||
{#if error}
|
||||
<h3 class="text-xl font-semibold">{$_("error")}</h3>
|
||||
<p class="mt-3 text-sm text-gray-500">{error}</p>
|
||||
<a class="btn-secondary mt-6 inline-block" href="/settings/plugins">{$_("plugins")}</a>
|
||||
{:else}
|
||||
<h3 class="text-xl font-semibold">{$_("plugins")}</h3>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user