diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 7ea35942..ff1cdbaa 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -1,6 +1,6 @@ name: Python Linter (Black) -on: [push, pull_request] +on: [pull_request] jobs: lint-backend: diff --git a/.github/workflows/build_docker.pr.yml b/.github/workflows/build_docker.pr.yml index 489388b1..50557b3d 100644 --- a/.github/workflows/build_docker.pr.yml +++ b/.github/workflows/build_docker.pr.yml @@ -2,12 +2,15 @@ name: Build Docker Image (On PR) on: pull_request: + types: [opened, synchronize] branches: - "dev" jobs: build-docker-pr: runs-on: ubuntu-latest + outputs: + branch: ${{ steps.extract_branch.outputs.branch }} steps: - name: Set up QEMU uses: docker/setup-qemu-action@v1 @@ -18,10 +21,31 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build Docker Image + - name: Extract branch name + shell: bash + run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" | tr / - >> $GITHUB_OUTPUT + id: extract_branch + - name: Build and push pr image id: docker_build uses: docker/build-push-action@v3 with: file: docker/Dockerfile platforms: linux/amd64 - push: false + push: true + tags: membermatters/membermatters:untrusted-pr-${{ steps.extract_branch.outputs.branch }} + + comment_docker_image: + needs: build-docker-pr + runs-on: ubuntu-latest + steps: + - name: Comment name of docker image + id: comment_docker_image + uses: actions/github-script@v6 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: 'Created image with name `membermatters/membermatters:untrusted-pr-${{ needs.build-docker-pr.outputs.branch }}`.' + }) diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 34dc5437..126f5102 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -1,5 +1,5 @@ name: JavaScript Linter (eslint) -on: [push, pull_request] +on: [pull_request] jobs: lint-frontend: runs-on: ubuntu-latest diff --git a/memberportal/api_admin_tools/views.py b/memberportal/api_admin_tools/views.py index b8547cc0..7cf61c45 100644 --- a/memberportal/api_admin_tools/views.py +++ b/memberportal/api_admin_tools/views.py @@ -726,9 +726,11 @@ def get(self, request, member_id): "totalTime": interlock_log.total_time, "totalCost": (interlock_log.total_cost or 0) / 100, "status": status, - "userEnded": interlock_log.user_ended.get_full_name() - if interlock_log.user_ended - else None, + "userEnded": ( + interlock_log.user_ended.get_full_name() + if interlock_log.user_ended + else None + ), } ) diff --git a/memberportal/api_general/views.py b/memberportal/api_general/views.py index 18525b57..69a0a40b 100644 --- a/memberportal/api_general/views.py +++ b/memberportal/api_general/views.py @@ -394,17 +394,17 @@ def get(self, request): "expiry": p.stripe_card_expiry, }, }, - "membershipPlan": p.membership_plan.get_object() - if p.membership_plan - else None, - "membershipTier": p.membership_plan.member_tier.get_object() - if p.membership_plan - else None - if p.membership_plan - else None, + "membershipPlan": ( + p.membership_plan.get_object() if p.membership_plan else None + ), + "membershipTier": ( + p.membership_plan.member_tier.get_object() + if p.membership_plan + else None if p.membership_plan else None + ), "subscriptionState": p.subscription_status, }, - "permissions": {"admin": user.is_admin}, + "permissions": {"staff": user.is_staff}, } return Response(response) @@ -491,7 +491,7 @@ class Kiosks(APIView): permission_classes = (permissions.AllowAny,) def get(self, request): - if not request.user.is_authenticated and not request.user.is_admin: + if not request.user.is_authenticated and not request.user.is_staff: return Response(status=status.HTTP_403_FORBIDDEN) kiosks = Kiosk.objects.all() @@ -520,7 +520,7 @@ def put(self, request, id=None): "HTTP_X_REAL_IP", request.META.get("REMOTE_ADDR") ) kiosk.checkin() - if not request.user.is_authenticated and not request.user.is_admin: + if not request.user.is_authenticated and not request.user.is_staff: return Response(status=status.HTTP_403_FORBIDDEN) else: kiosk = Kiosk.objects.get(kiosk_id=body.get("kioskId")) @@ -533,23 +533,22 @@ def put(self, request, id=None): play_theme=False, ) - if request.user.is_authenticated: - if request.user.is_admin: - if body.get("playTheme"): - kiosk.play_theme = body.get("playTheme") + if request.user.is_authenticated and request.user.is_staff: + if body.get("playTheme"): + kiosk.play_theme = body.get("playTheme") - if body.get("name"): - kiosk.name = body.get("name") + if body.get("name"): + kiosk.name = body.get("name") - if body.get("authorised") is not None and request.user.is_admin: - kiosk.authorised = body.get("authorised") + if body.get("authorised") is not None: + kiosk.authorised = body.get("authorised") kiosk.save() return Response() def delete(self, request, id): - if not request.user.is_authenticated and not request.user.is_admin: + if not request.user.is_authenticated and not request.user.is_staff: return Response(status=status.HTTP_403_FORBIDDEN) kiosk = Kiosk.objects.get(id=id) diff --git a/memberportal/membermatters/decorators.py b/memberportal/membermatters/decorators.py deleted file mode 100644 index 79f0b440..00000000 --- a/memberportal/membermatters/decorators.py +++ /dev/null @@ -1,70 +0,0 @@ -from django.http import HttpResponseForbidden, HttpResponse -from constance import config -import json - - -def login_required_401(function=None, redirect_field_name=None): - """ - Just make sure the user is authenticated to access a certain ajax view - - Found here: https://stackoverflow.com/questions/10031001/login-required-decorator-on-ajax-views-to-return-401-instead-of-302 - - Otherwise return a HttpResponse 401 - authentication required - instead of the 302 redirect of the original Django decorator - """ - - def _decorator(view_func): - def _wrapped_view(request, *args, **kwargs): - if request.user.is_authenticated: - return view_func(request, *args, **kwargs) - else: - return HttpResponse(status=401) - - return _wrapped_view - - if function is None: - return _decorator - else: - return _decorator(function) - - -def staff_required(view): - def wrap(request, *args, **kwargs): - if request.user.is_staff: - return view(request, *args, **kwargs) - else: - # if the user isn't authorised let them know - return HttpResponseForbidden() - - return wrap - - -def no_noobs(view): - def wrap(request, *args, **kwargs): - if request.user.profile.state == "noob": - # if the user isn't authorised let them know - return HttpResponseForbidden() - else: - return view(request, *args, **kwargs) - - return wrap - - -def api_auth(view): - def wrap(request, *args, **kwargs): - secret_key = config.API_SECRET_KEY - - if request.method == "GET" and request.GET.get("secret", "wrong") == secret_key: - return view(request, *args, **kwargs) - - elif request.method == "POST": - details = json.loads(request.body) - # if the secret key exists, and it matches the one we have stored - if details.get("secret") == secret_key: - print(True) - return view(request, *args, **kwargs) - - # if the user isn't authorised let them know - return HttpResponseForbidden("403 Access Forbidden") - - return wrap diff --git a/memberportal/membermatters/oidc_provider_settings.py b/memberportal/membermatters/oidc_provider_settings.py index b5dfaa03..a7fd5da1 100644 --- a/memberportal/membermatters/oidc_provider_settings.py +++ b/memberportal/membermatters/oidc_provider_settings.py @@ -1,10 +1,56 @@ def userinfo(claims, user): # Populate claims dict. - claims["name"] = "{0} {1}".format(user.profile.first_name, user.profile.last_name) + claims["name"] = user.get_full_name() or "NO_NAME" claims["given_name"] = user.profile.first_name or "NO_FIRSTNAME" claims["family_name"] = user.profile.last_name or "NO_LASTNAME" claims["nickname"] = user.profile.screen_name or "NO_SCREENNAME" + claims["preferred_username"] = user.profile.screen_name or "NO_SCREENNAME" claims["email"] = user.email + claims["email_verified"] = user.email_verified claims["phone_number"] = user.profile.phone or "NO_PHONENUMBER" + claims["phone_number_verified"] = False + claims["updated_at"] = user.profile.modified.isoformat() return claims + + +from django.utils.translation import ugettext_lazy as _ +from oidc_provider.lib.claims import ScopeClaims + + +class CustomScopeClaims(ScopeClaims): + info_membershipinfo = ( + _("Membership Info"), + _( + "Current membership status, and other membership information like permissions/groups." + ), + ) + + def scope_membershipinfo(self): + groups = [] + state = self.user.profile.state + subscription_state = self.user.profile.subscription_status + subscriptionActive = subscription_state in ["active", "cancelling"] + firstSubscribed = self.user.profile.subscription_first_created + firstSubscribed = firstSubscribed.isoformat() if firstSubscribed else None + + if self.user.is_staff: + groups.append("staff") + + if self.user.is_admin: + groups.append("admin") + + if self.user.is_superuser: + groups.append("superuser") + + if self.user.profile.state == "active": + groups.append("active") + + return { + "state": state, + "active": state == "active", + "subscriptionState": subscription_state, + "subscriptionActive": subscriptionActive, + "firstSubscribedDate": firstSubscribed, + "groups": groups, + } diff --git a/memberportal/membermatters/settings.py b/memberportal/membermatters/settings.py index 607bddf8..01b8d13a 100644 --- a/memberportal/membermatters/settings.py +++ b/memberportal/membermatters/settings.py @@ -295,6 +295,7 @@ CONSTANCE_CONFIG_FIELDSETS = CONSTANCE_CONFIG_FIELDSETS OIDC_USERINFO = "membermatters.oidc_provider_settings.userinfo" +OIDC_EXTRA_SCOPE_CLAIMS = "membermatters.oidc_provider_settings.CustomScopeClaims" USE_X_FORWARDED_HOST = True SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/memberportal/membermatters/urls.py b/memberportal/membermatters/urls.py index 2b9e0a20..3d933e30 100644 --- a/memberportal/membermatters/urls.py +++ b/memberportal/membermatters/urls.py @@ -1,5 +1,6 @@ """membermatters URL Configuration """ + import os import django.db.utils from django.contrib import admin diff --git a/memberportal/membermatters/wsgi.py b/memberportal/membermatters/wsgi.py index 603dcc85..bb9ef76f 100644 --- a/memberportal/membermatters/wsgi.py +++ b/memberportal/membermatters/wsgi.py @@ -3,6 +3,7 @@ For more information on this file, see https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ """ + import os from django.core.wsgi import get_wsgi_application import logging, sys diff --git a/memberportal/profile/migrations/0001_initial.py b/memberportal/profile/migrations/0001_initial.py index 3b71603f..4ee1b0c6 100644 --- a/memberportal/profile/migrations/0001_initial.py +++ b/memberportal/profile/migrations/0001_initial.py @@ -264,7 +264,6 @@ class Migration(migrations.Migration): models.ImageField( blank=True, null=True, - upload_to=profile.models.Profile.path_and_rename, ), ), ( diff --git a/memberportal/profile/models.py b/memberportal/profile/models.py index e41624f1..d1850b7b 100644 --- a/memberportal/profile/models.py +++ b/memberportal/profile/models.py @@ -167,7 +167,7 @@ class User(AbstractBaseUser, PermissionsMixin): password_reset_key = models.UUIDField(default=None, blank=True, null=True) password_reset_expire = models.DateTimeField(default=None, blank=True, null=True) staff = models.BooleanField(default=False) # an admin user for the portal - admin = models.BooleanField(default=False) # a superuser + admin = models.BooleanField(default=False) # a portal superuser USERNAME_FIELD = "email" REQUIRED_FIELDS = [] # Email & Password are required by default. @@ -296,13 +296,6 @@ def reset_password(self): class Profile(models.Model): - def path_and_rename(self, filename): - ext = filename.split(".")[-1] - # set filename as random string - filename = f"profile_pics/{str(uuid.uuid4())}.{ext}" - # return the new path to the file - return os.path.join(filename) - STATES = ( ("noob", "Needs Induction"), ("active", "Active"), @@ -521,7 +514,6 @@ def get_basic_profile(self): return { "id": self.user.id, "admin": self.user.is_staff, - "superuser": self.user.is_admin, "email": self.user.email, "excludeFromEmailExport": self.exclude_from_email_export, "registrationDate": self.created.strftime("%m/%d/%Y, %H:%M:%S"), @@ -538,19 +530,23 @@ def get_basic_profile(self): "rfid": self.rfid, "memberBucks": { "balance": self.memberbucks_balance, - "lastPurchase": self.last_memberbucks_purchase.strftime( - "%m/%d/%Y, %H:%M:%S" - ) - if self.last_memberbucks_purchase - else None, + "lastPurchase": ( + self.last_memberbucks_purchase.strftime("%m/%d/%Y, %H:%M:%S") + if self.last_memberbucks_purchase + else None + ), }, "updateProfileRequired": self.must_update_profile, - "lastSeen": self.last_seen.strftime("%m/%d/%Y, %H:%M:%S") - if self.last_seen - else None, - "lastInduction": self.last_induction.strftime("%m/%d/%Y, %H:%M:%S") - if self.last_induction - else None, + "lastSeen": ( + self.last_seen.strftime("%m/%d/%Y, %H:%M:%S") + if self.last_seen + else None + ), + "lastInduction": ( + self.last_induction.strftime("%m/%d/%Y, %H:%M:%S") + if self.last_induction + else None + ), "stripe": { "cardExpiry": self.stripe_card_expiry, "last4": self.stripe_card_last_digits, diff --git a/memberportal/requirements.txt b/memberportal/requirements.txt index 55f40127..3655849d 100644 --- a/memberportal/requirements.txt +++ b/memberportal/requirements.txt @@ -5,7 +5,7 @@ humanize~=4.1.0 django-constance~=2.9.0 django-picklefield~=3.0.1 django-cors-headers~=3.12.0 -black==23.9.1 +black==24.1.0 pre-commit==2.19.0 djangorestframework~=3.13.1 djangorestframework-simplejwt>=4.7.2 diff --git a/package-lock.json b/package-lock.json index bc72371a..0fb1680f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "membermatters", - "version": "3.4.1", + "version": "3.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "membermatters", - "version": "3.4.1", + "version": "3.6.1", "devDependencies": { "eslint-webpack-plugin": "^3.1.1", "husky": "^6.0.0", diff --git a/src-frontend/src/boot/routeGuards.ts b/src-frontend/src/boot/routeGuards.ts index f1765a16..35a75203 100644 --- a/src-frontend/src/boot/routeGuards.ts +++ b/src-frontend/src/boot/routeGuards.ts @@ -40,7 +40,7 @@ export default boot(({ router, store }) => { // Check if the user must be an admin to access the route if (to.meta.admin === true) { - if (store.getters['profile/profile'].permissions.admin === true) + if (store.getters['profile/profile'].permissions.staff === true) return next(); else { return next({ name: 'Error403' }); diff --git a/src-frontend/src/layouts/MainLayout.vue b/src-frontend/src/layouts/MainLayout.vue index 56d0d7a4..e6c81f95 100644 --- a/src-frontend/src/layouts/MainLayout.vue +++ b/src-frontend/src/layouts/MainLayout.vue @@ -211,11 +211,7 @@ export default defineComponent({ if (link.memberOnly && this.profile.memberStatus !== 'Active') displayLink = false; if (this.$q.platform.is.electron && !link.kiosk) displayLink = false; - if ( - link.admin && - this.profile.permissions && - !this.profile.permissions.admin - ) { + if (link.admin && !this.profile?.permissions?.staff) { displayLink = false; } diff --git a/src-frontend/src/types/member.d.ts b/src-frontend/src/types/member.d.ts index 24af19ca..83345146 100644 --- a/src-frontend/src/types/member.d.ts +++ b/src-frontend/src/types/member.d.ts @@ -21,7 +21,6 @@ export enum MemberSubscriptionState { interface MemberProfile { id: number; admin: boolean; - superuser: boolean; email: string; excludeFromEmailExport: boolean; registrationDate: string;