diff --git a/src/lib/components/roles/roles.svelte b/src/lib/components/roles/roles.svelte index 79d1b1d838..ece8a9d5a0 100644 --- a/src/lib/components/roles/roles.svelte +++ b/src/lib/components/roles/roles.svelte @@ -1,11 +1,17 @@

Roles

-

Owner, Developer, Editor, Analyst and Billing.

+

+ {isProjectSpecific + ? 'Owner, Developer, Editor and Analyst' + : 'Owner, Developer, Editor, Analyst and Billing.'} +

r.value !== 'billing'); + export const teamStatusReadonly = 'readonly'; export const billingLimitOutstandingInvoice = 'outstanding_invoice'; diff --git a/src/routes/(console)/organization-[organization]/createMember.svelte b/src/routes/(console)/organization-[organization]/createMember.svelte index 63a55ff911..460cf842be 100644 --- a/src/routes/(console)/organization-[organization]/createMember.svelte +++ b/src/routes/(console)/organization-[organization]/createMember.svelte @@ -2,21 +2,23 @@ import { base } from '$app/paths'; import { page } from '$app/state'; import { Modal } from '$lib/components'; - import { InputText, InputEmail, Button } from '$lib/elements/forms'; + import { InputText, InputEmail, Button, InputSwitch } from '$lib/elements/forms'; import { addNotification } from '$lib/stores/notifications'; import { sdk } from '$lib/stores/sdk'; import { createEventDispatcher } from 'svelte'; import { organization } from '$lib/stores/organization'; import { invalidate } from '$app/navigation'; - import { Dependencies } from '$lib/constants'; + import { BillingPlan, Dependencies } from '$lib/constants'; import { Submit, trackEvent, trackError } from '$lib/actions/analytics'; import { isCloud, isSelfHosted } from '$lib/system'; - import { roles } from '$lib/stores/billing'; + import { projectRoles, roles } from '$lib/stores/billing'; import InputSelect from '$lib/elements/forms/inputSelect.svelte'; import Roles from '$lib/components/roles/roles.svelte'; - import { Icon, Popover } from '@appwrite.io/pink-svelte'; - import { IconInfo } from '@appwrite.io/pink-icons-svelte'; + import { Icon, Popover, Table } from '@appwrite.io/pink-svelte'; + import { IconInfo, IconPlus, IconX } from '@appwrite.io/pink-icons-svelte'; import { Layout } from '@appwrite.io/pink-svelte'; + import type { Models } from '@appwrite.io/console'; + import ChooseProject from './members/project.svelte'; export let showCreate = false; @@ -27,11 +29,30 @@ error: string, role: string = isSelfHosted ? 'owner' : 'developer'; + let showChooseProjects = false; + let isInvitingToSpecificProjects = false; + let selectedProjectsWithRole: { project: Models.Project; role: string }[] = []; + + function removeProject(projectId: string) { + selectedProjectsWithRole = selectedProjectsWithRole.filter( + (p) => p.project.$id !== projectId + ); + } + async function create() { try { + const roles = isInvitingToSpecificProjects + ? [ + 'member', + ...selectedProjectsWithRole.map( + (pr) => `project:${pr.project.$id}/${pr.role}` + ) + ] + : [role]; + const team = await sdk.forConsole.teams.createMembership({ teamId: $organization.$id, - roles: [role], + roles, email, url: `${page.url.origin}${base}/invite`, name: name || undefined @@ -72,19 +93,92 @@ bind:value={email} /> {#if isCloud} - - - - - - - - + {#if $organization?.billingPlan === BillingPlan.SCALE} + + + If enabled, you will be able to select specific projects to invite the member to. + Otherwise, the member will be invited to all projects in the organization. + + + {/if} + {#if isInvitingToSpecificProjects} + {#if selectedProjectsWithRole.length > 0} + + + Project + + + Role + + + + + + + + + {#each selectedProjectsWithRole as selected} + + {selected.project.name} + + + + + + + + {/each} + + {/if} +

+ +
+ {:else} + + + + + + + + + {/if} {/if} - + + + diff --git a/src/routes/(console)/organization-[organization]/members/+page.svelte b/src/routes/(console)/organization-[organization]/members/+page.svelte index 05c3500802..55bf6527a9 100644 --- a/src/routes/(console)/organization-[organization]/members/+page.svelte +++ b/src/routes/(console)/organization-[organization]/members/+page.svelte @@ -143,7 +143,11 @@ - {member.roles.map((role) => getRoleLabel(role)).join(', ')} + {#if member.roles.some((role) => role.startsWith('project:'))} + {"Custom"} + {:else} + {member.roles.map((role) => getRoleLabel(role)).join(', ')} + {/if} import { Modal } from '$lib/components'; - import { Button } from '$lib/elements/forms'; + import { Button, InputSwitch } from '$lib/elements/forms'; import { addNotification } from '$lib/stores/notifications'; import { sdk } from '$lib/stores/sdk'; import { createEventDispatcher } from 'svelte'; import { organization } from '$lib/stores/organization'; import { invalidate } from '$app/navigation'; - import { Dependencies } from '$lib/constants'; + import { BillingPlan, Dependencies } from '$lib/constants'; import { Submit, trackEvent, trackError } from '$lib/actions/analytics'; import InputSelect from '$lib/elements/forms/inputSelect.svelte'; - import type { Models } from '@appwrite.io/console'; + import { Query, type Models } from '@appwrite.io/console'; import Roles from '$lib/components/roles/roles.svelte'; - import { IconInfo } from '@appwrite.io/pink-icons-svelte'; - import { Icon, Layout, Popover } from '@appwrite.io/pink-svelte'; + import { IconInfo, IconPlus, IconX } from '@appwrite.io/pink-icons-svelte'; + import { Icon, Layout, Popover, Table } from '@appwrite.io/pink-svelte'; + import { projectRoles, roles } from '$lib/stores/billing'; + import ChooseProject from './project.svelte'; + import { isSelfHosted } from '$lib/system'; - export let showEdit = false; - export let selectedMember: Models.Membership; + let { + showEdit = $bindable(), + selectedMember + }: { showEdit: boolean; selectedMember: Models.Membership } = $props(); const dispatch = createEventDispatcher(); + const defaultTeamRole = isSelfHosted ? 'owner' : 'developer'; - let error: string; - let role = selectedMember?.roles?.[0]; + let error: string = $state(null); + let showChooseProjects = $state(false); - const roles = [ - { - label: 'Owner', - value: 'owner' - }, - { - label: 'Developer', - value: 'developer' - }, - { - label: 'Editor', - value: 'editor' - }, - { - label: 'Analyst', - value: 'analyst' - }, - { - label: 'Billing', - value: 'billing' - } - ]; + let isProjectSpecificRoles = $derived( + selectedMember?.roles?.some((r) => r.startsWith('project:')) + ); + let teamRole = $derived(isProjectSpecificRoles ? defaultTeamRole : selectedMember?.roles?.[0]); + let isRestrictingToSpecificProjects = $derived(isProjectSpecificRoles); + let selectedProjectsWithRole: { project: Models.Project; role: string }[] = $state([]); + + function removeProject(projectId: string) { + selectedProjectsWithRole = selectedProjectsWithRole.filter( + (p) => p.project.$id !== projectId + ); + } async function submit() { try { + const roles = isRestrictingToSpecificProjects + ? [ + 'member', + ...selectedProjectsWithRole.map( + (pr) => `project:${pr.project.$id}/${pr.role}` + ) + ] + : [teamRole]; + const membership = await sdk.forConsole.teams.updateMembership({ teamId: $organization.$id, membershipId: selectedMember.$id, - roles: [role] + roles: roles }); await invalidate(Dependencies.ACCOUNT); await invalidate(Dependencies.ORGANIZATION); @@ -69,30 +74,126 @@ } } - $: if (!showEdit) { - error = null; - role = null; - } + $effect(() => { + if (!showEdit) { + error = null; + } + }); - $: if (showEdit && !role) { - role = selectedMember.roles?.[0]; - } + $effect(() => { + if (!showEdit || !isProjectSpecificRoles) { + return; + } + + async function loadProjects() { + const projectIdToRole = selectedMember?.roles + ?.filter((r) => r.startsWith('project:')) + .reduce((acc, r) => { + const parts = r.split(':')[1]; + const [projectId, role] = parts.split('/'); + acc[projectId] = role; + return acc; + }, {}); + const projectIdsQuery = Object.keys(projectIdToRole).map((id) => + Query.equal('$id', id) + ); + const projectsList = await sdk.forConsole.projects.list({ + queries: [Query.equal('teamId', $organization.$id), Query.or(projectIdsQuery)] + }); + selectedProjectsWithRole = projectsList.projects.map((p) => ({ + project: p, + role: projectIdToRole[p.$id] + })); + } + + loadProjects(); + }); - - - - - - - - + {#if $organization?.billingPlan === BillingPlan.SCALE} + + + If enabled, you will be able to allow access specific projects. + Otherwise, the member will have access to all projects in the organization. + + + {/if} + {#if isRestrictingToSpecificProjects} + {#if selectedProjectsWithRole.length > 0} + + + Project + + + Role + + + + + + + + + {#each selectedProjectsWithRole as selected} + + {selected.project.name} + + + + + + + + {/each} + + {/if} +
+ +
+ {:else} + + + + + + + + + {/if} - +
+ + diff --git a/src/routes/(console)/organization-[organization]/members/project.svelte b/src/routes/(console)/organization-[organization]/members/project.svelte new file mode 100644 index 0000000000..d58cbfc003 --- /dev/null +++ b/src/routes/(console)/organization-[organization]/members/project.svelte @@ -0,0 +1,162 @@ + + + + Grant access to any project in the organization. + + + {#if results?.projects?.length} + + {#each results.projects as project (project.$id)} + toggleSelection(project)}> + +
+ p.project.$id === project.$id + )} /> +
+
+ + + + + {project.name} + + {project.$id} + + + + +
+ {/each} +
+ + +

Total results: {results?.total}

+ +
+ {:else if search} + + + + {:else if isLoading} + +
+ +
+ {:else} + + + + Need a hand? Learn more in our + documentation. + + + + {/if} + + + + +