diff --git a/packages/stack-shared/src/interface/clientInterface.ts b/packages/stack-shared/src/interface/clientInterface.ts index edd080520..f6ea29499 100644 --- a/packages/stack-shared/src/interface/clientInterface.ts +++ b/packages/stack-shared/src/interface/clientInterface.ts @@ -19,6 +19,7 @@ import { InternalProjectsCrud, ProjectsCrud } from './crud/projects'; import { SessionsCrud } from './crud/sessions'; import { TeamInvitationCrud } from './crud/team-invitation'; import { TeamMemberProfilesCrud } from './crud/team-member-profiles'; +import { ProjectPermissionsCrud } from './crud/project-permissions'; import { TeamPermissionsCrud } from './crud/team-permissions'; import { TeamsCrud } from './crud/teams'; @@ -1183,6 +1184,21 @@ export class StackClientInterface { return result.items; } + async listCurrentUserProjectPermissions( + options: { + recursive: boolean, + }, + session: InternalSession + ): Promise { + const response = await this.sendClientRequest( + `/project-permissions?user_id=me&recursive=${options.recursive}`, + {}, + session, + ); + const result = await response.json() as ProjectPermissionsCrud['Client']['List']; + return result.items; + } + async listCurrentUserTeams(session: InternalSession): Promise { const response = await this.sendClientRequest( "/teams?user_id=me", diff --git a/packages/stack-shared/src/interface/serverInterface.ts b/packages/stack-shared/src/interface/serverInterface.ts index 2600bfc1b..8fd6cee28 100644 --- a/packages/stack-shared/src/interface/serverInterface.ts +++ b/packages/stack-shared/src/interface/serverInterface.ts @@ -15,6 +15,7 @@ import { SessionsCrud } from "./crud/sessions"; import { TeamInvitationCrud } from "./crud/team-invitation"; import { TeamMemberProfilesCrud } from "./crud/team-member-profiles"; import { TeamMembershipsCrud } from "./crud/team-memberships"; +import { ProjectPermissionsCrud } from "./crud/project-permissions"; import { TeamPermissionsCrud } from "./crud/team-permissions"; import { TeamsCrud } from "./crud/teams"; import { UsersCrud } from "./crud/users"; @@ -195,6 +196,25 @@ export class StackServerInterface extends StackClientInterface { return result.items; } + async listServerProjectPermissions( + options: { + userId?: string, + recursive: boolean, + }, + session: InternalSession | null, + ): Promise { + const response = await this.sendServerRequest( + `/project-permissions?${new URLSearchParams(filterUndefined({ + user_id: options.userId, + recursive: options.recursive.toString(), + }))}`, + {}, + session, + ); + const result = await response.json() as ProjectPermissionsCrud['Server']['List']; + return result.items; + } + async listServerUsers(options: { cursor?: string, limit?: number, diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index bfdf2a840..fd6234793 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -46,6 +46,7 @@ let isReactServer = false; // IF_PLATFORM next import * as sc from "@stackframe/stack-sc"; import { cookies } from '@stackframe/stack-sc'; +import { ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; isReactServer = sc.isReactServer; // NextNavigation.useRouter does not exist in react-server environments and some bundlers try to be helpful and throw a warning. Ignore the warning. @@ -115,6 +116,12 @@ export class _StackClientAppImplIncomplete(async (session, [teamId, recursive]) => { return await this._interface.listCurrentUserTeamPermissions({ teamId, recursive }, session); }); + private readonly _currentUserProjectPermissionsCache = createCacheBySession< + [boolean], + ProjectPermissionsCrud['Client']['Read'][] + >(async (session, [recursive]) => { + return await this._interface.listCurrentUserProjectPermissions({ recursive }, session); + }); private readonly _currentUserTeamsCache = createCacheBySession(async (session) => { return await this._interface.listCurrentUserTeams(session); }); @@ -584,7 +591,7 @@ export class _StackClientAppImplIncomplete app._clientTeamPermissionFromCrud(crud)); }, + async listProjectPermissions(options?: { recursive?: boolean }): Promise { + const recursive = options?.recursive ?? true; + const permissions = Result.orThrow(await app._currentUserProjectPermissionsCache.getOrWait([session, recursive], "write-only")); + return permissions.map((crud) => app._clientTeamPermissionFromCrud(crud)); + }, // IF_PLATFORM react-like usePermissions(scope: Team, options?: { recursive?: boolean }): TeamPermission[] { const recursive = options?.recursive ?? true; const permissions = useAsyncCache(app._currentUserPermissionsCache, [session, scope.id, recursive] as const, "user.usePermissions()"); return useMemo(() => permissions.map((crud) => app._clientTeamPermissionFromCrud(crud)), [permissions]); }, + useProjectPermissions(options?: { recursive?: boolean }): TeamPermission[] { + const recursive = options?.recursive ?? true; + const permissions = useAsyncCache(app._currentUserProjectPermissionsCache, [session, recursive] as const, "user.useProjectPermissions()"); + return useMemo(() => permissions.map((crud) => app._clientTeamPermissionFromCrud(crud)), [permissions]); + }, // END_PLATFORM // IF_PLATFORM react-like usePermission(scope: Team, permissionId: string): TeamPermission | null { const permissions = this.usePermissions(scope); return useMemo(() => permissions.find((p) => p.id === permissionId) ?? null, [permissions, permissionId]); }, + useProjectPermission(permissionId: string): TeamPermission | null { + const permissions = this.useProjectPermissions(); + return useMemo(() => permissions.find((p) => p.id === permissionId) ?? null, [permissions, permissionId]); + }, // END_PLATFORM async getPermission(scope: Team, permissionId: string): Promise { const permissions = await this.listPermissions(scope); @@ -888,6 +909,13 @@ export class _StackClientAppImplIncomplete { return (await this.getPermission(scope, permissionId)) !== null; }, + async getProjectPermission(permissionId: string): Promise { + const permissions = await this.listProjectPermissions(); + return permissions.find((p) => p.id === permissionId) ?? null; + }, + async hasProjectPermission(permissionId: string): Promise { + return (await this.getProjectPermission(permissionId)) !== null; + }, async update(update) { return await app._updateClientUser(update, session); }, diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 1d249e616..224828134 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -3,7 +3,7 @@ import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/cru import { TeamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation"; import { TeamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/crud/team-member-profiles"; import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions"; -import { ProjectPermissionDefinitionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; +import { ProjectPermissionDefinitionsCrud, ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; @@ -61,6 +61,12 @@ export class _StackServerAppImplIncomplete(async ([teamId, userId, recursive]) => { return await this._interface.listServerTeamPermissions({ teamId, userId, recursive }, null); }); + private readonly _serverUserProjectPermissionsCache = createCache< + [string, boolean], + ProjectPermissionsCrud['Server']['Read'][] + >(async ([userId, recursive]) => { + return await this._interface.listServerProjectPermissions({ userId, recursive }, null); + }); private readonly _serverUserOAuthConnectionAccessTokensCache = createCache<[string, string, string], { accessToken: string } | null>( async ([userId, providerId, scope]) => { try { @@ -341,6 +347,31 @@ export class _StackServerAppImplIncomplete { return await this.getPermission(scope, permissionId) !== null; }, + async listProjectPermissions(options?: { recursive?: boolean }): Promise { + const recursive = options?.recursive ?? true; + const permissions = Result.orThrow(await app._serverUserProjectPermissionsCache.getOrWait([crud.id, recursive], "write-only")); + return permissions.map((crud) => app._serverPermissionFromCrud(crud)); + }, + // IF_PLATFORM react-like + useProjectPermissions(options?: { recursive?: boolean }): AdminTeamPermission[] { + const recursive = options?.recursive ?? true; + const permissions = useAsyncCache(app._serverUserProjectPermissionsCache, [crud.id, recursive] as const, "user.useProjectPermissions()"); + return useMemo(() => permissions.map((crud) => app._serverPermissionFromCrud(crud)), [permissions]); + }, + // END_PLATFORM + async getProjectPermission(permissionId: string): Promise { + const permissions = await this.listProjectPermissions(); + return permissions.find((p) => p.id === permissionId) ?? null; + }, + // IF_PLATFORM react-like + useProjectPermission(permissionId: string): AdminTeamPermission | null { + const permissions = this.useProjectPermissions(); + return useMemo(() => permissions.find((p) => p.id === permissionId) ?? null, [permissions, permissionId]); + }, + // END_PLATFORM + async hasProjectPermission(permissionId: string): Promise { + return await this.getProjectPermission(permissionId) !== null; + }, async update(update: ServerUserUpdateOptions) { await app._updateServerUser(crud.id, update); }, @@ -626,7 +657,7 @@ export class _StackServerAppImplIncomplete, + getProjectPermission(permissionId: string): Promise, + hasProjectPermission(permissionId: string): Promise, + listProjectPermissions(options?: { recursive?: boolean }): Promise, + // IF_PLATFORM react-like + useProjectPermissions(options?: { recursive?: boolean }): TeamPermission[], + useProjectPermission(permissionId: string): TeamPermission | null, + // END_PLATFORM readonly selectedTeam: Team | null, setSelectedTeam(team: Team | null): Promise, @@ -269,6 +276,14 @@ export type ServerBaseUser = { grantPermission(scope: Team, permissionId: string): Promise, revokePermission(scope: Team, permissionId: string): Promise, + + getProjectPermission(permissionId: string): Promise, + hasProjectPermission(permissionId: string): Promise, + listProjectPermissions(options?: { recursive?: boolean }): Promise, + // IF_PLATFORM react-like + useProjectPermissions(options?: { recursive?: boolean }): TeamPermission[], + useProjectPermission(permissionId: string): TeamPermission | null, + // END_PLATFORM /** * Creates a new session object with a refresh token for this user. Can be used to impersonate them.