From b94247eaadbc2a14f1b6fee498ad8b4472eb0302 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 | 79 ++++++++++- 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, 424 insertions(+), 193 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 cacda42e4..da7c5f7e6 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 06dc5db94..b76fdd34e 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, @@ -105,7 +109,7 @@ impl Series { .pipe(Ok) } - async fn load_acl(&self, context: &Context) -> ApiResult { + async fn load_acl(&self, context: &Context) -> ApiResult { let raw_roles_sql = "\ select unnest($1::text[]) as role, 'read' as action union @@ -286,6 +290,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. @@ -368,6 +426,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 78a0c5f3e..c18d31064 100644 --- a/frontend/src/i18n/locales/de.yaml +++ b/frontend/src/i18n/locales/de.yaml @@ -446,12 +446,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 @@ -476,9 +478,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 @@ -689,6 +688,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 c793c71fb..bc8b7c241 100644 --- a/frontend/src/i18n/locales/en.yaml +++ b/frontend/src/i18n/locales/en.yaml @@ -459,9 +459,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 @@ -667,6 +664,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 c26975c84..c0174ed70 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"; @@ -66,6 +67,7 @@ const { ManageVideoDetailsRoute, ManageVideoTechnicalDetailsRoute, ManageRealmRoute, + ManageSeriesAccessRoute, ManageSeriesRoute, ManageSeriesDetailsRoute, UploadRoute, diff --git a/frontend/src/routes/Upload.tsx b/frontend/src/routes/Upload.tsx index a80ef0ffc..e66f22215 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 91009e53e..ca564c75f 100644 --- a/frontend/src/routes/manage/Series/index.tsx +++ b/frontend/src/routes/manage/Series/index.tsx @@ -115,7 +115,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 13efabef0..5f8420261 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -384,6 +384,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.