See #248: can now generate and list invitations in the front-end
This commit is contained in:
		
							parent
							
								
									d18f98e0f8
								
							
						
					
					
						commit
						107b1ea7dc
					
				|  | @ -1,4 +1,3 @@ | ||||||
| 
 |  | ||||||
| from django_filters import rest_framework as filters | from django_filters import rest_framework as filters | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import fields | from funkwhale_api.common import fields | ||||||
|  | @ -37,3 +36,11 @@ class ManageUserFilterSet(filters.FilterSet): | ||||||
|             "permission_settings", |             "permission_settings", | ||||||
|             "permission_federation", |             "permission_federation", | ||||||
|         ] |         ] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ManageInvitationFilterSet(filters.FilterSet): | ||||||
|  |     q = fields.SearchFilter(search_fields=["owner__username", "code", "owner__email"]) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = users_models.Invitation | ||||||
|  |         fields = ["q"] | ||||||
|  |  | ||||||
|  | @ -78,6 +78,23 @@ class PermissionsSerializer(serializers.Serializer): | ||||||
|         return {"permissions": o} |         return {"permissions": o} | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | class ManageUserSimpleSerializer(serializers.ModelSerializer): | ||||||
|  |     class Meta: | ||||||
|  |         model = users_models.User | ||||||
|  |         fields = ( | ||||||
|  |             "id", | ||||||
|  |             "username", | ||||||
|  |             "email", | ||||||
|  |             "name", | ||||||
|  |             "is_active", | ||||||
|  |             "is_staff", | ||||||
|  |             "is_superuser", | ||||||
|  |             "date_joined", | ||||||
|  |             "last_activity", | ||||||
|  |             "privacy_level", | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class ManageUserSerializer(serializers.ModelSerializer): | class ManageUserSerializer(serializers.ModelSerializer): | ||||||
|     permissions = PermissionsSerializer(source="*") |     permissions = PermissionsSerializer(source="*") | ||||||
| 
 | 
 | ||||||
|  | @ -115,3 +132,23 @@ class ManageUserSerializer(serializers.ModelSerializer): | ||||||
|                 update_fields=["permission_{}".format(p) for p in permissions.keys()] |                 update_fields=["permission_{}".format(p) for p in permissions.keys()] | ||||||
|             ) |             ) | ||||||
|         return instance |         return instance | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ManageInvitationSerializer(serializers.ModelSerializer): | ||||||
|  |     users = ManageUserSimpleSerializer(many=True, required=False) | ||||||
|  |     owner = ManageUserSimpleSerializer(required=False) | ||||||
|  |     code = serializers.CharField(required=False, allow_null=True) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = users_models.Invitation | ||||||
|  |         fields = ("id", "owner", "code", "expiration_date", "creation_date", "users") | ||||||
|  |         read_only_fields = ["id", "expiration_date", "owner", "creation_date", "users"] | ||||||
|  | 
 | ||||||
|  |     def validate_code(self, value): | ||||||
|  |         if not value: | ||||||
|  |             return value | ||||||
|  |         if users_models.Invitation.objects.filter(code=value.lower()).exists(): | ||||||
|  |             raise serializers.ValidationError( | ||||||
|  |                 "An invitation with this code already exists" | ||||||
|  |             ) | ||||||
|  |         return value | ||||||
|  |  | ||||||
|  | @ -7,6 +7,7 @@ library_router = routers.SimpleRouter() | ||||||
| library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files") | library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files") | ||||||
| users_router = routers.SimpleRouter() | users_router = routers.SimpleRouter() | ||||||
| users_router.register(r"users", views.ManageUserViewSet, "users") | users_router.register(r"users", views.ManageUserViewSet, "users") | ||||||
|  | users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations") | ||||||
| 
 | 
 | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     url(r"^library/", include((library_router.urls, "instance"), namespace="library")), |     url(r"^library/", include((library_router.urls, "instance"), namespace="library")), | ||||||
