diff --git a/src/lib/helpers/oauth2-authorization-details.ts b/src/lib/helpers/oauth2-authorization-details.ts index d26f051797..3d6caf7440 100644 --- a/src/lib/helpers/oauth2-authorization-details.ts +++ b/src/lib/helpers/oauth2-authorization-details.ts @@ -126,6 +126,11 @@ export interface ResolvedResource { export type ResourceNameMap = Map; +export interface ResourcePage { + resources: ResolvedResource[]; + hasMore: boolean; +} + function idOnlyMap(ids: string[]): ResourceNameMap { const map: ResourceNameMap = new Map(); for (const id of ids) { @@ -205,68 +210,97 @@ export async function resolveOrganizationNames(ids: string[]): Promise { +export async function searchProjects( + term: string, + offset: number, + limit: number +): Promise { try { const orgs = await getTeamOrOrganizationList([Query.limit(SEARCH_ORG_SCAN)]); const trimmed = term.trim(); - const results = await Promise.allSettled( - orgs.teams.map((org) => { - const queries = [ - Query.select(['$id', 'name', 'region', 'teamId']), - Query.limit(SEARCH_LIMIT) - ]; - if (trimmed !== '') { - queries.unshift(Query.startsWith('name', trimmed)); - } - return sdk.forConsole.organization(org.$id).listProjects({ queries }); - }) - ); + const resources: ResolvedResource[] = []; + let skip = offset; - const seen = new Set(); - const out: ResolvedResource[] = []; - for (const result of results) { - if (result.status !== 'fulfilled') continue; - for (const project of result.value.projects) { - if (project.$id === RESERVED_CONSOLE_PROJECT || seen.has(project.$id)) continue; - seen.add(project.$id); - out.push({ + for (const org of orgs.teams) { + const pageLimit = limit + 1 - resources.length; + const queries = [ + Query.notEqual('$id', RESERVED_CONSOLE_PROJECT), + Query.offset(skip), + Query.limit(pageLimit), + Query.select(['$id', 'name', 'region', 'teamId']) + ]; + if (trimmed !== '') { + queries.unshift(Query.startsWith('name', trimmed)); + } + + const result = await sdk.forConsole + .organization(org.$id) + .listProjects({ queries }) + .catch(() => null); + if (!result) continue; + + if (skip >= result.total) { + skip -= result.total; + continue; + } + skip = 0; + + for (const project of result.projects) { + resources.push({ id: project.$id, name: project.name, region: project.region, resolved: true }); - if (out.length >= SEARCH_LIMIT) return out; } + if (resources.length > limit) break; } - return out; + + return { + resources: resources.slice(0, limit), + hasMore: resources.length > limit + }; } catch { - return []; + return { resources: [], hasMore: false }; } } /** - * Load the user's organizations as resources for the org picker. Organizations - * are few enough to load once and filter client-side, so this returns the full - * list; the picker searches within it. Never throws. + * Search the user's organizations by name using offset pagination. Never + * throws: returns an empty page on failure. */ -export async function listOrganizationResources(): Promise { +export async function searchOrganizations( + term: string, + offset: number, + limit: number +): Promise { try { - const orgs = await getTeamOrOrganizationList([Query.limit(100)]); - return orgs.teams.map((org) => ({ id: org.$id, name: org.name, resolved: true })); + const queries = [Query.offset(offset), Query.limit(limit)]; + const trimmed = term.trim(); + // startsWith over search: search is full-text (word-boundary) matching, + // which drops partial-fragment matches; this mirrors the project search. + if (trimmed !== '') queries.push(Query.startsWith('name', trimmed)); + + const orgs = await getTeamOrOrganizationList(queries); + return { + resources: orgs.teams.map((org) => ({ + id: org.$id, + name: org.name, + resolved: true + })), + hasMore: offset + orgs.teams.length < orgs.total + }; } catch { - return []; + return { resources: [], hasMore: false }; } } diff --git a/src/lib/helpers/oauth2-mcp.ts b/src/lib/helpers/oauth2-mcp.ts index c859188883..a45d6e2046 100644 --- a/src/lib/helpers/oauth2-mcp.ts +++ b/src/lib/helpers/oauth2-mcp.ts @@ -28,8 +28,8 @@ import { * client gains nothing by claiming the MCP resource URI. */ -/** Canonical resource URI of the hosted Appwrite MCP server. */ -export const DEFAULT_MCP_RESOURCE_URLS = ['https://mcp.appwrite.io/mcp']; +/** Resource URIs advertised by the hosted Appwrite MCP server. */ +export const DEFAULT_MCP_RESOURCE_URLS = ['https://mcp.appwrite.io', 'https://mcp.appwrite.io/mcp']; /** Mirror of the authorization server's `scope` parameter length cap. */ export const MAX_SCOPE_PARAM_LENGTH = 8192; diff --git a/src/lib/helpers/oauth2-scopes.ts b/src/lib/helpers/oauth2-scopes.ts index 79fc427c35..9cc3554bb5 100644 --- a/src/lib/helpers/oauth2-scopes.ts +++ b/src/lib/helpers/oauth2-scopes.ts @@ -403,6 +403,14 @@ const ORGANIZATION_RESOURCE_COPY: Record = { name: 'Development keys', desc: 'Development keys used to bypass rate limits while building locally.' }, + 'organization.memberships': { + name: 'Organization memberships', + desc: 'Memberships that control who belongs to this organization and their roles.' + }, + organization: { + name: 'Organization', + desc: "This organization's name, settings, and other general configuration." + }, domains: { name: 'Organization domains', desc: 'Custom domains owned and managed at the organization level.' @@ -468,6 +476,8 @@ export interface PermissionGroup { heading: string; /** Contextual note under the heading, e.g. which resources it applies to. */ note?: string; + /** Whether the consent screen lets the user collapse this group. */ + collapsible?: boolean; lines: PermissionLine[]; } @@ -658,6 +668,7 @@ export function buildConsentPermissions(model: ConsentScopeModel): PermissionGro groups.push({ heading: 'Projects', note: 'Applies only to the projects you select below.', + collapsible: true, lines: projectLines }); } @@ -672,6 +683,7 @@ export function buildConsentPermissions(model: ConsentScopeModel): PermissionGro groups.push({ heading: 'Organizations', note: 'Applies only to the organizations you select below.', + collapsible: true, lines: organizationLines }); } diff --git a/src/routes/(public)/oauth2/consent-card.svelte b/src/routes/(public)/oauth2/consent-card.svelte index 7c47086080..68018bb108 100644 --- a/src/routes/(public)/oauth2/consent-card.svelte +++ b/src/routes/(public)/oauth2/consent-card.svelte @@ -11,9 +11,9 @@ IconOfficeBuilding, IconLockClosed, IconExclamationCircle, - IconDuplicate, IconAdjustments, - IconShieldCheck + IconShieldCheck, + IconSwitchHorizontal } from '@appwrite.io/pink-icons-svelte'; import { Button, Form } from '$lib/elements/forms'; import { addNotification } from '$lib/stores/notifications'; @@ -39,13 +39,13 @@ mergeIdentifiers, serializeGrantedDetails, searchProjects, - listOrganizationResources, + searchOrganizations, resolveProjectNames, resolveOrganizationNames, PROJECT_RAR_TYPE, ORGANIZATION_RAR_TYPE, WILDCARD_IDENTIFIER, - type ResolvedResource, + type ResourcePage, type ResourceNameMap } from '$lib/helpers/oauth2-authorization-details'; import ResourceSelector from './resource-selector.svelte'; @@ -61,31 +61,16 @@ } } - // Scope tokens are never rendered inline — they add noise. Instead each row - // exposes a hover copy button that yields the raw token, exactly as issued - // (prefix included), for anyone who needs the precise string. - let copiedToken = $state(null); - let copyTimer: ReturnType | null = null; - async function copyToken(token: string) { - try { - await navigator.clipboard.writeText(token); - copiedToken = token; - if (copyTimer) clearTimeout(copyTimer); - copyTimer = setTimeout(() => (copiedToken = null), 1500); - } catch { - /* clipboard unavailable — nothing to surface */ - } - } - interface Props { grant: Models.Oauth2Grant; app: Models.App; accountLabel?: string; flow: OAuth2Flow; onDone?: (outcome: OAuth2Outcome, redirectUrl?: string) => void; + onSwitchAccount?: () => void | Promise; } - let { grant, app, accountLabel = undefined, flow, onDone }: Props = $props(); + let { grant, app, accountLabel = undefined, flow, onDone, onSwitchAccount }: Props = $props(); let error = $state(null); let approving = $state(false); @@ -130,6 +115,34 @@ const permissionGroups = $derived(buildConsentPermissions(scopeModel)); let showPermissions = $state(true); + let permissionGroupOpen = $state>({}); + + // Account menu — the chip doubles as a trigger for switching accounts. + let accountMenuOpen = $state(false); + let accountMenuEl = $state(null); + + $effect(() => { + if (!accountMenuOpen) return; + const onPointerDown = (event: PointerEvent) => { + if (accountMenuEl && !accountMenuEl.contains(event.target as Node)) { + accountMenuOpen = false; + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') accountMenuOpen = false; + }; + window.addEventListener('pointerdown', onPointerDown, true); + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('pointerdown', onPointerDown, true); + window.removeEventListener('keydown', onKeyDown); + }; + }); + + function switchAccount() { + accountMenuOpen = false; + void onSwitchAccount?.(); + } // MCP grants (detected via the grant's RFC 8707 resources) get a narrowing // editor: the client requested the full scope catalog, so consent is the @@ -150,6 +163,8 @@ let readOnlyAll = $state(false); let projectSelection = $state({}); let organizationSelection = $state({}); + let projectPermissionsOpen = $state(true); + let organizationPermissionsOpen = $state(true); $effect(() => { grant.$id; @@ -159,6 +174,9 @@ readOnlyAll = false; projectSelection = {}; organizationSelection = {}; + projectPermissionsOpen = true; + organizationPermissionsOpen = true; + permissionGroupOpen = {}; }); const projectRows = $derived( @@ -217,6 +235,22 @@ return rows.every((row) => rowState(selection, row.resource).selected); } + function tierPermissionsOpen(tier: 'project' | 'organization'): boolean { + return tier === 'project' ? projectPermissionsOpen : organizationPermissionsOpen; + } + + function toggleTierPermissions(tier: 'project' | 'organization') { + if (tier === 'project') projectPermissionsOpen = !projectPermissionsOpen; + else organizationPermissionsOpen = !organizationPermissionsOpen; + } + + function togglePermissionGroup(heading: string) { + permissionGroupOpen = { + ...permissionGroupOpen, + [heading]: permissionGroupOpen[heading] === false + }; + } + const projectGranted = $derived(projectRequested && projectSelected.length > 0); const organizationGranted = $derived(organizationRequested && organizationSelected.length > 0); @@ -246,15 +280,11 @@ // Resource search wiring. Projects search server-side (there can be many); // organizations are few, so they load once and filter client-side. - function findProjects(term: string): Promise { - return searchProjects(term); + function findProjects(term: string, offset: number, limit: number): Promise { + return searchProjects(term, offset, limit); } - let orgCache: ResolvedResource[] | null = null; - async function findOrganizations(term: string): Promise { - if (!orgCache) orgCache = await listOrganizationResources(); - const t = term.trim().toLowerCase(); - const list = t ? orgCache.filter((o) => o.name.toLowerCase().includes(t)) : orgCache; - return list.slice(0, 8); + function findOrganizations(term: string, offset: number, limit: number): Promise { + return searchOrganizations(term, offset, limit); } function resolveProjects(ids: string[]): Promise { return resolveProjectNames(ids); @@ -371,90 +401,94 @@ {#if rows.length > 0}
- +
+ + +
{note}
-
    - {#each rows as row (row.resource)} - {@const state = rowState(selection, row.resource)} -
  • - - - - {row.title} - {#if row.hasRead && row.hasWrite} - - - - - {:else} - - {row.access} - + {#if tierPermissionsOpen(tierKey)} +
      + {#each rows as row (row.resource)} + {@const state = rowState(selection, row.resource)} +
    • + + + + {row.title} + {#if row.hasRead && row.hasWrite} + + + + + {:else} + + {row.access} + + {/if} + + {#if row.description} + {row.description} {/if} - {#if row.description} - {row.description} - {/if} - -
    • - {/each} -
    +
  • + {/each} +
+ {/if}
{/if} {/snippet} -{#snippet copyBtn(token: string)} - -{/snippet} - {#if accountLabel} - + {#if onSwitchAccount} + + {:else} + + {/if} {/if} @@ -608,42 +686,71 @@ {#if showPermissions}
{#each permissionGroups as group (group.heading)} + {@const collapsible = group.collapsible === true}
- - {group.heading} - + {#if collapsible} + + {:else} + + {group.heading} + + {/if} {#if group.note} {group.note} {/if}
-
    - {#each group.lines as line (line.token)} -
  • - - - - - - {line.title} - {#if line.access} - {line.access} + {#if !collapsible || permissionGroupOpen[group.heading] !== false} +
      + {#each group.lines as line (line.token)} +
    • + + + + + + {line.title} + {#if line.access} + {line.access} + {/if} + + {#if line.description} + {line.description} {/if} - {#if line.description} - {line.description} - {/if} - - {@render copyBtn(line.token)} -
    • - {/each} -
    +
  • + {/each} +
+ {/if}
{/each}
@@ -868,6 +975,116 @@ background: var(--bgcolor-neutral-primary); } + .account-chip.interactive { + padding-inline-end: 0.6rem; + cursor: pointer; + font: inherit; + font-size: 0.8125rem; + transition: + background 0.15s ease, + border-color 0.15s ease; + } + + .account-chip.interactive:hover:not(:disabled), + .account-chip.interactive.open { + background: var(--overlay-neutral-hover, var(--bgcolor-neutral-secondary)); + border-color: var(--border-neutral-strong); + } + + .account-chip.interactive:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + .account-menu { + position: relative; + display: flex; + justify-content: center; + max-width: 100%; + } + + .account-dropdown { + position: absolute; + top: calc(100% + 0.4rem); + z-index: 10; + width: 19rem; + max-width: calc(100vw - 2rem); + padding: 0.35rem; + border: 1px solid var(--border-neutral); + border-radius: var(--border-radius-m, 0.6rem); + background: var(--bgcolor-neutral-primary); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + text-align: start; + } + + .account-dropdown-current { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.55rem 0.6rem; + border-radius: var(--border-radius-s, 0.5rem); + } + + .account-dropdown-text { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; + flex: 1; + } + + .account-dropdown-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.85rem; + font-weight: 500; + color: var(--fgcolor-neutral-primary); + } + + .account-dropdown-check { + flex-shrink: 0; + color: var(--fgcolor-success); + } + + .account-dropdown-action { + display: flex; + align-items: center; + gap: 0.65rem; + width: 100%; + margin-top: 0.25rem; + padding: 0.55rem 0.6rem; + border: none; + border-top: 1px solid var(--border-neutral); + border-radius: 0 0 var(--border-radius-s, 0.5rem) var(--border-radius-s, 0.5rem); + background: transparent; + color: var(--fgcolor-neutral-primary); + font: inherit; + font-size: 0.85rem; + cursor: pointer; + text-align: start; + } + + .account-dropdown-action:hover:not(:disabled) { + background: var(--overlay-neutral-hover, var(--bgcolor-neutral-secondary)); + } + + .account-dropdown-action:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + .account-dropdown-action-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + border: 1px dashed var(--border-neutral-strong); + color: var(--fgcolor-neutral-secondary); + } + .account-avatar { flex-shrink: 0; width: 1.4rem; @@ -882,6 +1099,12 @@ font-weight: 600; } + .account-avatar.large { + width: 2rem; + height: 2rem; + font-size: 0.85rem; + } + .account-label { overflow: hidden; text-overflow: ellipsis; @@ -946,6 +1169,31 @@ gap: 0.1rem; } + .perm-group-heading-row { + display: flex; + align-items: center; + gap: 0.6rem; + } + + .perm-group-toggle, + .permission-group-toggle { + display: flex; + align-items: center; + justify-content: space-between; + flex: 1; + min-width: 0; + padding: 0; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + text-align: start; + } + + .permission-group-toggle { + width: 100%; + } + .perm-list { list-style: none; margin: 0; @@ -1027,44 +1275,6 @@ color: var(--fgcolor-neutral-secondary); } - .copy-btn { - position: absolute; - right: 0; - bottom: 0.55rem; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 1.5rem; - height: 1.5rem; - padding: 0; - border: none; - border-radius: var(--border-radius-xs, 0.3rem); - background: var(--bgcolor-neutral-primary); - color: var(--fgcolor-neutral-tertiary); - cursor: pointer; - opacity: 0; - transition: - opacity 0.12s ease, - background 0.12s ease, - color 0.12s ease; - } - - .perm:hover .copy-btn, - .copy-btn:focus-visible, - .copy-btn.copied { - opacity: 1; - } - - .copy-btn:hover { - background: var(--overlay-neutral-hover, var(--bgcolor-neutral-secondary)); - color: var(--fgcolor-neutral-primary); - } - - .copy-btn.copied { - color: var(--fgcolor-success); - } - /* ---- MCP narrowing editor ----------------------------------------------- */ .editor-panel { display: flex; diff --git a/src/routes/(public)/oauth2/consent/+page.svelte b/src/routes/(public)/oauth2/consent/+page.svelte index 09c3080636..81553b1727 100644 --- a/src/routes/(public)/oauth2/consent/+page.svelte +++ b/src/routes/(public)/oauth2/consent/+page.svelte @@ -7,6 +7,7 @@ import { IconExclamation } from '@appwrite.io/pink-icons-svelte'; import { Button } from '$lib/elements/forms'; import { sdk } from '$lib/stores/sdk'; + import { logout } from '$lib/helpers/logout'; import { isWebRedirect } from '$lib/helpers/oauth2-redirect'; import OAuth2ConsentCard, { type OAuth2Outcome } from '../consent-card.svelte'; import OAuth2OutcomeCard from '../outcome-card.svelte'; @@ -21,6 +22,17 @@ let account = $state(null); let error = $state(null); let completedRedirectUrl = $state(undefined); + let accountSwitchResumeUrl = $state(null); + + const ACCOUNT_SWITCH_STORAGE_PREFIX = 'oauth2-account-switch:'; + + function rememberAccountSwitchUrl(key: string, url: string) { + sessionStorage.setItem(`${ACCOUNT_SWITCH_STORAGE_PREFIX}${key}`, url); + } + + function accountSwitchUrlFor(key: string): string | null { + return sessionStorage.getItem(`${ACCOUNT_SWITCH_STORAGE_PREFIX}${key}`); + } // OIDC `max_age` is a non-negative integer count of seconds. Reject anything // else (e.g. `max_age=abc`) so we omit the param rather than forwarding NaN. @@ -84,6 +96,7 @@ } async function resumeFromGrant(grantId: string, cancelled: () => boolean): Promise { + accountSwitchResumeUrl = accountSwitchUrlFor(grantId); try { await loadConsent(grantId, cancelled); } catch (e: unknown) { @@ -118,6 +131,9 @@ return; } if (result.grantId) { + if (accountSwitchResumeUrl) { + rememberAccountSwitchUrl(result.grantId, accountSwitchResumeUrl); + } if (fromRequestUri) { // The handle is now consumed — rewrite to the grant URL so // reloads resume via getGrant instead of a dead request_uri. @@ -138,6 +154,7 @@ requestUri: string, cancelled: () => boolean ): Promise { + accountSwitchResumeUrl = accountSwitchUrlFor(requestUri); const loggedInAccount = await getAccount(); if (cancelled()) return; @@ -176,6 +193,8 @@ params: URLSearchParams, cancelled: () => boolean ): Promise { + const authorizeUrl = window.location.pathname + window.location.search; + accountSwitchResumeUrl = authorizeUrl; const loggedInAccount = await getAccount(); if (cancelled()) return; @@ -189,6 +208,7 @@ ...readAuthorizeParams(params) }); if (cancelled()) return; + rememberAccountSwitchUrl(par.request_uri, authorizeUrl); goSignIn( `${resolve('/oauth2/consent')}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(par.request_uri)}` ); @@ -243,6 +263,17 @@ phase = outcome === 'approved' ? 'approved' : 'denied'; } + async function switchAccount() { + if (!accountSwitchResumeUrl) return; + phase = 'loading'; + try { + await logout(false); + goSignIn(accountSwitchResumeUrl); + } catch (e: unknown) { + fail(e, 'Failed to switch accounts'); + } + } + // A $derived string so identity-only replacements of page.url (e.g. login // calling invalidate(ACCOUNT) right after goto) don't restart the flow — // a restart would cancel an in-flight authorize and re-dereference an @@ -261,6 +292,7 @@ phase = 'loading'; error = null; completedRedirectUrl = undefined; + accountSwitchResumeUrl = null; void init(params, () => run !== currentRun); @@ -303,6 +335,7 @@ {app} accountLabel={account?.email || account?.name || undefined} flow="authorization" + onSwitchAccount={accountSwitchResumeUrl ? switchAccount : undefined} {onDone} /> {:else if phase === 'approved' || phase === 'denied'} Promise; + /** Paginated live search over the selectable universe. */ + find: (term: string, offset: number, limit: number) => Promise; /** Resolve specific requested ids to display names. */ resolveNames: (ids: string[]) => Promise; disabled?: boolean; @@ -58,6 +61,8 @@ let term = $state(''); let results = $state([]); let searching = $state(false); + let loadingMore = $state(false); + let hasMore = $state(false); function labelFor(id: string): ResolvedResource { return names[id] ?? { id, name: id, resolved: false }; @@ -82,12 +87,16 @@ const t = term; let cancelled = false; searching = true; + loadingMore = false; + hasMore = false; + results = []; const timer = setTimeout(() => { - void find(t).then((res) => { + void find(t, 0, PAGE_SIZE).then((page) => { if (cancelled) return; - results = res; + results = page.resources; + hasMore = page.hasMore; const merged = { ...names }; - for (const r of res) merged[r.id] = r; + for (const r of page.resources) merged[r.id] = r; names = merged; searching = false; }); @@ -98,6 +107,34 @@ }; }); + async function loadMore() { + if (searching || loadingMore || !hasMore) return; + const currentTerm = term; + const offset = results.length; + loadingMore = true; + try { + const page = await find(currentTerm, offset, PAGE_SIZE); + if (currentTerm !== term) return; + + const seen = new Set(results.map((resource) => resource.id)); + const next = page.resources.filter((resource) => !seen.has(resource.id)); + results = [...results, ...next]; + hasMore = page.hasMore; + const merged = { ...names }; + for (const resource of next) merged[resource.id] = resource; + names = merged; + } finally { + loadingMore = false; + } + } + + function handleResultsScroll(event: Event) { + const list = event.currentTarget as HTMLElement; + if (list.scrollTop + list.clientHeight >= list.scrollHeight - 24) { + void loadMore(); + } + } + const suggestions = $derived(results.filter((r) => !selected.includes(r.id))); function setAll(all: boolean) { @@ -107,6 +144,7 @@ function add(id: string) { if (selected.includes(id)) return; selected = [...selected.filter((x) => x !== WILDCARD_IDENTIFIER), id]; + if (hasMore) queueMicrotask(() => void loadMore()); } function remove(id: string) { @@ -187,7 +225,7 @@ bind:value={term} placeholder={`Search ${pluralLabel} by name`} {disabled} /> -
    +
      {#if searching}
    • {:else if suggestions.length === 0} @@ -217,6 +255,9 @@ {/each} + {#if loadingMore} +
    • + {/if} {/if}
    {/if}