From 4fa8967cbf23a1f4411ca4bcdc68913fe72770c8 Mon Sep 17 00:00:00 2001
From: Konrad Mohrfeldt <km@roko.li>
Date: Fri, 24 Jan 2025 14:11:15 +0100
Subject: [PATCH] feat: validate actual permission strings

This changes enables us to catch typos as well as missing or updated
steering permissions in the dashboard code.
---
 .../generic/APermissionGuard.spec.ts          |   9 +-
 src/components/generic/APermissionGuard.vue   |   3 +-
 src/components/shows/TimeSlotRow.vue          |  59 +++--
 src/steering-types.ts                         | 233 +++++++++++++++++-
 src/types.ts                                  |   2 +
 5 files changed, 272 insertions(+), 34 deletions(-)

diff --git a/src/components/generic/APermissionGuard.spec.ts b/src/components/generic/APermissionGuard.spec.ts
index 2acaeeb4..d1a3c8f1 100644
--- a/src/components/generic/APermissionGuard.spec.ts
+++ b/src/components/generic/APermissionGuard.spec.ts
@@ -6,6 +6,7 @@ import { ref, h, nextTick } from 'vue'
 
 import { SteeringUser, useHasUserPermission } from '@/stores/auth'
 import APermissionGuard from './APermissionGuard.vue'
+import { AnyPermission } from '@/types'
 
 function createGuard(
   content:
@@ -14,7 +15,7 @@ function createGuard(
         disabled?: boolean
         areAllChildGuardsHidden: boolean
       }) => ReturnType<typeof h>),
