Skip to content

Commit c7905b4

Browse files
Improve OAuth2 consent screen UX
Recognize both hosted MCP resource URIs, add missing organization scope descriptions, make permission groups collapsible, paginate project and organization pickers with infinite scroll, remove per-scope copy buttons, and add account switching via a dropdown on the account chip. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f348973 commit c7905b4

6 files changed

Lines changed: 543 additions & 220 deletions

File tree

src/lib/helpers/oauth2-authorization-details.ts

Lines changed: 69 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export interface ResolvedResource {
126126

127127
export type ResourceNameMap = Map<string, ResolvedResource>;
128128

129+
export interface ResourcePage {
130+
resources: ResolvedResource[];
131+
hasMore: boolean;
132+
}
133+
129134
function idOnlyMap(ids: string[]): ResourceNameMap {
130135
const map: ResourceNameMap = new Map();
131136
for (const id of ids) {
@@ -205,68 +210,95 @@ export async function resolveOrganizationNames(ids: string[]): Promise<ResourceN
205210
/* Type-to-search — narrowing a wildcard grant to specific resources */
206211
/* -------------------------------------------------------------------------- */
207212

208-
/** How many results a resource search returns. Keeps the dropdown bounded and
209-
* sidesteps pagination — the user types to narrow rather than scrolling. */
210-
const SEARCH_LIMIT = 8;
211213
/** Cap on organizations swept per project search, to bound the request fan-out. */
212214
const SEARCH_ORG_SCAN = 20;
213215

214216
/**
215217
* Search the user's projects by name for the resource picker. Projects are only
216218
* reachable through their owning organization, so we sweep the user's orgs and
217-
* ask each for name matches, merge, and cap. An empty term returns the first
218-
* projects found (so the picker can show something before the user types).
219-
* Never throws: returns an empty list on failure.
219+
* ask each for name matches. Results are exposed as a global paginated list
220+
* across those organizations so the picker can load more as the user scrolls.
221+
* Never throws: returns an empty page on failure.
220222
*/
221-
export async function searchProjects(term: string): Promise<ResolvedResource[]> {
223+
export async function searchProjects(
224+
term: string,
225+
offset: number,
226+
limit: number
227+
): Promise<ResourcePage> {
222228
try {
223229
const orgs = await getTeamOrOrganizationList([Query.limit(SEARCH_ORG_SCAN)]);
224230
const trimmed = term.trim();
225-
const results = await Promise.allSettled(
226-
orgs.teams.map((org) => {
227-
const queries = [
228-
Query.select(['$id', 'name', 'region', 'teamId']),
229-
Query.limit(SEARCH_LIMIT)
230-
];
231-
if (trimmed !== '') {
232-
queries.unshift(Query.startsWith('name', trimmed));
233-
}
234-
return sdk.forConsole.organization(org.$id).listProjects({ queries });
235-
})
236-
);
231+
const resources: ResolvedResource[] = [];
232+
let skip = offset;
237233

238-
const seen = new Set<string>();
239-
const out: ResolvedResource[] = [];
240-
for (const result of results) {
241-
if (result.status !== 'fulfilled') continue;
242-
for (const project of result.value.projects) {
243-
if (project.$id === RESERVED_CONSOLE_PROJECT || seen.has(project.$id)) continue;
244-
seen.add(project.$id);
245-
out.push({
234+
for (const org of orgs.teams) {
235+
const pageLimit = limit + 1 - resources.length;
236+
const queries = [
237+
Query.notEqual('$id', RESERVED_CONSOLE_PROJECT),
238+
Query.offset(skip),
239+
Query.limit(pageLimit),
240+
Query.select(['$id', 'name', 'region', 'teamId'])
241+
];
242+
if (trimmed !== '') {
243+
queries.unshift(Query.startsWith('name', trimmed));
244+
}
245+
246+
const result = await sdk.forConsole
247+
.organization(org.$id)
248+
.listProjects({ queries })
249+
.catch(() => null);
250+
if (!result) continue;
251+
252+
if (skip >= result.total) {
253+
skip -= result.total;
254+
continue;
255+
}
256+
skip = 0;
257+
258+
for (const project of result.projects) {
259+
resources.push({
246260
id: project.$id,
247261
name: project.name,
248262
region: project.region,
249263
resolved: true
250264
});
251-
if (out.length >= SEARCH_LIMIT) return out;
252265
}
266+
if (resources.length > limit) break;
253267
}
254-
return out;
268+
269+
return {
270+
resources: resources.slice(0, limit),
271+
hasMore: resources.length > limit
272+
};
255273
} catch {
256-
return [];
274+
return { resources: [], hasMore: false };
257275
}
258276
}
259277

260278
/**
261-
* Load the user's organizations as resources for the org picker. Organizations
262-
* are few enough to load once and filter client-side, so this returns the full
263-
* list; the picker searches within it. Never throws.
279+
* Search the user's organizations by name using offset pagination. Never
280+
* throws: returns an empty page on failure.
264281
*/
265-
export async function listOrganizationResources(): Promise<ResolvedResource[]> {
282+
export async function searchOrganizations(
283+
term: string,
284+
offset: number,
285+
limit: number
286+
): Promise<ResourcePage> {
266287
try {
267-
const orgs = await getTeamOrOrganizationList([Query.limit(100)]);
268-
return orgs.teams.map((org) => ({ id: org.$id, name: org.name, resolved: true }));
288+
const queries = [Query.offset(offset), Query.limit(limit)];
289+
const trimmed = term.trim();
290+
if (trimmed !== '') queries.push(Query.search('name', trimmed));
291+
292+
const orgs = await getTeamOrOrganizationList(queries);
293+
return {
294+
resources: orgs.teams.map((org) => ({
295+
id: org.$id,
296+
name: org.name,
297+
resolved: true
298+
})),
299+
hasMore: offset + orgs.teams.length < orgs.total
300+
};
269301
} catch {
270-
return [];
302+
return { resources: [], hasMore: false };
271303
}
272304
}

src/lib/helpers/oauth2-mcp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ import {
2828
* client gains nothing by claiming the MCP resource URI.
2929
*/
3030

31-
/** Canonical resource URI of the hosted Appwrite MCP server. */
32-
export const DEFAULT_MCP_RESOURCE_URLS = ['https://mcp.appwrite.io/mcp'];
31+
/** Resource URIs advertised by the hosted Appwrite MCP server. */
32+
export const DEFAULT_MCP_RESOURCE_URLS = ['https://mcp.appwrite.io', 'https://mcp.appwrite.io/mcp'];
3333

3434
/** Mirror of the authorization server's `scope` parameter length cap. */
3535
export const MAX_SCOPE_PARAM_LENGTH = 8192;

src/lib/helpers/oauth2-scopes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,14 @@ const ORGANIZATION_RESOURCE_COPY: Record<string, ResourceCopy> = {
403403
name: 'Development keys',
404404
desc: 'Development keys used to bypass rate limits while building locally.'
405405
},
406+
'organization.memberships': {
407+
name: 'Organization memberships',
408+
desc: 'Memberships that control who belongs to this organization and their roles.'
409+
},
410+
organization: {
411+
name: 'Organization',
412+
desc: "This organization's name, settings, and other general configuration."
413+
},
406414
domains: {
407415
name: 'Organization domains',
408416
desc: 'Custom domains owned and managed at the organization level.'

0 commit comments

Comments
 (0)