This commit is contained in:
Timothy Jaeryang Baek 2026-10-12 23:22:18 +04:00
parent cc4fe3eab3
commit e9635596dd
7 changed files with 276 additions and 162 deletions

View file

@ -385,19 +385,31 @@ async def connect_app_by_id(
db: AsyncSession = Depends(get_async_session),
):
app = await get_app_by_id(app_id, db=db)
webui_url = str(request.base_url).rstrip('/')
webui_url = str(await Config.get('webui.url') or request.base_url).strip().rstrip('/')
try:
validate_app_url(webui_url)
origin = urlsplit(webui_url)
if origin.path or origin.query or origin.fragment:
raise ValueError()
if (
origin.scheme not in ('http', 'https')
or not origin.hostname
or origin.username is not None
or origin.password is not None
or origin.path
or origin.query
or origin.fragment
or '\\' in webui_url
or len(webui_url) > 2048
or any(c.isspace() for c in webui_url)
):
raise ValueError('Invalid WebUI origin')
host = f'[{origin.hostname}]' if ':' in origin.hostname else origin.hostname.lower()
port = origin.port or (443 if origin.scheme == 'https' else 80)
suffix = f':{port}' if port != (443 if origin.scheme == 'https' else 80) else ''
webui_url = f'{origin.scheme}://{host}{suffix}'
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail='Connect through your public HTTPS WebUI URL'
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid WebUI URL. Set WebUI URL in Settings > Admin > General to an HTTP(S) origin '
'without a path, query, fragment or credentials.',
) from None
if form_data.api_key is not None:
app.api_key = get_app_encryption_cipher().encrypt(form_data.api_key.get_secret_value().encode()).decode()

View file