|  |  | ||||||
|  | @ -62,3 +62,27 @@ class ManageUserViewSet( | ||||||
|         context = super().get_serializer_context() |         context = super().get_serializer_context() | ||||||
|         context["default_permissions"] = preferences.get("users__default_permissions") |         context["default_permissions"] = preferences.get("users__default_permissions") | ||||||
|         return context |         return context | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ManageInvitationViewSet( | ||||||
|  |     mixins.CreateModelMixin, | ||||||
|  |     mixins.ListModelMixin, | ||||||
|  |     mixins.RetrieveModelMixin, | ||||||
|  |     mixins.UpdateModelMixin, | ||||||
|  |     mixins.DestroyModelMixin, | ||||||
|  |     viewsets.GenericViewSet, | ||||||
|  | ): | ||||||
|  |     queryset = ( | ||||||
|  |         users_models.Invitation.objects.all() | ||||||
|  |         .order_by("-id") | ||||||
|  |         .prefetch_related("users") | ||||||
|  |         .select_related("owner") | ||||||
|  |     ) | ||||||
|  |     serializer_class = serializers.ManageInvitationSerializer | ||||||
|  |     filter_class = filters.ManageInvitationFilterSet | ||||||
|  |     permission_classes = (HasUserPermission,) | ||||||
|  |     required_permissions = ["settings"] | ||||||
|  |     ordering_fields = ["creation_date", "expiration_date"] | ||||||
|  | 
 | ||||||
|  |     def perform_create(self, serializer): | ||||||
|  |         serializer.save(owner=self.request.user) | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ from funkwhale_api.manage import serializers, views | ||||||
|     [ |     [ | ||||||
|         (views.ManageTrackFileViewSet, ["library"], "and"), |         (views.ManageTrackFileViewSet, ["library"], "and"), | ||||||
|         (views.ManageUserViewSet, ["settings"], "and"), |         (views.ManageUserViewSet, ["settings"], "and"), | ||||||
|  |         (views.ManageInvitationViewSet, ["settings"], "and"), | ||||||
|     ], |     ], | ||||||
| ) | ) | ||||||
| def test_permissions(assert_user_permission, view, permissions, operator): | def test_permissions(assert_user_permission, view, permissions, operator): | ||||||
|  | @ -42,3 +43,23 @@ def test_user_view(factories, superuser_api_client, mocker): | ||||||
| 
 | 
 | ||||||
|     assert response.data["count"] == len(users) |     assert response.data["count"] == len(users) | ||||||
|     assert response.data["results"] == expected |     assert response.data["results"] == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_invitation_view(factories, superuser_api_client, mocker): | ||||||
|  |     invitations = factories["users.Invitation"].create_batch(size=5) | ||||||
|  |     qs = invitations[0].__class__.objects.order_by("-id") | ||||||
|  |     url = reverse("api:v1:manage:users:invitations-list") | ||||||
|  | 
 | ||||||
|  |     response = superuser_api_client.get(url, {"sort": "-id"}) | ||||||
|  |     expected = serializers.ManageInvitationSerializer(qs, many=True).data | ||||||
|  | 
 | ||||||