-  props: { showPermissions?: string[]; editPermissions?: string[] },
+  props: { showPermissions?: AnyPermission[]; editPermissions?: AnyPermission[] },
   pinia: TestingPinia,
 ) {
   return mount(APermissionGuard, {
@@ -89,6 +90,7 @@ describe('APermissionGuard', () => {
     const pinia = createPiniaWithFakeUserAuthStore()
     const guard = createGuard(
       'Is disabled: {{ params.disabled ? "Yes" : "No" }}',
+      // @ts-expect-error my-edit-permission is not a real permission
       { editPermissions: ['my-edit-permission'] },
       pinia,
     )
@@ -99,6 +101,7 @@ describe('APermissionGuard', () => {
     const pinia = createPiniaWithFakeUserAuthStore(['my-edit-permission'])
     const guard = createGuard(
       'Is disabled: {{ "disabled" in params || params.disabled ? "Yes" : "No" }}',
+      // @ts-expect-error my-edit-permission is not a real permission
       { editPermissions: ['my-edit-permission'] },
       pinia,
     )
@@ -107,12 +110,14 @@ describe('APermissionGuard', () => {
 
   test('Does not render content if show permissions are not granted.', () => {
     const pinia = createPiniaWithFakeUserAuthStore()
+    // @ts-expect-error my-show-permission is not a real permission
     const guard = createGuard('my content', { showPermissions: ['my-show-permission'] }, pinia)
     expect(guard.text()).toEqual('')
   })
 
   test('Does render content if show permissions are granted.', () => {
     const pinia = createPiniaWithFakeUserAuthStore(['my-show-permission'])
+    // @ts-expect-error my-show-permission is not a real permission
     const guard = createGuard('my content', { showPermissions: ['my-show-permission'] }, pinia)
     expect(guard.text()).toEqual('my content')
   })
@@ -122,6 +127,7 @@ describe('APermissionGuard', () => {
     const guard = createGuard(
       ({ areAllChildGuardsHidden }) => {
         return h('div', [
+          // @ts-expect-error my-show-permission is not a real permission
           h(APermissionGuard, { showPermissions: 'my-other-permission' }, () => ['test: ']),
           areAllChildGuardsHidden ? 'has-no-visible-guards' : 'has-visible-guards',
         ])
@@ -138,6 +144,7 @@ describe('APermissionGuard', () => {
     const guard = createGuard(
       ({ areAllChildGuardsHidden }) => {
         return h('div', [
+          // @ts-expect-error my-show-permission is not a real permission
           h(APermissionGuard, { showPermissions: 'my-show-permission' }, () => ['test: ']),
           areAllChildGuardsHidden ? 'has-no-visible-guards' : 'has-visible-guards',
         ])
diff --git a/src/components/generic/APermissionGuard.vue b/src/components/generic/APermissionGuard.vue
index 89c95552..00d0ddee 100644
--- a/src/components/generic/APermissionGuard.vue
+++ b/src/components/generic/APermissionGuard.vue
@@ -5,8 +5,9 @@
 <script lang="ts">
 import type { InjectionKey, Ref } from 'vue'
 import type { Authorizer } from '@/stores/auth'
+import { AnyPermission } from '@/types'
 
-type Permissions = Authorizer | string | string[]
+type Permissions = Authorizer | AnyPermission | AnyPermission[]
 export type PermissionGuardProps = {
   showPermissions?: Permissions
   editPermissions?: Permissions
diff --git a/src/components/shows/TimeSlotRow.vue b/src/components/shows/TimeSlotRow.vue
index 11b261a1..55ac9bce 100644
--- a/src/components/shows/TimeSlotRow.vue
+++ b/src/components/shows/TimeSlotRow.vue
@@ -9,37 +9,35 @@
           :is-repetition="Boolean(timeslot.repetitionOfId)"
         />
 
-        <APermissionGuard show-permissions="program.edit__timeslot__episode">
-          <button
-            type="button"
-            class="btn btn-default"
-            :class="{
-              'tw-aspect-square btn-sm': timeslot.episodeId,
-            }"
-            :popovertarget="episodePopoverId"
-            popovertargetaction="toggle"
-            :style="{ 'anchor-name': `--${episodePopoverId}` }"
-          >
-            <icon-pepicons-pencil-dots-y v-if="timeslot.episodeId" />
-            <span :class="{ 'tw-sr-only': timeslot.episodeId }">
-              {{ t('episode.labels.assign') }}
-            </span>
-          </button>
+        <button
+          type="button"
+          class="btn btn-default"
+          :class="{
+            'tw-aspect-square btn-sm': timeslot.episodeId,
+          }"
+          :popovertarget="episodePopoverId"
+          popovertargetaction="toggle"
+          :style="{ 'anchor-name': `--${episodePopoverId}` }"
+        >
+          <icon-pepicons-pencil-dots-y v-if="timeslot.episodeId" />
+          <span :class="{ 'tw-sr-only': timeslot.episodeId }">
+            {{ t('episode.labels.assign') }}
+          </span>
+        </button>
 
-          <AEpisodeAssignmentManager
-            :id="episodePopoverId"
-            v-model="episodeId.value"
-            :show-id="timeslot.showId"
-            class="anchored"
-            :class="{
-              'on-bottom-right': timeslot.episodeId,
-              'on-bottom-left tw-mt-2': !timeslot.episodeId,
-            }"
-            :style="{
-              'position-anchor': `--${episodePopoverId}`,
-            }"
-          />
-        </APermissionGuard>
+        <AEpisodeAssignmentManager
+          :id="episodePopoverId"
+          v-model="episodeId.value"
+          :show-id="timeslot.showId"
+          class="anchored"
+          :class="{
+            'on-bottom-right': timeslot.episodeId,
+            'on-bottom-left tw-mt-2': !timeslot.episodeId,
+          }"
+          :style="{
+            'position-anchor': `--${episodePopoverId}`,
+          }"
+        />
       </div>
     </td>
     <td>
@@ -85,7 +83,6 @@ import { calculateDurationSeconds, secondsToDurationString } from '@/util'
 import { useEpisodeStore, useMediaStore, useTimeSlotStore } from '@/stores'
 import { useMediaState } from '@/stores/media'
 import AStatus from '@/components/generic/AStatus.vue'
-import APermissionGuard from '@/components/generic/APermissionGuard.vue'
 import AEpisodeAssignmentManager from '@/components/episode/AEpisodeAssignmentManager.vue'
 import { useAPIObjectFieldCopy } from '@/form'
 import AEpisodeLink from '@/components/episode/AEpisodeLink.vue'
diff --git a/src/steering-types.ts b/src/steering-types.ts
index a156ebf4..a1c44636 100644
--- a/src/steering-types.ts
+++ b/src/steering-types.ts
@@ -1,6 +1,6 @@
 /* eslint-disable */
 /*
- * This file was auto-generated by `make update-types` at 2025-01-23 19:23:45.859Z.
+ * This file was auto-generated by `make update-types` at 2025-01-24 12:56:47.495Z.
  * DO NOT make changes to this file.
  */
 
@@ -1738,6 +1738,237 @@ export interface components {
       permissions: readonly string[]
       profileIds?: number[]
     }
+    /**
+     * @description Permissions used by steering.
+     * @enum {string}
+     */
+    AnyPermission:
+      | 'auth.add_group'
+      | 'auth.add_permission'
+      | 'auth.add_user'
+      | 'auth.change_group'
+      | 'auth.change_permission'
+      | 'auth.change_user'
+      | 'auth.delete_group'
+      | 'auth.delete_permission'
+      | 'auth.delete_user'
+      | 'auth.view_group'
+      | 'auth.view_permission'
+      | 'auth.view_user'
+      | 'program.add__file'
+      | 'program.add__import'
+      | 'program.add__line'
+      | 'program.add__m3ufile'
+      | 'program.add__stream'
+      | 'program.add_category'
+      | 'program.add_cba'
+      | 'program.add_contentlicense'
+      | 'program.add_episode'
+      | 'program.add_episodelink'
+      | 'program.add_fundingcategory'
+      | 'program.add_host'
+      | 'program.add_hostlink'
+      | 'program.add_image'
+      | 'program.add_language'
+      | 'program.add_license'
+      | 'program.add_licensetype'
+      | 'program.add_linktype'
+      | 'program.add_media'
+      | 'program.add_mediasource'
+      | 'program.add_musicfocus'
+      | 'program.add_note'
+      | 'program.add_notelink'
+      | 'program.add_playlist'
+      | 'program.add_playlistentry'
+      | 'program.add_profile'
+      | 'program.add_profilelink'
+      | 'program.add_radiosettings'
+      | 'program.add_rrule'
+      | 'program.add_schedule'
+      | 'program.add_show'
+      | 'program.add_showlink'
+      | 'program.add_timeslot'
+      | 'program.add_topic'
+      | 'program.add_type'
+      | 'program.add_userprofile'
+      | 'program.change_category'
+      | 'program.change_cba'
+      | 'program.change_contentlicense'
+      | 'program.change_episode'
+      | 'program.change_episodelink'
+      | 'program.change_fundingcategory'
+      | 'program.change_host'
+      | 'program.change_hostlink'
+      | 'program.change_image'
+      | 'program.change_language'
+      | 'program.change_license'
+      | 'program.change_licensetype'
+      | 'program.change_linktype'
+      | 'program.change_media'
+      | 'program.change_mediasource'
+      | 'program.change_musicfocus'
+      | 'program.change_note'
+      | 'program.change_notelink'
+      | 'program.change_playlist'
+      | 'program.change_playlistentry'
+      | 'program.change_profile'
+      | 'program.change_profilelink'
+      | 'program.change_radiosettings'
+      | 'program.change_rrule'
+      | 'program.change_schedule'
+      | 'program.change_show'
+      | 'program.change_showlink'
+      | 'program.change_timeslot'
+      | 'program.change_topic'
+      | 'program.change_type'
+      | 'program.change_userprofile'
+      | 'program.create_cba'
+      | 'program.create_episode'
+      | 'program.create_media'
+      | 'program.create_note'
+      | 'program.create_playlist'
+      | 'program.delete_category'
+      | 'program.delete_cba'
+      | 'program.delete_contentlicense'
+      | 'program.delete_episode'
+      | 'program.delete_episodelink'
+      | 'program.delete_fundingcategory'
+      | 'program.delete_host'
+      | 'program.delete_hostlink'
+      | 'program.delete_image'
+      | 'program.delete_language'
+      | 'program.delete_license'
+      | 'program.delete_licensetype'
+      | 'program.delete_linktype'
+      | 'program.delete_media'
+      | 'program.delete_mediasource'
+      | 'program.delete_musicfocus'
+      | 'program.delete_note'
+      | 'program.delete_notelink'
+      | 'program.delete_playlist'
+      | 'program.delete_playlistentry'
+      | 'program.delete_profile'
+      | 'program.delete_profilelink'
+      | 'program.delete_radiosettings'
+      | 'program.delete_rrule'
+      | 'program.delete_schedule'
+      | 'program.delete_show'
+      | 'program.delete_showlink'
+      | 'program.delete_timeslot'
+      | 'program.delete_topic'
+      | 'program.delete_type'
+      | 'program.delete_userprofile'
+      | 'program.destroy_media'
+      | 'program.destroy_playlist'
+      | 'program.display__show__internal_note'
+      | 'program.edit__episode__cba_id'
+      | 'program.edit__episode__content'
+      | 'program.edit__episode__contributors'
+      | 'program.edit__episode__image'
+      | 'program.edit__episode__languages'
+      | 'program.edit__episode__links'
+      | 'program.edit__episode__media'
+      | 'program.edit__episode__media_description'
+      | 'program.edit__episode__memo'
+      | 'program.edit__episode__playlist'
+      | 'program.edit__episode__summary'
+      | 'program.edit__episode__tags'
+      | 'program.edit__episode__title'
+      | 'program.edit__episode__topics'
+      | 'program.edit__host__biography'
+      | 'program.edit__host__email'
+      | 'program.edit__host__image'
+      | 'program.edit__host__links'
+      | 'program.edit__host__name'
+      | 'program.edit__host__owners'
+      | 'program.edit__note__cba_id'
+      | 'program.edit__note__content'
+      | 'program.edit__note__contributors'
+      | 'program.edit__note__image'
+      | 'program.edit__note__language'
+      | 'program.edit__note__languages'
+      | 'program.edit__note__links'
+      | 'program.edit__note__playlist'
+      | 'program.edit__note__summary'
+      | 'program.edit__note__tags'
+      | 'program.edit__note__title'
+      | 'program.edit__note__topic'
+      | 'program.edit__note__topics'
+      | 'program.edit__profile__biography'
+      | 'program.edit__profile__email'
+      | 'program.edit__profile__image'
+      | 'program.edit__profile__links'
+      | 'program.edit__profile__name'
+      | 'program.edit__profile__owners'
+      | 'program.edit__schedule__default_media_id'
+      | 'program.edit__schedule__default_playlist_id'
+      | 'program.edit__show__categories'
+      | 'program.edit__show__cba_series_id'
+      | 'program.edit__show__default_media_id'
+      | 'program.edit__show__default_playlist'
+      | 'program.edit__show__default_playlist_id'
+      | 'program.edit__show__description'
+      | 'program.edit__show__email'
+      | 'program.edit__show__funding_categories'
+      | 'program.edit__show__hosts'
+      | 'program.edit__show__image'
+      | 'program.edit__show__internal_note'
+      | 'program.edit__show__is_active'
+      | 'program.edit__show__languages'
+      | 'program.edit__show__links'
+      | 'program.edit__show__logo'
+      | 'program.edit__show__music_focuses'
+      | 'program.edit__show__name'
+      | 'program.edit__show__owners'
+      | 'program.edit__show__predecessor'
+      | 'program.edit__show__short_description'
+      | 'program.edit__show__slug'
+      | 'program.edit__show__topics'
+      | 'program.edit__show__type'
+      | 'program.edit__timeslot__memo'
+      | 'program.edit__timeslot__playlist'
+      | 'program.edit__timeslot__playlist_id'
+      | 'program.edit__timeslot__repetition_of'
+      | 'program.update_cba'
+      | 'program.update_episode'
+      | 'program.update_host'
+      | 'program.update_media'
+      | 'program.update_note'
+      | 'program.update_playlist'
+      | 'program.update_profile'
+      | 'program.update_show'
+      | 'program.update_timeslot'
+      | 'program.view_category'
+      | 'program.view_cba'
+      | 'program.view_contentlicense'
+      | 'program.view_episode'
+      | 'program.view_episodelink'
+      | 'program.view_fundingcategory'
+      | 'program.view_host'
+      | 'program.view_hostlink'
+      | 'program.view_image'
+      | 'program.view_language'
+      | 'program.view_license'
+      | 'program.view_licensetype'
+      | 'program.view_linktype'
+      | 'program.view_media'
+      | 'program.view_mediasource'
+      | 'program.view_musicfocus'
+      | 'program.view_note'
+      | 'program.view_notelink'
+      | 'program.view_playlist'
+      | 'program.view_playlistentry'
+      | 'program.view_profile'
+      | 'program.view_profilelink'
+      | 'program.view_radiosettings'
+      | 'program.view_rrule'
+      | 'program.view_schedule'
+      | 'program.view_show'
+      | 'program.view_showlink'
+      | 'program.view_timeslot'
+      | 'program.view_topic'
+      | 'program.view_type'
+      | 'program.view_userprofile'
   }
   responses: never
   parameters: never
diff --git a/src/types.ts b/src/types.ts
index 0f11fa54..fe8c68e2 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -35,6 +35,8 @@ export type ImageFrameSpec = {
 
 type ReadonlyAttrs = 'id' | 'createdAt' | 'createdBy' | 'updatedAt' | 'updatedBy'
 
+export type AnyPermission = steeringComponents['schemas']['AnyPermission']
+
 type _Episode = steeringComponents['schemas']['Episode']
 type _EpisodeReadonlyAttrs = ReadonlyAttrs | 'timeslotIds'
 export type Episode = Required<_Episode>
-- 
GitLab