Skip to content

Commit b584206

Browse files
authored
Merge pull request #3116 from appwrite/feat-oauth2-consent-improvements
Improve OAuth2 consent screen UX
2 parents f348973 + fd020af commit b584206

6 files changed

Lines changed: 550 additions & 220 deletions

File tree

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

Lines changed: 71 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,97 @@ 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+
// startsWith over search: search is full-text (word-boundary) matching,
291+
// which drops partial-fragment matches; this mirrors the project search.
292+
if (trimmed !== '') queries.push(Query.startsWith('name', trimmed));
293+
294+
const orgs = await getTeamOrOrganizationList(queries);
295+
return {
296+
resources: orgs.teams.map((org) => ({
297+
id: org.$id,
298+
name: org.name,
299+
resolved: true
300+
})),
301+
hasMore: offset + orgs.teams.length < orgs.total
302+
};
269303
} catch {
270-
return [];
304+
return { resources: [], hasMore: false };
271305
}
272306
}

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: 12 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.'
@@ -468,6 +476,8 @@ export interface PermissionGroup {
468476
heading: string;
469477
/** Contextual note under the heading, e.g. which resources it applies to. */
470478
note?: string;
479+
/** Whether the consent screen lets the user collapse this group. */
480+
collapsible?: boolean;
471481
lines: PermissionLine[];
472482
}
473483

@@ -658,6 +668,7 @@ export function buildConsentPermissions(model: ConsentScopeModel): PermissionGro
658668
groups.push({
659669
heading: 'Projects',
660670
note: 'Applies only to the projects you select below.',
671+
collapsible: true,
661672
lines: projectLines
662673
});
663674
}
@@ -672,6 +683,7 @@ export function buildConsentPermissions(model: ConsentScopeModel): PermissionGro
672683
groups.push({
673684
heading: 'Organizations',
674685
note: 'Applies only to the organizations you select below.',
686+
collapsible: true,
675687
lines: organizationLines
676688
});
677689
}

0 commit comments

Comments
 (0)