|  |     assert response.data["count"] == len(invitations) | ||||||
|  |     assert response.data["results"] == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_invitation_view_create(factories, superuser_api_client, mocker): | ||||||
|  |     url = reverse("api:v1:manage:users:invitations-list") | ||||||
|  |     response = superuser_api_client.post(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 201 | ||||||
|  |     assert superuser_api_client.user.invitations.latest("id") is not None | ||||||
|  |  | ||||||
|  | @ -99,7 +99,7 @@ | ||||||
|             <router-link |             <router-link | ||||||
|               class="item" |               class="item" | ||||||
|               v-if="$store.state.auth.availablePermissions['settings']" |               v-if="$store.state.auth.availablePermissions['settings']" | ||||||
|               :to="{path: '/manage/users'}"> |               :to="{name: 'manage.users.users.list'}"> | ||||||
|               <i class="users icon"></i>{{ $t('Users') }} |               <i class="users icon"></i>{{ $t('Users') }} | ||||||
|             </router-link> |             </router-link> | ||||||
|           </div> |           </div> | ||||||
|  |  | ||||||
|  | @ -0,0 +1,82 @@ | ||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <form v-if="!over" class="ui form" @submit.prevent="submit"> | ||||||
|  |       <div v-if="errors.length > 0" class="ui negative message"> | ||||||
|  |         <div class="header">{{ $t('Error while creating invitation') }}</div> | ||||||
|  |         <ul class="list"> | ||||||
|  |           <li v-for="error in errors">{{ error }}</li> | ||||||
|  |         </ul> | ||||||
|  |       </div> | ||||||
|  |       <div class="inline fields"> | ||||||
|  |         <div class="ui field"> | ||||||
|  |           <label>{{ $t('Invitation code')}}</label> | ||||||
|  |           <input type="text" v-model="code" :placeholder="$t('Leave empty for a random code')" /> | ||||||
|  |         </div> | ||||||
|  |         <div class="ui field"> | ||||||
|  |           <button :class="['ui', {loading: isLoading}, 'button']" :disabled="isLoading" type="submit"> | ||||||
|  |             {{ $t('Get a new invitation') }} | ||||||
|  |           </button> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </form> | ||||||
|  |     <div v-if="invitations.length > 0"> | ||||||
|  |       <div class="ui hidden divider"></div> | ||||||
|  |       <table class="ui ui basic table"> | ||||||
|  |         <thead> | ||||||
|  |           <tr> | ||||||
|  |             <th>{{ $t('Code') }}</th> | ||||||
|  |             <th>{{ $t('Share link') }}</th> | ||||||
|  |           </tr> | ||||||
|  |         </thead> | ||||||
|  |         <tbody> | ||||||
|  |           <tr v-for="invitation in invitations" :key="invitation.code"> | ||||||
|  |             <td>{{ invitation.code.toUpperCase() }}</td> | ||||||
|  |             <td><a :href="getUrl(invitation.code)" target="_blank">{{ getUrl(invitation.code) }}</a></td> | ||||||
|  |           </tr> | ||||||
|  |         </tbody> | ||||||
|  |       </table> | ||||||
|  |       <button class="ui basic button" @click="invitations = []">{{ $t('Clear') }}</button> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | import axios from 'axios' | ||||||
|  | 
 | ||||||
|  | import backend from '@/audio/backend' | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   data () { | ||||||
|  |     return { | ||||||
|  |       isLoading: false, | ||||||
|  |       code: null, | ||||||
|  |       invitations: [], | ||||||
|  |       errors: [] | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     submit () { | ||||||
|  |       let self = this | ||||||
|  |       this.isLoading = true | ||||||
|  |       this.errors = [] | ||||||
|  |       let url = 'manage/users/invitations/' | ||||||
|  |       let payload = { | ||||||
|  |         code: this.code | ||||||
|  |       } | ||||||
|  |       axios.post(url, payload).then((response) => { | ||||||
|  |         self.isLoading = false | ||||||
|  |         self.invitations.unshift(response.data) | ||||||
|  |       }, (error) => { | ||||||
|  |         self.isLoading = false | ||||||
|  |         self.errors = error.backendErrors | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |     getUrl (code) { | ||||||
|  |       return backend.absoluteUrl(this.$router.resolve({name: 'signup', query: {invitation: code.toUpperCase()}}).href) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <style scoped> | ||||||
|  | </style> | ||||||
|  | @ -0,0 +1,180 @@ | ||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <div class="ui inline form"> | ||||||
|  |       <div class="fields"> | ||||||
|  |         <div class="ui field"> | ||||||
|  |           <label>{{ $t('Search') }}</label> | ||||||
|  |           <input type="text" v-model="search" placeholder="Search by username, email, code..." /> | ||||||
|  |         </div> | ||||||
|  |         <div class="field"> | ||||||
|  |           <i18next tag="label" path="Ordering"/> | ||||||
|  |           <select class="ui dropdown" v-model="ordering"> | ||||||
|  |             <option v-for="option in orderingOptions" :value="option[0]"> | ||||||
|  |               {{ option[1] }} | ||||||
|  |             </option> | ||||||
|  |           </select> | ||||||
|  |         </div> | ||||||
|  |         <div class="field"> | ||||||
|  |           <i18next tag="label" path="Ordering direction"/> | ||||||
|  |           <select class="ui dropdown" v-model="orderingDirection"> | ||||||
|  |             <option value="+">{{ $t('Ascending') }}</option> | ||||||
|  |             <option value="-">{{ $t('Descending') }}</option> | ||||||
|  |           </select> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       </div> | ||||||
|  |     <div class="dimmable"> | ||||||
|  |       <div v-if="isLoading" class="ui active inverted dimmer"> | ||||||
|  |           <div class="ui loader"></div> | ||||||
|  |       </div> | ||||||
|  |       <action-table | ||||||
|  |         v-if="result" | ||||||
|  |         @action-launched="fetchData" | ||||||
|  |         :objects-data="result" | ||||||
|  |         :actions="actions" | ||||||
|  |         :action-url="'manage/users/invitations/action/'" | ||||||
|  |         :filters="actionFilters"> | ||||||
|  |         <template slot="header-cells"> | ||||||
|  |           <th>{{ $t('Owner') }}</th> | ||||||
|  |           <th>{{ $t('Status') }}</th> | ||||||
|  |           <th>{{ $t('Creation date') }}</th> | ||||||
|  |           <th>{{ $t('Expiration date') }}</th> | ||||||
|  |           <th>{{ $t('Code') }}</th> | ||||||
|  |         </template> | ||||||
|  |         <template slot="row-cells" slot-scope="scope"> | ||||||
|  |           <td> | ||||||
|  |             <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.owner.username }}</router-link> | ||||||
|  |           </td> | ||||||
|  |           <td> | ||||||
|  |             <span v-if="scope.obj.users.length > 0" class="ui green basic label">{{ $t('Used') }}</span> | ||||||
|  |             <span v-else-if="scope.obj.expiration_date < new Date()" class="ui red basic label">{{ $t('Expired') }}</span> | ||||||
|  |             <span v-else class="ui basic label">{{ $t('Not used') }}</span> | ||||||
|  |           </td> | ||||||
|  |           <td> | ||||||
|  |             <human-date :date="scope.obj.creation_date"></human-date> | ||||||
|  |           </td> | ||||||
|  |           <td> | ||||||
|  |             <human-date :date="scope.obj.expiration_date"></human-date> | ||||||
|  |           </td> | ||||||
|  |           <td> | ||||||
|  |             {{ scope.obj.code.toUpperCase() }} | ||||||
|  |           </td> | ||||||
|  |         </template> | ||||||
|  |       </action-table> | ||||||
|  |     </div> | ||||||
|  |     <div> | ||||||
|  |       <pagination | ||||||
|  |         v-if="result && result.results.length > 0" | ||||||
|  |         @page-changed="selectPage" | ||||||
|  |         :compact="true" | ||||||
|  |         :current="page" | ||||||
|  |         :paginate-by="paginateBy" | ||||||
|  |         :total="result.count" | ||||||
|  |         ></pagination> | ||||||
|  | 
 | ||||||
|  |       <span v-if="result && result.results.length > 0"> | ||||||
|  |         {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} | ||||||
|  |       </span> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | import axios from 'axios' | ||||||
|  | import _ from 'lodash' | ||||||
|  | import time from '@/utils/time' | ||||||
|  | import Pagination from '@/components/Pagination' | ||||||
|  | import ActionTable from '@/components/common/ActionTable' | ||||||
|  | import OrderingMixin from '@/components/mixins/Ordering' | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   mixins: [OrderingMixin], | ||||||
|  |   props: { | ||||||
|  |     filters: {type: Object, required: false} | ||||||
|  |   }, | ||||||
|  |   components: { | ||||||
|  |     Pagination, | ||||||
|  |     ActionTable | ||||||
|  |   }, | ||||||
|  |   data () { | ||||||
|  |     let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') | ||||||
|  |     return { | ||||||
|  |       time, | ||||||
|  |       isLoading: false, | ||||||
|  |       result: null, | ||||||
|  |       page: 1, | ||||||
|  |       paginateBy: 50, | ||||||
|  |       search: '', | ||||||
|  |       orderingDirection: defaultOrdering.direction || '+', | ||||||
|  |       ordering: defaultOrdering.field, | ||||||
|  |       orderingOptions: [ | ||||||
|  |         ['expiration_date', 'Expiration date'], | ||||||
|  |         ['creation_date', 'Creation date'] | ||||||
|  |       ] | ||||||
|  | 
 | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   created () { | ||||||
|  |     this.fetchData() | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     fetchData () { | ||||||
|  |       let params = _.merge({ | ||||||
|  |         'page': this.page, | ||||||
|  |         'page_size': this.paginateBy, | ||||||
|  |         'q': this.search, | ||||||
|  |         'ordering': this.getOrderingAsString() | ||||||
|  |       }, this.filters) | ||||||
|  |       let self = this | ||||||
|  |       self.isLoading = true | ||||||
|  |       self.checked = [] | ||||||
|  |       axios.get('/manage/users/invitations/', {params: params}).then((response) => { | ||||||
|  |         self.result = response.data | ||||||
|  |         self.isLoading = false | ||||||
|  |       }, error => { | ||||||
|  |         self.isLoading = false | ||||||
|  |         self.errors = error.backendErrors | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |     selectPage: function (page) { | ||||||
|  |       this.page = page | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     actionFilters () { | ||||||
|  |       var currentFilters = { | ||||||
|  |         q: this.search | ||||||
|  |       } | ||||||
|  |       if (this.filters) { | ||||||
|  |         return _.merge(currentFilters, this.filters) | ||||||
|  |       } else { | ||||||
|  |         return currentFilters | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     actions () { | ||||||
|  |       return [ | ||||||
|  |         // { | ||||||
|  |         //   name: 'delete', | ||||||
|  |         //   label: this.$t('Delete'), | ||||||
|  |         //   isDangerous: true | ||||||
|  |         // } | ||||||
|  |       ] | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   watch: { | ||||||
|  |     search (newValue) { | ||||||
|  |       this.page = 1 | ||||||
|  |       this.fetchData() | ||||||
|  |     }, | ||||||
|  |     page () { | ||||||
|  |       this.fetchData() | ||||||
|  |     }, | ||||||
|  |     ordering () { | ||||||
|  |       this.fetchData() | ||||||
|  |     }, | ||||||
|  |     orderingDirection () { | ||||||
|  |       this.fetchData() | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  | @ -45,7 +45,7 @@ | ||||||
|         </template> |         </template> | ||||||
|         <template slot="row-cells" slot-scope="scope"> |         <template slot="row-cells" slot-scope="scope"> | ||||||
|           <td> |           <td> | ||||||
|             <router-link :to="{name: 'manage.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link> |             <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link> | ||||||
|           </td> |           </td> | ||||||
|           <td> |           <td> | ||||||
|             <span>{{ scope.obj.email }}</span> |             <span>{{ scope.obj.email }}</span> | ||||||
|  |  | ||||||
|  | @ -34,6 +34,7 @@ import AdminLibraryFilesList from '@/views/admin/library/FilesList' | ||||||
| import AdminUsersBase from '@/views/admin/users/Base' | import AdminUsersBase from '@/views/admin/users/Base' | ||||||
| import AdminUsersDetail from '@/views/admin/users/UsersDetail' | import AdminUsersDetail from '@/views/admin/users/UsersDetail' | ||||||
| import AdminUsersList from '@/views/admin/users/UsersList' | import AdminUsersList from '@/views/admin/users/UsersList' | ||||||
|  | import AdminInvitationsList from '@/views/admin/users/InvitationsList' | ||||||
| import FederationBase from '@/views/federation/Base' | import FederationBase from '@/views/federation/Base' | ||||||
| import FederationScan from '@/views/federation/Scan' | import FederationScan from '@/views/federation/Scan' | ||||||
| import FederationLibraryDetail from '@/views/federation/LibraryDetail' | import FederationLibraryDetail from '@/views/federation/LibraryDetail' | ||||||
|  | @ -191,15 +192,20 @@ export default new Router({ | ||||||
|       component: AdminUsersBase, |       component: AdminUsersBase, | ||||||
|       children: [ |       children: [ | ||||||
|         { |         { | ||||||
|           path: '', |           path: 'users', | ||||||
|           name: 'manage.users.list', |           name: 'manage.users.users.list', | ||||||
|           component: AdminUsersList |           component: AdminUsersList | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|           path: ':id', |           path: 'users/:id', | ||||||
|           name: 'manage.users.detail', |           name: 'manage.users.users.detail', | ||||||
|           component: AdminUsersDetail, |           component: AdminUsersDetail, | ||||||
|           props: true |           props: true | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           path: 'invitations', | ||||||
|  |           name: 'manage.users.invitations.list', | ||||||
|  |           component: AdminInvitationsList | ||||||
|         } |         } | ||||||
|       ] |       ] | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|  | @ -3,7 +3,10 @@ | ||||||
|     <div class="ui secondary pointing menu"> |     <div class="ui secondary pointing menu"> | ||||||
|       <router-link |       <router-link | ||||||
|         class="ui item" |         class="ui item" | ||||||
|         :to="{name: 'manage.users.list'}">{{ $t('Users') }}</router-link> |         :to="{name: 'manage.users.users.list'}">{{ $t('Users') }}</router-link> | ||||||
|  |       <router-link | ||||||
|  |         class="ui item" | ||||||
|  |         :to="{name: 'manage.users.invitations.list'}">{{ $t('Invitations') }}</router-link> | ||||||
|     </div> |     </div> | ||||||
|     <router-view :key="$route.fullPath"></router-view> |     <router-view :key="$route.fullPath"></router-view> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|  | @ -0,0 +1,26 @@ | ||||||
|  | <template> | ||||||
|  |   <div v-title="$t('Invitations')"> | ||||||
|  |     <div class="ui vertical stripe segment"> | ||||||
|  |       <h2 class="ui header">{{ $t('Invitations') }}</h2> | ||||||
|  |       <invitation-form></invitation-form> | ||||||
|  |       <div class="ui hidden divider"></div> | ||||||
|  |       <invitations-table></invitations-table> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | import InvitationForm from '@/components/manage/users/InvitationForm' | ||||||
|  | import InvitationsTable from '@/components/manage/users/InvitationsTable' | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   components: { | ||||||
|  |     InvitationForm, | ||||||
|  |     InvitationsTable | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||||
|  | <style scoped> | ||||||
|  | </style> | ||||||
		Loading…
	
		Reference in New Issue
	
	 Eliot Berriot
						Eliot Berriot