@ -13,6 +13,8 @@
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Search from '$lib/components/icons/Search.svelte';
import Refresh from '$lib/components/icons/Refresh.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import AccessButton from '$lib/components/common/AccessButton.svelte';
@ -93,9 +95,16 @@
showScopes = true;
} else {
if (manual) {
manifest = { ...manifest, id: manifest.id || slug, name, requested_scopes: scopes };
manifest = {
...manifest,
id: manifest.id || slug,
name: app ? manifest.name : name,
requested_scopes: [...new Set([...manifest.requested_scopes, ...scopes])]
};
manifestUpdated = !!app && JSON.stringify(manifest) !== JSON.stringify(app.data.manifest);
}
if (!app) {
manifest = { ...manifest, name };
app = await createNewApp(localStorage.token, {
manifest,
url,
@ -141,7 +150,7 @@
let opened = false;
async function initialize() {
manifestUpdated = false;
manual = false;
manual = !!app;
showScopes = false;
scopeQuery = '';
url = app?.data.url ?? '';
@ -174,7 +183,7 @@
bind:accessGrants={grants}
accessRoles={['read']}
labels={{
title: $i18n.t('Who can use this app?'),
title: $i18n.t('Access'),
public: $i18n.t('Everyone'),
private: $i18n.t('Restricted'),
read: $i18n.t('Use'),
@ -305,7 +314,7 @@
bind:value={apiKey}
required={!app?.configured}
maxlength="4096"
placeholder={app?.configured ? '••••••••' : ''}
placeholder={app?.configured ? '••••••••' : $i18n.t('API Key')}
/>
</AdminSettingField>
{:else}
@ -315,7 +324,6 @@
id="app-name"
class={inputClass}
bind:value={name}
disabled={!app && !manual}
required
maxlength="100"
/></AdminSettingField
@ -354,30 +362,35 @@
</AdminSettingField>
{/if}
<AdminSettingSection title={$i18n.t('Connection')}>
<p class="break-all text-xs text-gray-600 dark:text-gray-400">{url}</p>
{#if app && !manual}
<button
type="button"
disabled={busy}
class="mt-2 text-xs text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white hover:underline"
on:click={() =>
run(async () => {
manifest = await previewAppManifest(localStorage.token, url, {
...(apiKey ? { api_key: apiKey } : {}),
...(app ? { app_id: app.id } : {})
});
scopes = scopes.filter((scope) => manifest.requested_scopes.includes(scope));
manifestUpdated = true;
showScopes = true;
})}>{$i18n.t('Update app')}</button
>
<button
type="button"
disabled={busy}
class="ml-3 mt-2 text-xs text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white hover:underline"
on:click={editDetailsManually}>{$i18n.t('Edit details manually')}</button
>
{/if}
<div class="flex items-center gap-2">
<p class="min-w-0 flex-1 break-all text-xs text-gray-600 dark:text-gray-400">{url}</p>
{#if app}
<Tooltip content={$i18n.t('Update app')}>
<button
type="button"
aria-label={$i18n.t('Update app')}
disabled={busy}
class="self-center flex size-6 shrink-0 items-center justify-center bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition disabled:opacity-50"
on:click={() =>
run(async () => {
manifest = await previewAppManifest(localStorage.token, url, {
...(apiKey ? { api_key: apiKey } : {}),
app_id: app!.id
});
scopes = scopes.filter((scope) =>
manifest.requested_scopes.includes(scope)
);
manifestUpdated = true;
showScopes = true;
})}
>
{#if busy}<Spinner className="size-3.5" />{:else}<Refresh
className="size-3.5"
/>{/if}
</button>
</Tooltip>
{/if}
</div>
{#if manifestUpdated}
<p class="mt-2 text-xs text-gray-500">
{$i18n.t('Saving approves the updated app identity and selected scopes.')}
@ -399,7 +412,7 @@
bind:value={apiKey}
required={!app?.configured}
maxlength="4096"
placeholder={app?.configured ? '••••••••' : ''}
placeholder={app?.configured ? '••••••••' : $i18n.t('API Key')}
/>
</AdminSettingField>
</AdminSettingSection>
@ -425,7 +438,7 @@
</AdminSettingRow>
</AdminSettingSection>
<AccessButton
label={$i18n.t('Who can use this app?')}
label={$i18n.t('Access')}
className="mt-5"
on:click={() => {
showGrants = true;

View file

@ -1,82 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { user } from '$lib/stores';
import { getApps, type App } from '$lib/apis/apps';
import { getChannelAppsById, inviteAppToChannel, removeAppFromChannel } from '$lib/apis/channels';
export let channel: any;
let joined: App[] = [];
let available: App[] = [];
let selected = '';
let busy = false;
$: canManage = channel?.is_manager || $user?.role === 'admin' || channel?.user_id === $user?.id;
async function load() {
try {
joined = await getChannelAppsById(localStorage.token, channel.id);
available = (await getApps(localStorage.token)).filter(
(a: App) => a.is_active && !joined.some((j) => j.id === a.id)
);
} catch (error) {
toast.error(String(error));
}
}
async function change(appId: string, remove = false) {
busy = true;
try {
if (remove) {
await removeAppFromChannel(localStorage.token, channel.id, appId);
} else {
await inviteAppToChannel(localStorage.token, channel.id, appId);
}
selected = '';
await load();
} catch (error) {
toast.error(String(error));
} finally {
busy = false;
}
}
onMount(load);
</script>
<section class="space-y-2 px-1 mt-3" aria-label="Apps">
<h3 class="text-[0.6875rem] text-gray-400 dark:text-gray-600 dark:text-gray-400">Apps</h3>
{#each joined as app}
<div class="flex items-center justify-between gap-2 text-xs text-gray-600 dark:text-gray-400">
<span
>{app.name}
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600"
>{app.is_active ? 'App' : 'Inactive'}</span
></span
>
{#if canManage}<button
type="button"
disabled={busy}
class="text-xs text-gray-400 hover:text-red-600 transition disabled:opacity-50"
on:click={() => change(app.id, true)}>Remove</button
>{/if}
</div>
{:else}<p class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
No apps have been invited.
</p>{/each}
{#if canManage && available.length}
<div class="flex gap-2">
<select
aria-label="App to invite"
bind:value={selected}
class="min-w-0 flex-1 bg-transparent text-xs text-gray-600 dark:text-gray-400 outline-hidden"
>
<option value="">Choose an app</option>{#each available as app}<option value={app.id}
>{app.name}</option
>{/each}
</select>
<button
type="button"
disabled={!selected || busy}
on:click={() => change(selected)}
class="h-7 rounded-lg px-2.5 text-xs bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 transition hover:bg-black dark:hover:bg-white disabled:opacity-50"
>Invite</button
>
</div>
{/if}
</section>

View file

@ -1,13 +1,12 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { getContext } from 'svelte';
const i18n = getContext<any>('i18n');
import Apps from './Apps.svelte';
import { config } from '$lib/stores';
import { removeMembersById } from '$lib/apis/channels';
import { config, user } from '$lib/stores';
import type { App } from '$lib/apis/apps';
import { removeMembersById, getChannelAppsById, removeAppFromChannel } from '$lib/apis/channels';
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
@ -22,6 +21,22 @@
export let onUpdate = () => {};
let showAddMembersModal = false;
let joinedApps: App[] = [];
$: canManage =
channel?.type !== 'dm' &&
(channel?.is_manager || $user?.role === 'admin' || channel?.user_id === $user?.id);
const updateMembers = async () => {
await init();
onUpdate();
};
const removeAppHandler = async (appId: string) => {
try {
await removeAppFromChannel(localStorage.token, channel.id, appId);
await updateMembers();
} catch (error) {
toast.error(`${error}`);
}
};
const submitHandler = async () => {};
const hasPublicReadGrant = (grants: any) =>
@ -59,19 +74,28 @@
}
};
const init = () => {};
const init = async () => {
joinedApps = [];
if (show && channel?.type !== 'dm' && $config?.features?.enable_apps) {
joinedApps = await getChannelAppsById(localStorage.token, channel.id).catch((error) => {
toast.error(`${error}`);
return [];
});
}
};
$: if (show) {
init();
}
onMount(() => {
init();
});
</script>
{#if channel}
<AddMembersModal bind:show={showAddMembersModal} {channel} {onUpdate} />
<AddMembersModal
bind:show={showAddMembersModal}
{channel}
{joinedApps}
onUpdate={updateMembers}
/>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-4 pt-3 mb-1">
@ -118,7 +142,9 @@
<div class="flex flex-col w-full h-full pb-2">
<UserList
{channel}
onAdd={channel?.type === 'group' && channel?.is_manager
apps={joinedApps}
onRemoveApp={canManage ? removeAppHandler : null}
onAdd={canManage && (channel?.type === 'group' || $config?.features?.enable_apps)
? () => {
showAddMembersModal = true;
}
@ -132,7 +158,6 @@
sort={channel?.type !== 'dm'}
/>
</div>
{#if $config?.features?.enable_apps && channel.type !== 'dm'}<Apps {channel} />{/if}
</form>
</div>
</div>

View file

@ -1,9 +1,11 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { getContext } from 'svelte';
const i18n = getContext<any>('i18n');
import { addMembersById } from '$lib/apis/channels';
import { addMembersById, inviteAppToChannel } from '$lib/apis/channels';
import { getApps, type App } from '$lib/apis/apps';
import { config } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
@ -11,36 +13,67 @@
import Spinner from '$lib/components/common/Spinner.svelte';
export let show = false;
export let channel = null;
export let channel: any = null;
export let joinedApps: App[] = [];
let apps: App[] = [];
let appIds: string[] = [];
$: availableApps = apps.filter(
(app) => app.is_active && !joinedApps.some((joined) => joined.id === app.id)
);
export let onUpdate = () => {};
let groupIds = [];
let userIds = [];
let groupIds: string[] = [];
let userIds: string[] = [];
let loading = false;
const submitHandler = async () => {
const res = await addMembersById(localStorage.token, channel.id, {
user_ids: userIds,
group_ids: groupIds
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
if (loading) return;
loading = true;
let changed = false;
try {
if (userIds.length || groupIds.length) {
await addMembersById(localStorage.token, channel.id, {
user_ids: userIds,
group_ids: groupIds
});
userIds = [];
groupIds = [];
changed = true;
}
for (const id of [...appIds]) {
await inviteAppToChannel(localStorage.token, channel.id, id);
appIds = appIds.filter((appId) => appId !== id);
changed = true;
}
toast.success($i18n.t('Members added successfully'));
onUpdate();
show = false;
} else {
toast.error($i18n.t('Failed to add members'));
} catch (error) {
toast.error(`${error}`);
} finally {
if (changed) onUpdate();
loading = false;
}
};
let opened = false;
$: if (show && !opened) {
opened = true;
if ($config?.features?.enable_apps && channel?.type !== 'dm') {
getApps(localStorage.token)
.then((value) => (apps = value))
.catch((error) => toast.error(`${error}`));
}
} else if (!show) {
opened = false;
apps = [];
}
const reset = () => {
userIds = [];
groupIds = [];
appIds = [];
loading = false;
};
@ -78,14 +111,21 @@
}}
>
<div class="flex flex-col w-full h-full pb-2">
<MemberSelector bind:userIds bind:groupIds includeGroups={true} />
<MemberSelector
bind:userIds
bind:groupIds
bind:appIds
apps={availableApps}
includeGroups={channel.type === 'group'}
includeUsers={channel.type === 'group'}
/>
</div>
<div class="flex justify-end pt-3 text-sm font-normal gap-1.5">
<button
class="flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white disabled:cursor-not-allowed"
type="submit"
disabled={loading}
disabled={loading || !(userIds.length || groupIds.length || appIds.length)}
>
{$i18n.t('Add')}

View file

@ -20,10 +20,18 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import ProfilePreview from '../Messages/Message/ProfilePreview.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Grid from '$lib/components/icons/Grid.svelte';
import type { App } from '$lib/apis/apps';
const i18n = getContext('i18n');
const i18n = getContext<any>('i18n');
export let channel = null;
export let apps: App[] = [];
export let onRemoveApp: ((appId: string) => Promise<void>) | null = null;
let removingApp = '';
$: filteredApps = apps.filter((app) =>
`${app.name} ${app.slug}`.toLowerCase().includes(query.toLowerCase())
);
export let onAdd = null;
export let onRemove = null;
@ -108,7 +116,7 @@
<span class="text-sm">
{$i18n.t('Members')}
</span>
<span class="text-sm text-gray-500">{total}</span>
<span class="text-sm text-gray-500">{total + filteredApps.length}</span>
</div>
{#if onAdd}
@ -155,7 +163,7 @@
</div>
{/if}
{#if users.length > 0}
{#if users.length > 0 || filteredApps.length > 0}
<div class="scrollbar-hidden relative whitespace-nowrap w-full max-w-full">
<div class=" text-sm text-left text-gray-500 dark:text-gray-400 w-full max-w-full">
<!-- <div
@ -214,6 +222,39 @@
</div>
</div> -->
<div class="w-full">
{#each filteredApps as app (app.id)}
<div class="text-xs flex items-center justify-between">
<div class="px-2 py-1.5 font-normal text-gray-900 dark:text-white flex-1 min-w-0">
<div class="flex items-center gap-2">
<div
class="size-6 rounded-lg bg-gray-100 dark:bg-gray-850 flex items-center justify-center shrink-0"
>
<Grid className="size-3.5" />
</div>
<span class="truncate">{app.name}</span>
</div>
</div>
<div class="px-2 py-1 flex items-center gap-1 translate-y-0.5">
<Badge type="muted" content={$i18n.t('App')} />
{#if onRemoveApp}
<button
type="button"
aria-label={$i18n.t('Remove {{name}}', { name: app.name })}
disabled={!!removingApp}
class="rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition disabled:opacity-50"
on:click={async () => {
removingApp = app.id;
try {
await onRemoveApp?.(app.id);
} finally {
removingApp = '';
}
}}><XMark /></button
>
{/if}
</div>
</div>
{/each}
{#each users as user, userIdx (user.id)}
<div class=" dark:border-gray-850 text-xs flex items-center justify-between">
<div class="px-2 py-1.5 font-normal text-gray-900 dark:text-white flex-1">

View file

@ -17,6 +17,8 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import { getGroups } from '$lib/apis/groups';
import type { App } from '$lib/apis/apps';
import Grid from '$lib/components/icons/Grid.svelte';
export let includeGroups = true;
export let includeUsers = true;
@ -26,6 +28,11 @@
export let groupIds: string[] = [];
export let userIds: string[] = [];
export let appIds: string[] = [];
export let apps: App[] = [];
$: filteredApps = apps.filter((app) =>
`${app.name} ${app.slug}`.toLowerCase().includes(query.toLowerCase())
);
let groups: any[] | null = null;
let filteredGroups: any[] = [];
@ -79,7 +86,7 @@
const handleSearchInput = () => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
getUserList();
if (includeUsers) getUserList();
}, 300);
};
@ -87,15 +94,20 @@
clearTimeout(searchDebounceTimer);
});
$: if (page !== null && orderBy !== null && direction !== null) {
$: if (includeUsers && page !== null && orderBy !== null && direction !== null) {
getUserList();
} else if (!includeUsers) {
users = [];
total = 0;
}
onMount(async () => {
groups = await getGroups(localStorage.token, true).catch((error) => {
console.error(error);
return [];
});
groups = includeGroups
? await getGroups(localStorage.token, true).catch((error) => {
console.error(error);
return [];
})
: [];
if (userIds.length > 0) {
userIds.forEach(async (id) => {
@ -180,6 +192,22 @@
</div>
{/if}
{#if appIds.length > 0}
<div class="mx-1 mb-1.5">
<div class="text-xs text-gray-500 mx-0.5 mb-1">{appIds.length} {$i18n.t('Apps')}</div>
<div class="flex gap-1 flex-wrap">
{#each apps.filter((app) => appIds.includes(app.id)) as app (app.id)}
<button
type="button"
class="inline-flex items-center space-x-1 px-2 py-1 bg-gray-100/50 dark:bg-gray-850 rounded-lg text-xs"
on:click={() => (appIds = appIds.filter((id) => id !== app.id))}
>
<span>{app.name}</span><XMark className="size-3" />
</button>
{/each}
</div>
</div>
{/if}
<div class="flex gap-1 mb-1">
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
@ -207,7 +235,7 @@
</div>
</div>
{#if filteredUsers.length > 0 || filteredGroups.length > 0}
{#if (includeUsers && filteredUsers.length > 0) || (includeGroups && filteredGroups.length > 0) || filteredApps.length > 0}
<div class="scrollbar-hidden relative whitespace-nowrap w-full max-w-full">
<div class=" text-sm text-left text-gray-500 dark:text-gray-400 w-full max-w-full">
<div class="w-full max-h-96 overflow-y-auto rounded-lg">
@ -311,6 +339,43 @@
{/each}
</div>
{/if}
{#if filteredApps.length > 0}
<div class="text-xs text-gray-500 mb-1 mx-1 mt-3">{$i18n.t('Apps')}</div>
<div>
{#each filteredApps as app (app.id)}
<div class="text-xs flex items-center justify-between w-full">
<button
type="button"
class="px-3 py-1.5 font-normal text-gray-900 dark:text-white flex-1 min-w-0"
on:click={() =>
(appIds = appIds.includes(app.id)
? appIds.filter((id) => id !== app.id)
: [...appIds, app.id])}
>
<div class="flex items-center gap-2">
<div
class="size-6 rounded-lg bg-gray-100 dark:bg-gray-850 flex items-center justify-center shrink-0"
>
<Grid className="size-3.5" />
</div>
<span class="truncate">{app.name}</span>
</div>
</button>
<div class="px-3 py-1">
<Checkbox
ariaLabel={app.name}
state={appIds.includes(app.id) ? 'checked' : 'unchecked'}
on:change={() =>
(appIds = appIds.includes(app.id)
? appIds.filter((id) => id !== app.id)
: [...appIds, app.id])}
/>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>