From 03802078698d82a06f8ad1e6959bcf7aebbc379c Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Tue, 21 Jan 2025 23:03:12 +0100 Subject: [PATCH] Add series ACL page This required a bunch of changes: - The `update_acl` endpoint which talks to Opencast was generalized to work for both events and series, as their acl `put` endpoints pretty much work the same. - The access page of events was refactored and most code is now usable for both events and series. This tries to walk the thin line between modularity and overspecialization by attempting to balance out reusablility and complexity, limiting both duplicated code and prop drilling. --- backend/src/api/model/event.rs | 87 +++++------- backend/src/api/model/series.rs | 77 ++++++++++- backend/src/api/model/shared.rs | 55 ++++++++ backend/src/api/mutation.rs | 8 ++ backend/src/sync/client.rs | 42 ++---- backend/src/sync/mod.rs | 2 +- frontend/src/i18n/locales/de.yaml | 11 +- frontend/src/i18n/locales/en.yaml | 7 +- frontend/src/router.tsx | 2 + frontend/src/routes/Upload.tsx | 2 +- frontend/src/routes/manage/Series/Access.tsx | 79 +++++++++++ frontend/src/routes/manage/Series/Shared.tsx | 1 + frontend/src/routes/manage/Series/index.tsx | 1 - .../src/routes/manage/Shared/AccessUI.tsx | 102 ++++++++++++++ frontend/src/routes/manage/Video/Access.tsx | 130 +++++------------- frontend/src/routes/manage/Video/Shared.tsx | 2 +- frontend/src/schema.graphql | 7 + 17 files changed, 423 insertions(+), 192 deletions(-) create mode 100644 frontend/src/routes/manage/Series/Access.tsx create mode 100644 frontend/src/routes/manage/Shared/AccessUI.tsx diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index fa356116e..ea9da1e09 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use chrono::{DateTime, Utc}; use hyper::StatusCode; use postgres_types::ToSql; @@ -14,6 +12,7 @@ use crate::{ acl::{self, Acl}, realm::Realm, series::Series, + shared::convert_acl_input, }, Context, Id, @@ -21,11 +20,12 @@ use crate::{ NodeValue, }, db::{ - types::{EventCaption, EventSegment, EventState, EventTrack, Credentials}, + types::{Credentials, EventCaption, EventSegment, EventState, EventTrack}, util::{impl_from_db, select}, }, - model::{Key, ExtraMetadata}, + model::{ExtraMetadata, Key}, prelude::*, + sync::client::{AclInput, OcEndpoint} }; use self::{acl::AclInputEntry, err::ApiError}; @@ -579,7 +579,7 @@ impl AuthorizedEvent { let response = context .oc_client - .update_event_acl(&event.opencast_id, &acl, context) + .update_acl(&event, &event.opencast_id, &acl, context) .await .map_err(|e| { error!("Failed to send acl update request: {}", e); @@ -674,6 +674,34 @@ impl LoadableAsset for AuthorizedEvent { } } +impl OcEndpoint for AuthorizedEvent { + fn endpoint_name(&self) -> &'static str { + "events" + } + + async fn extra_roles(&self, context: &Context, oc_id: &str) -> Result> { + let query = "\ + select unnest(preview_roles) as role, 'preview' as action from events where opencast_id = $1 + union + select role, key as action + from jsonb_each_text( + (select custom_action_roles from events where opencast_id = $1) + ) as actions(key, value) + cross join lateral jsonb_array_elements_text(value::jsonb) as role(role) + "; + + context.db.query_mapped(&query, dbargs![&oc_id], |row| { + let role: String = row.get("role"); + let action: String = row.get("action"); + AclInput { + allow: true, + action, + role, + } + }).await.map_err(Into::into) + } +} + impl From for Track { fn from(src: EventTrack) -> Self { Self { @@ -726,52 +754,3 @@ impl EventConnection { pub(crate) struct RemovedEvent { id: Id, } - -#[derive(Debug)] -struct AclForDB { - // todo: add custom and preview roles when sent by frontend - // preview_roles: Vec, - read_roles: Vec, - write_roles: Vec, - // custom_action_roles: CustomActions, -} - -fn convert_acl_input(entries: Vec) -> AclForDB { - // let mut preview_roles = HashSet::new(); - let mut read_roles = HashSet::new(); - let mut write_roles = HashSet::new(); - // let mut custom_action_roles = CustomActions::default(); - - for entry in entries { - let role = entry.role; - for action in entry.actions { - match action.as_str() { - // "preview" => { - // preview_roles.insert(role.clone()); - // } - "read" => { - read_roles.insert(role.clone()); - } - "write" => { - write_roles.insert(role.clone()); - } - _ => { - // custom_action_roles - // .0 - // .entry(action) - // .or_insert_with(Vec::new) - // .push(role.clone()); - todo!(); - } - }; - } - } - - AclForDB { - // todo: add custom and preview roles when sent by frontend - // preview_roles: preview_roles.into_iter().collect(), - read_roles: read_roles.into_iter().collect(), - write_roles: write_roles.into_iter().collect(), - // custom_action_roles, - } -} diff --git a/backend/src/api/model/series.rs b/backend/src/api/model/series.rs index 6dbdd1255..4db335978 100644 --- a/backend/src/api/model/series.rs +++ b/backend/src/api/model/series.rs @@ -1,25 +1,29 @@ use chrono::{DateTime, Utc}; +use hyper::StatusCode; use juniper::{graphql_object, GraphQLObject, GraphQLInputObject}; use postgres_types::ToSql; use crate::{ api::{ Context, Id, Node, NodeValue, - err::{invalid_input, ApiResult}, + err::{self, invalid_input, ApiResult}, model::{ + acl::{self, Acl}, event::AuthorizedEvent, realm::Realm, - acl::{self, Acl}, + shared::convert_acl_input }, }, db::{ types::SeriesState as State, util::{impl_from_db, select}, }, - model::{Key, ExtraMetadata}, - prelude::*, + model::{ExtraMetadata, Key}, + prelude::*, sync::client::{AclInput, OcEndpoint}, }; +use self::acl::AclInputEntry; + use super::{ block::{BlockValue, NewSeriesBlock, VideoListLayout, VideoListOrder}, playlist::VideoListEntry, @@ -302,6 +306,60 @@ impl Series { Ok(SeriesConnection { inner: conn }) } + + pub(crate) async fn update_acl(id: Id, acl: Vec, context: &Context) -> ApiResult { + if !context.config.general.allow_acl_edit { + return Err(err::not_authorized!("editing ACLs is not allowed")); + } + + let series = Self::load_by_id(id, context) + .await? + .ok_or_else(|| invalid_input!("`seriesId` does not refer to a valid series"))?; + + info!(series_id = %id, "Requesting ACL update of series"); + + let response = context + .oc_client + .update_acl(&series, &series.opencast_id, &acl, context) + .await + .map_err(|e| { + error!("Failed to send acl update request: {}", e); + err::opencast_unavailable!("Failed to send acl update request") + })?; + + if response.status() == StatusCode::OK { + // 200: The updated access control list is returned. + let db_acl = convert_acl_input(acl); + + context.db.execute("\ + update series \ + set read_roles = $2, write_roles = $3 \ + where id = $1 \ + ", &[&series.key, &db_acl.read_roles, &db_acl.write_roles]).await?; + + if context.config.general.lock_acl_to_series { + context.db.execute("\ + update events \ + set read_roles = $2, write_roles = $3 \ + where series = $1 \ + ", &[&series.key, &db_acl.read_roles, &db_acl.write_roles]).await?; + } + + Self::load_by_id(id, context) + .await? + .ok_or_else(|| err::invalid_input!( + key = "series.acl.not-found", + "series not found", + )) + } else { + warn!( + series_id = %id, + "Failed to update series acl, OC returned status: {}", + response.status(), + ); + Err(err::opencast_error!("Opencast API error: {}", response.status())) + } + } } /// Represents an Opencast series. @@ -384,6 +442,17 @@ impl Node for Series { } } +impl OcEndpoint for Series { + fn endpoint_name(&self) -> &'static str { + "series" + } + + async fn extra_roles(&self, _context: &Context, _oc_id: &str) -> Result> { + // Series do not have custom or preview roles. + Ok(vec![]) + } +} + #[derive(GraphQLInputObject)] pub(crate) struct NewSeries { diff --git a/backend/src/api/model/shared.rs b/backend/src/api/model/shared.rs index 8fc229439..18894b58c 100644 --- a/backend/src/api/model/shared.rs +++ b/backend/src/api/model/shared.rs @@ -1,9 +1,13 @@ +use std::collections::HashSet; + use tokio_postgres::types::ToSql; use crate::api::err::{ApiResult, invalid_input}; use crate::api::Context; use crate::{db, FromDb, HasRoles}; use juniper::{GraphQLEnum, GraphQLInputObject, GraphQLObject}; +use super::acl::AclInputEntry; + #[derive(Debug, Clone, Copy)] pub struct SortOrder { @@ -247,3 +251,54 @@ where page_info, }) } + + +#[derive(Debug)] +pub(crate) struct AclForDB { + // todo: add custom and preview roles for events when sent by frontend + pub(crate) read_roles: Vec, + pub(crate) write_roles: Vec, + // preview_roles: Option>, + // custom_action_roles: Option, +} + +pub(crate) fn convert_acl_input(entries: Vec) -> AclForDB { + let mut read_roles = HashSet::new(); + let mut write_roles = HashSet::new(); + // let mut preview_roles = HashSet::new(); + // let mut custom_action_roles = CustomActions::default(); + + for entry in entries { + let role = entry.role; + for action in entry.actions { + match action.as_str() { + // "preview" => { + // preview_roles.insert(role.clone()); + // } + "read" => { + read_roles.insert(role.clone()); + } + "write" => { + write_roles.insert(role.clone()); + } + _ => { + // custom_action_roles + // .0 + // .entry(action) + // .or_insert_with(Vec::new) + // .push(role.clone()); + todo!(); + } + }; + } + } + + AclForDB { + read_roles: read_roles.into_iter().collect(), + write_roles: write_roles.into_iter().collect(), + // todo: add custom and preview roles when sent by frontend + // preview_roles: preview_roles.into_iter().collect(), + // custom_action_roles, + } +} + diff --git a/backend/src/api/mutation.rs b/backend/src/api/mutation.rs index b96f622e4..7ced648ba 100644 --- a/backend/src/api/mutation.rs +++ b/backend/src/api/mutation.rs @@ -79,6 +79,14 @@ impl Mutation { AuthorizedEvent::update_acl(id, acl, context).await } + /// Updates the acl of a given series by sending the changes to Opencast. + /// The `acl` parameter can include `read` and `write` roles. + /// If successful, the updated ACL are stored in Tobira without waiting for an upcoming sync - however + /// this means it might get overwritten again if the update in Opencast failed for some reason. + async fn update_series_acl(id: Id, acl: Vec, context: &Context) -> ApiResult { + Series::update_acl(id, acl, context).await + } + /// Sets the order of all children of a specific realm. /// /// `childIndices` must contain at least one element, i.e. do not call this diff --git a/backend/src/sync/client.rs b/backend/src/sync/client.rs index 5bc5ad482..7da7a4e68 100644 --- a/backend/src/sync/client.rs +++ b/backend/src/sync/client.rs @@ -151,37 +151,20 @@ impl OcClient { self.http_client.request(req).await.map_err(Into::into) } - pub async fn update_event_acl( + pub async fn update_acl( &self, + endpoint: &T, oc_id: &str, acl: &[AclInputEntry], context: &Context, ) -> Result> { - let pq = format!("/api/events/{oc_id}/acl"); + let endpoint_name = endpoint.endpoint_name(); + let pq = format!("/api/{endpoint_name}/{oc_id}/acl", ); + let mut access_policy = Vec::new(); // Temporary solution to add custom and preview roles // Todo: remove again once frontend sends these roles. - let extra_roles_sql = "\ - select unnest(preview_roles) as role, 'preview' as action from events where opencast_id = $1 - union - select role, key as action - from jsonb_each_text( - (select custom_action_roles from events where opencast_id = $1) - ) as actions(key, value) - cross join lateral jsonb_array_elements_text(value::jsonb) as role(role) - "; - - let extra_roles = context.db.query_mapped(&extra_roles_sql, dbargs![&oc_id], |row| { - let role: String = row.get("role"); - let action: String = row.get("action"); - AclInput { - allow: true, - action, - role, - } - }).await?; - - let mut access_policy = Vec::new(); + let extra_roles = endpoint.extra_roles(context, oc_id).await?; access_policy.extend(extra_roles); for entry in acl { @@ -298,13 +281,18 @@ pub struct ExternalApiVersions { } #[derive(Debug, Serialize)] -struct AclInput { - allow: bool, - action: String, - role: String, +pub(crate) struct AclInput { + pub allow: bool, + pub action: String, + pub role: String, } #[derive(Debug, Deserialize)] pub struct EventStatus { pub processing_state: String, } + +pub(crate) trait OcEndpoint { + fn endpoint_name(&self) -> &'static str; + async fn extra_roles(&self, context: &Context, oc_id: &str) -> Result>; +} diff --git a/backend/src/sync/mod.rs b/backend/src/sync/mod.rs index b24bc5392..22c79b523 100644 --- a/backend/src/sync/mod.rs +++ b/backend/src/sync/mod.rs @@ -8,7 +8,7 @@ pub(crate) mod cmd; pub(crate) mod harvest; pub(crate) mod stats; pub(crate) mod text; -mod client; +pub(crate) mod client; mod status; pub(crate) use self::client::OcClient; diff --git a/frontend/src/i18n/locales/de.yaml b/frontend/src/i18n/locales/de.yaml index 6c6bae842..804567be4 100644 --- a/frontend/src/i18n/locales/de.yaml +++ b/frontend/src/i18n/locales/de.yaml @@ -447,12 +447,14 @@ manage: share-direct-link: Via Direktlink teilen copy-direct-link-to-clipboard: Videolink in Zwischenablage kopieren acl: - title: Access policy + title: Zugriffsrechte my-series: title: Meine Serien content: Inhalt no-of-videos: '{{count}} Videos' + details: + title: Seriendetails my-videos: title: Meine Videos @@ -477,9 +479,6 @@ manage: pending: > Der Löschvorgang dieses Videos in Opencast wurde angesetzt und es wird nach dessen Erfolg hier entfernt. - - acl: - title: Zugriffsrechte technical-details: title: Technische Details tracks: Video-/Audiospuren @@ -690,6 +689,10 @@ api-remote-errors: workflow: not-allowed: Sie haben nicht die Berechtigung, die Workflowaktivität für dieses Video abzufragen. active: $t(manage.access.workflow-active) + series: + acl: + not-found: "Zugriffsrechte konnten nicht geändert werden: Serie nicht gefunden." + not-allowed: Sie haben nicht die Berechtigung, die Zugriffsreche dieser Serie zu ändern. embed: not-supported: Diese Seite kann nicht eingebettet werden. diff --git a/frontend/src/i18n/locales/en.yaml b/frontend/src/i18n/locales/en.yaml index 88b6d45bc..04db53748 100644 --- a/frontend/src/i18n/locales/en.yaml +++ b/frontend/src/i18n/locales/en.yaml @@ -460,9 +460,6 @@ manage: pending: > The deletion of this video was requested in Opencast and it will be removed from this list upon success. - - acl: - title: Access policy technical-details: title: Technical details tracks: Video/audio tracks @@ -668,6 +665,10 @@ api-remote-errors: workflow: not-allowed: You are not allowed to inquire about workflow activity of this video. active: $t(manage.access.workflow-active) + series: + acl: + not-found: "Access policy update failed: series not found." + not-allowed: You are not allowed to update the access policies of this series. embed: not-supported: This page can't be embedded. diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index e0ff22ac5..867f7f8c4 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -31,6 +31,7 @@ import { ManageVideoAccessRoute } from "./routes/manage/Video/Access"; import { DirectPlaylistOCRoute, DirectPlaylistRoute } from "./routes/Playlist"; import { ManageSeriesRoute } from "./routes/manage/Series"; import { ManageSeriesDetailsRoute } from "./routes/manage/Series/Details"; +import { ManageSeriesAccessRoute } from "./routes/manage/Series/Access"; @@ -67,6 +68,7 @@ const { ManageVideoDetailsRoute, ManageVideoTechnicalDetailsRoute, ManageRealmRoute, + ManageSeriesAccessRoute, ManageSeriesRoute, ManageSeriesDetailsRoute, UploadRoute, diff --git a/frontend/src/routes/Upload.tsx b/frontend/src/routes/Upload.tsx index 090891ef3..7cf06f43e 100644 --- a/frontend/src/routes/Upload.tsx +++ b/frontend/src/routes/Upload.tsx @@ -848,7 +848,7 @@ const MetaDataEdit: React.FC = ({ onSave, disabled, knownRole marginTop: 32, marginBottom: 12, fontSize: 22, - }}>{t("manage.my-videos.acl.title")} + }}>{t("manage.shared.acl.title")} {boxError(aclError)} {aclLoading && } {lockedAcl && ( diff --git a/frontend/src/routes/manage/Series/Access.tsx b/frontend/src/routes/manage/Series/Access.tsx new file mode 100644 index 000000000..01ed9cbe4 --- /dev/null +++ b/frontend/src/routes/manage/Series/Access.tsx @@ -0,0 +1,79 @@ +import { graphql, useMutation } from "react-relay"; +import { currentRef } from "@opencast/appkit"; + +import { AccessKnownRolesData$key } from "../../../ui/__generated__/AccessKnownRolesData.graphql"; +import { + AccessUpdateSeriesAclMutation, +} from "./__generated__/AccessUpdateSeriesAclMutation.graphql"; +import { makeManageSeriesRoute, Series } from "./Shared"; +import { ManageSeriesRoute } from "."; +import { ManageSeriesDetailsRoute } from "./Details"; +import { displayCommitError } from "../Realm/util"; +import { AccessEditor, AclPage, SubmitAclProps } from "../Shared/AccessUI"; +import i18n from "../../../i18n"; + + +export const ManageSeriesAccessRoute = makeManageSeriesRoute( + "acl", + "/access", + (series, data) => ( + + + + ), +); + + +const updateSeriesAcl = graphql` + mutation AccessUpdateSeriesAclMutation($id: ID!, $acl: [AclInputEntry!]!) { + updateSeriesAcl(id: $id, acl: $acl) { + ...on Series { + acl { role actions info { label implies large } } + } + } + } +`; + + +type SeriesAclPageProps = { + series: Series; + data: AccessKnownRolesData$key; +}; + +const SeriesAclEditor: React.FC = ({ series, data }) => { + const [commit, inFlight] = useMutation(updateSeriesAcl); + + const onSubmit = async ({ selections, saveModalRef, setCommitError }: SubmitAclProps) => { + commit({ + variables: { + id: series.id, + acl: [...selections].map( + ([role, { actions }]) => ({ + role, + actions: [...actions], + }) + ), + }, + onCompleted: () => currentRef(saveModalRef).done(), + onError: error => { + setCommitError(displayCommitError(error)); + }, + }); + }; + + + return <> + + ; +}; + diff --git a/frontend/src/routes/manage/Series/Shared.tsx b/frontend/src/routes/manage/Series/Shared.tsx index ddb1ba44d..87685a01a 100644 --- a/frontend/src/routes/manage/Series/Shared.tsx +++ b/frontend/src/routes/manage/Series/Shared.tsx @@ -78,6 +78,7 @@ const query = graphql` title created updated + acl { role actions info { label implies large } } syncedData { description } entries { __typename diff --git a/frontend/src/routes/manage/Series/index.tsx b/frontend/src/routes/manage/Series/index.tsx index d1143248b..15db680a0 100644 --- a/frontend/src/routes/manage/Series/index.tsx +++ b/frontend/src/routes/manage/Series/index.tsx @@ -121,7 +121,6 @@ export const seriesColumns: ColumnProps[] = [ export const SeriesRow: React.FC<{ series: SingleSeries }> = ({ series }) => { - // Todo: change to "series details" route when available const link = `${PATH}/${keyOfId(series.id)}`; return ( diff --git a/frontend/src/routes/manage/Shared/AccessUI.tsx b/frontend/src/routes/manage/Shared/AccessUI.tsx new file mode 100644 index 000000000..002d7b564 --- /dev/null +++ b/frontend/src/routes/manage/Shared/AccessUI.tsx @@ -0,0 +1,102 @@ +import { useRef, useState, RefObject, SetStateAction, PropsWithChildren, ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useFragment } from "react-relay"; +import { Card, ConfirmationModalHandle, boxError } from "@opencast/appkit"; + +import { AccessKnownRolesData$key } from "../../../ui/__generated__/AccessKnownRolesData.graphql"; +import { Acl, AclSelector, AclEditButtons, knownRolesFragment } from "../../../ui/Access"; +import { READ_WRITE_ACTIONS } from "../../../util/permissionLevels"; +import { AclArray } from "../../Upload"; +import { mapAcl } from "../../util"; +import { ManageRoute } from ".."; +import CONFIG from "../../../config"; +import { PageTitle } from "../../../layout/header/ui"; +import { Breadcrumbs } from "../../../ui/Breadcrumbs"; +import { NotAuthorized } from "../../../ui/error"; +import { useUser, isRealUser } from "../../../User"; + + +type AclPageProps = PropsWithChildren & { + note?: ReactNode; + breadcrumbTails: { + label: string; + link: string; + }[]; +} +export const AclPage: React.FC = ({ children, note, breadcrumbTails }) => { + const { t } = useTranslation(); + const user = useUser(); + + if (!isRealUser(user)) { + return ; + } + + const breadcrumbs = [ + { label: t("user.manage-content"), link: ManageRoute.url }, + ...breadcrumbTails, + ]; + + return <> + + + {note} +
+ {CONFIG.allowAclEdit + ? children + : {t("manage.access.editing-disabled")} + } + ; +}; + +export type SubmitAclProps = { + selections: Acl; + saveModalRef: RefObject; + setCommitError: (value: SetStateAction) => void; +} + +type AccessEditorProps = { + rawAcl: AclArray; + onSubmit: ({ selections, saveModalRef, setCommitError }: SubmitAclProps) => Promise; + inFlight: boolean; + data: AccessKnownRolesData$key; + editingBlocked?: boolean; +}; + +export const AccessEditor: React.FC = ({ + rawAcl, + onSubmit, + inFlight, + data, + editingBlocked = false, +}) => { + const knownRoles = useFragment(knownRolesFragment, data); + const saveModalRef = useRef(null); + const acl = mapAcl(rawAcl); + const [selections, setSelections] = useState(acl); + const [commitError, setCommitError] = useState(null); + + return
+
+
+ + onSubmit({ selections, saveModalRef, setCommitError })} + kind="write" + /> +
+ {boxError(commitError)} +
+
; +}; diff --git a/frontend/src/routes/manage/Video/Access.tsx b/frontend/src/routes/manage/Video/Access.tsx index 5d496c8d9..6e6f39c31 100644 --- a/frontend/src/routes/manage/Video/Access.tsx +++ b/frontend/src/routes/manage/Video/Access.tsx @@ -1,71 +1,35 @@ import { Trans, useTranslation } from "react-i18next"; -import { boxError, Card, currentRef, WithTooltip } from "@opencast/appkit"; -import { useRef, useState } from "react"; +import { Card, currentRef, WithTooltip } from "@opencast/appkit"; +import { useState } from "react"; import { LuInfo } from "react-icons/lu"; -import { graphql, useFragment, useMutation } from "react-relay"; +import { graphql, useMutation } from "react-relay"; -import { Breadcrumbs } from "../../../ui/Breadcrumbs"; import { AuthorizedEvent, makeManageVideoRoute } from "./Shared"; -import { PageTitle } from "../../../layout/header/ui"; import { COLORS } from "../../../color"; -import { isRealUser, useUser } from "../../../User"; -import { NotAuthorized } from "../../../ui/error"; -import { Acl, AclSelector, AclEditButtons, knownRolesFragment } from "../../../ui/Access"; -import { - AccessKnownRolesData$data, - AccessKnownRolesData$key, -} from "../../../ui/__generated__/AccessKnownRolesData.graphql"; -import { ManageRoute } from ".."; +import { AccessKnownRolesData$key } from "../../../ui/__generated__/AccessKnownRolesData.graphql"; import { ManageVideosRoute } from "."; import { ManageVideoDetailsRoute } from "./Details"; -import { READ_WRITE_ACTIONS } from "../../../util/permissionLevels"; -import { ConfirmationModalHandle } from "../../../ui/Modal"; import { displayCommitError } from "../Realm/util"; -import { AccessUpdateAclMutation } from "./__generated__/AccessUpdateAclMutation.graphql"; +import { AccessUpdateEventAclMutation } from "./__generated__/AccessUpdateEventAclMutation.graphql"; import CONFIG from "../../../config"; -import { mapAcl } from "../../util"; +import { AccessEditor, AclPage, SubmitAclProps } from "../Shared/AccessUI"; +import i18n from "../../../i18n"; export const ManageVideoAccessRoute = makeManageVideoRoute( "acl", "/access", - (event, data) => , + (event, data) => ( + } breadcrumbTails={[ + { label: i18n.t("manage.my-videos.title"), link: ManageVideosRoute.url }, + { label: event.title, link: ManageVideoDetailsRoute.url({ videoId: event.id }) }, + ]}> + + + ), { fetchWorkflowState: true }, ); -type AclPageProps = { - event: AuthorizedEvent; - data: AccessKnownRolesData$key; -}; - -const AclPage: React.FC = ({ event, data }) => { - const { t } = useTranslation(); - const user = useUser(); - - if (!isRealUser(user)) { - return ; - } - - const knownRoles = useFragment(knownRolesFragment, data); - - const breadcrumbs = [ - { label: t("user.manage-content"), link: ManageRoute.url }, - { label: t("manage.my-videos.title"), link: ManageVideosRoute.url }, - { label: event.title, link: ManageVideoDetailsRoute.url({ videoId: event.id }) }, - ]; - - return <> - - - {event.hostRealms.length < 1 && } -
- {CONFIG.allowAclEdit - ? - : {t("manage.access.editing-disabled")} - } - ; -}; - const UnlistedNote: React.FC = () => { const { t } = useTranslation(); @@ -92,8 +56,9 @@ const UnlistedNote: React.FC = () => { ); }; + const updateVideoAcl = graphql` - mutation AccessUpdateAclMutation($id: ID!, $acl: [AclInputEntry!]!) { + mutation AccessUpdateEventAclMutation($id: ID!, $acl: [AclInputEntry!]!) { updateEventAcl(id: $id, acl: $acl) { ...on AuthorizedEvent { acl { role actions info { label implies large } } @@ -102,26 +67,21 @@ const updateVideoAcl = graphql` } `; -type AccessUIProps = { + +type EventAclPageProps = { event: AuthorizedEvent; - knownRoles: AccessKnownRolesData$data; -} + data: AccessKnownRolesData$key; +}; -const AccessUI: React.FC = ({ event, knownRoles }) => { +const EventAclEditor: React.FC = ({ event, data }) => { const { t } = useTranslation(); - const saveModalRef = useRef(null); - const [commitError, setCommitError] = useState(null); - const [commit, inFlight] = useMutation(updateVideoAcl); - const aclLockedToSeries = CONFIG.lockAclToSeries && event.series; + const [commit, inFlight] = useMutation(updateVideoAcl); + const aclLockedToSeries = CONFIG.lockAclToSeries && !!event.series; const [editingBlocked, setEditingBlocked] = useState( event.hasActiveWorkflows || aclLockedToSeries ); - const initialAcl: Acl = mapAcl(event.acl); - - const [selections, setSelections] = useState(initialAcl); - - const onSubmit = async () => { + const onSubmit = async ({ selections, saveModalRef, setCommitError }: SubmitAclProps) => { commit({ variables: { id: event.id, @@ -140,7 +100,6 @@ const AccessUI: React.FC = ({ event, knownRoles }) => { }); }; - return <> {event.hasActiveWorkflows && @@ -150,36 +109,15 @@ const AccessUI: React.FC = ({ event, knownRoles }) => { {t("manage.access.locked-to-series")} )} -
-
-
- - -
- {boxError(commitError)} -
-
+ ; }; diff --git a/frontend/src/routes/manage/Video/Shared.tsx b/frontend/src/routes/manage/Video/Shared.tsx index 946f1e3f6..82086af4c 100644 --- a/frontend/src/routes/manage/Video/Shared.tsx +++ b/frontend/src/routes/manage/Video/Shared.tsx @@ -154,7 +154,7 @@ const ManageVideoNav: React.FC = ({ event, active }) => { navEntries.splice(1, 0, { url: `/~manage/videos/${id}/access`, page: "acl", - body: <>{t("manage.my-videos.acl.title")}, + body: <>{t("manage.shared.acl.title")}, }); } diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index 2150af24d..44dae1232 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -389,6 +389,13 @@ type Mutation { This solution should be improved in the future. """ updateEventAcl(id: ID!, acl: [AclInputEntry!]!): AuthorizedEvent! + """ + Updates the acl of a given series by sending the changes to Opencast. + The `acl` parameter can include `read` and `write` roles. + If successful, the updated ACL are stored in Tobira without waiting for an upcoming sync - however + this means it might get overwritten again if the update in Opencast failed for some reason. + """ + updateSeriesAcl(id: ID!, acl: [AclInputEntry!]!): Series! """ Sets the order of all children of a specific realm.