diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index 1bae88e02..0c6a4a9cc 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -219,8 +219,7 @@ impl AuthorizedEvent { fn write_roles(&self) -> &[String] { &self.write_roles } - /// This includes all read roles (and by extension write roles, - /// as they are a subset of read roles). + /// This doesn't contain `ROLE_ADMIN` as that is included implicitly. fn preview_roles(&self) -> &[String] { &self.preview_roles } @@ -365,7 +364,7 @@ impl AuthorizedEvent { .await? .map(|row| { let event = Self::from_row_start(&row); - if context.auth.overlaps_roles(&event.preview_roles) { + if event.can_be_previewed(context) { Event::Event(event) } else { Event::NotAllowed(NotAllowed) @@ -386,7 +385,7 @@ impl AuthorizedEvent { context.db .query_mapped(&query, dbargs![&series_key], |row| { let event = Self::from_row_start(&row); - if !context.auth.overlaps_roles(&event.preview_roles) { + if !event.can_be_previewed(context) { return VideoListEntry::NotAllowed(NotAllowed); } @@ -396,6 +395,11 @@ impl AuthorizedEvent { .pipe(Ok) } + fn can_be_previewed(&self, context: &Context) -> bool { + context.auth.overlaps_roles(&self.preview_roles) + || context.auth.overlaps_roles(&self.read_roles) + } + pub(crate) async fn delete(id: Id, context: &Context) -> ApiResult { let event = Self::load_by_id(id, context) .await? diff --git a/backend/src/api/model/search/event.rs b/backend/src/api/model/search/event.rs index c3a4211b0..c92214000 100644 --- a/backend/src/api/model/search/event.rs +++ b/backend/src/api/model/search/event.rs @@ -69,7 +69,9 @@ impl Node for SearchEvent { impl SearchEvent { pub(crate) fn without_matches(src: search::Event, context: &Context) -> Self { - Self::new_inner(src, vec![], SearchEventMatches::default(), context) + let read_roles = decode_acl(&src.read_roles); + let user_can_read = context.auth.overlaps_roles(read_roles); + Self::new_inner(src, vec![], SearchEventMatches::default(), user_can_read) } pub(crate) fn new(hit: meilisearch_sdk::SearchResult, context: &Context) -> Self { @@ -77,16 +79,20 @@ impl SearchEvent { let src = hit.result; let mut text_matches = Vec::new(); - src.slide_texts.resolve_matches( - match_ranges_for(match_positions, "slide_texts.texts"), - &mut text_matches, - TextAssetType::SlideText, - ); - src.caption_texts.resolve_matches( - match_ranges_for(match_positions, "caption_texts.texts"), - &mut text_matches, - TextAssetType::Caption, - ); + let read_roles = decode_acl(&src.read_roles); + let user_can_read = context.auth.overlaps_roles(read_roles); + if user_can_read { + src.slide_texts.resolve_matches( + match_ranges_for(match_positions, "slide_texts.texts"), + &mut text_matches, + TextAssetType::SlideText, + ); + src.caption_texts.resolve_matches( + match_ranges_for(match_positions, "caption_texts.texts"), + &mut text_matches, + TextAssetType::Caption, + ); + } let matches = SearchEventMatches { title: field_matches_for(match_positions, "title"), @@ -94,25 +100,15 @@ impl SearchEvent { series_title: field_matches_for(match_positions, "series_title"), }; - Self::new_inner(src, text_matches, matches, context) + Self::new_inner(src, text_matches, matches, user_can_read) } fn new_inner( src: search::Event, text_matches: Vec, matches: SearchEventMatches, - context: &Context, + user_can_read: bool, ) -> Self { - let read_roles = decode_acl(&src.read_roles); - let user_is_authorized = context.auth.overlaps_roles(read_roles); - let thumbnail = { - if user_is_authorized { - src.thumbnail - } else { - None - } - }; - Self { id: Id::search_event(src.id.0), series_id: src.series_id.map(|id| Id::search_series(id.0)), @@ -120,7 +116,7 @@ impl SearchEvent { title: src.title, description: src.description, creators: src.creators, - thumbnail, + thumbnail: if user_can_read { src.thumbnail } else { None }, duration: src.duration as f64, created: src.created, start_time: src.start_time, @@ -133,7 +129,7 @@ impl SearchEvent { text_matches, matches, has_password: src.has_password, - user_is_authorized, + user_is_authorized: user_can_read, } } } diff --git a/backend/src/api/model/search/mod.rs b/backend/src/api/model/search/mod.rs index ace73503a..f92c58ffb 100644 --- a/backend/src/api/model/search/mod.rs +++ b/backend/src/api/model/search/mod.rs @@ -166,7 +166,7 @@ pub(crate) async fn perform( let selection = search::Event::select(); let query = format!("select {selection} from search_events \ where id = (select id from events where opencast_id = $1) \ - and (preview_roles || 'ROLE_ADMIN'::text) && $2"); + and (preview_roles || read_roles || 'ROLE_ADMIN'::text) && $2"); let items: Vec = context.db .query_opt(&query, &[&uuid_query, &context.auth.roles_vec()]) .await? @@ -186,18 +186,22 @@ pub(crate) async fn perform( // Prepare the event search - let filter = Filter::And( - std::iter::once(Filter::Leaf("listed = true".into())) - .chain(acl_filter("preview_roles", context)) - // Filter out live events that are already over. - .chain([Filter::Or([ - Filter::Leaf("is_live = false ".into()), - Filter::Leaf(format!("end_time_timestamp >= {}", Utc::now().timestamp()).into()), - ].into())]) - .chain(filters.start.map(|start| Filter::Leaf(format!("created_timestamp >= {}", start.timestamp()).into()))) - .chain(filters.end.map(|end| Filter::Leaf(format!("created_timestamp <= {}", end.timestamp()).into()))) - .collect() - ).to_string(); + let filter = Filter::and([ + Filter::listed(), + Filter::preview_or_read_access(context), + // Filter out live events that already ended + Filter::or([ + Filter::Leaf("is_live = false ".into()), + Filter::Leaf(format!("end_time_timestamp >= {}", Utc::now().timestamp()).into()), + ]), + // Apply user selected date filters + filters.start + .map(|start| Filter::Leaf(format!("created_timestamp >= {}", start.timestamp()).into())) + .unwrap_or(Filter::True), + filters.end + .map(|end| Filter::Leaf(format!("created_timestamp <= {}", end.timestamp()).into())) + .unwrap_or(Filter::True), + ]).to_string(); let event_query = context.search.event_index.search() .with_query(user_query) .with_limit(15) @@ -324,15 +328,18 @@ pub(crate) async fn all_events( return Err(context.not_logged_in_error()); } - let filter = Filter::make_or_none_for_admins(context, || { + let filter = Filter::make_or_true_for_admins(context, || { // All users can always find all events they have write access to. If // `writable_only` is false, this API also returns events that are // listed and that the user can read. - let writable = Filter::acl_access("write_roles", context); + let writable = Filter::write_access(context); if writable_only { writable } else { - Filter::or([Filter::listed_and_readable("preview_roles", context), writable]) + Filter::or([ + Filter::preview_or_read_access(context).and_listed(context), + writable, + ]) } }).to_string(); @@ -370,8 +377,8 @@ pub(crate) async fn all_series( return Err(context.not_logged_in_error()); } - let filter = Filter::make_or_none_for_admins(context, || { - let writable = Filter::acl_access("write_roles", context); + let filter = Filter::make_or_true_for_admins(context, || { + let writable = Filter::write_access(context); // All users can always find all items they have write access to, // regardless whether they are listed or not. @@ -382,7 +389,7 @@ pub(crate) async fn all_series( // Since series read_roles are not used for access control, we only need // to check whether we can return unlisted videos. if context.auth.can_find_unlisted_items(&context.config.auth) { - Filter::None + Filter::True } else { Filter::or([writable, Filter::listed()]) } @@ -421,15 +428,18 @@ pub(crate) async fn all_playlists( return Err(context.not_logged_in_error()); } - let filter = Filter::make_or_none_for_admins(context, || { + let filter = Filter::make_or_true_for_admins(context, || { // All users can always find all playlists they have write access to. If // `writable_only` is false, this API also returns playlists that are // listed and that the user can read. - let writable = Filter::acl_access("write_roles", context); + let writable = Filter::write_access(context); if writable_only { writable } else { - Filter::or([Filter::listed_and_readable("read_roles", context), writable]) + Filter::or([ + Filter::read_access(context).and_listed(context), + writable, + ]) } }).to_string(); @@ -449,39 +459,46 @@ pub(crate) async fn all_playlists( Ok(PlaylistSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() })) } -// TODO: replace usages of this and remove this. -fn acl_filter(action: &str, context: &Context) -> Option { - // If the user is admin, we just skip the filter alltogether as the admin - // can see anything anyway. - if context.auth.is_admin() { - return None; - } - - Some(Filter::acl_access(action, context)) -} enum Filter { // TODO: try to avoid Vec if not necessary. Oftentimes there are only two operands. + + /// Must not contain `Filter::None`, which is handled by `Filter::and`. And(Vec), + + /// Must not contain `Filter::None`, which is handled by `Filter::or`. Or(Vec), Leaf(Cow<'static, str>), - /// No filter. Formats to empty string and is filtered out if inside the - /// `And` or `Or` operands. - None, + /// A constant `true`. Inside `Or`, results in the whole `Or` expression + /// being replaced by `True`. Inside `And`, this is just filtered out and + /// the remaining operands are evaluated. If formated on its own, empty + /// string is emitted. + True, } impl Filter { - fn make_or_none_for_admins(context: &Context, f: impl FnOnce() -> Self) -> Self { - if context.auth.is_admin() { Self::None } else { f() } + fn make_or_true_for_admins(context: &Context, f: impl FnOnce() -> Self) -> Self { + if context.auth.is_admin() { Self::True } else { f() } } fn or(operands: impl IntoIterator) -> Self { - Self::Or(operands.into_iter().collect()) + let mut v = Vec::new(); + for op in operands { + if matches!(op, Self::True) { + return Self::True; + } + v.push(op); + } + Self::Or(v) } fn and(operands: impl IntoIterator) -> Self { - Self::And(operands.into_iter().collect()) + Self::And( + operands.into_iter() + .filter(|op| !matches!(op, Self::True)) + .collect(), + ) } /// Returns the filter "listed = true". @@ -489,24 +506,35 @@ impl Filter { Self::Leaf("listed = true".into()) } - /// Returns a filter checking that the current user has read access and that - /// the item is listed. If the user has the privilege to find unlisted - /// item, the second check is not performed. - /// "Readable" in this context can mean either "preview-able" in case of events - /// or actual "readable" in case of playlists, as they do not have preview roles. - fn listed_and_readable(roles_field: &str, context: &Context) -> Self { - let readable = Self::acl_access(roles_field, context); + /// If the user can find unlisted items, just returns `self`. Otherweise, + /// `self` is ANDed with `Self::listed()`. + fn and_listed(self, context: &Context) -> Self { if context.auth.can_find_unlisted_items(&context.config.auth) { - readable + self } else { - Self::and([readable, Self::listed()]) + Self::and([self, Self::listed()]) } } + fn read_access(context: &Context) -> Self { + Self::make_or_true_for_admins(context, || Self::acl_access_raw("read_roles", context)) + } + + fn write_access(context: &Context) -> Self { + Self::make_or_true_for_admins(context, || Self::acl_access_raw("write_roles", context)) + } + + fn preview_or_read_access(context: &Context) -> Self { + Self::make_or_true_for_admins(context, || Self::or([ + Self::acl_access_raw("read_roles", context), + Self::acl_access_raw("preview_roles", context), + ])) + } + /// Returns a filter checking if `roles_field` has any overlap with the /// current user roles. Encodes all roles as hex to work around Meili's - /// lack of case-sensitive comparison. - fn acl_access(roles_field: &str, context: &Context) -> Self { + /// lack of case-sensitive comparison. Does not handle the ROLE_ADMIN case. + fn acl_access_raw(roles_field: &str, context: &Context) -> Self { use std::io::Write; const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef"; @@ -533,10 +561,8 @@ impl Filter { impl fmt::Display for Filter { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn join(f: &mut fmt::Formatter, operands: &[Filter], sep: &str) -> fmt::Result { - if operands.iter().all(|op| matches!(op, Filter::None)) { - return Ok(()); - } - + // We are guaranteed by `and` and `or` methods that there are no + // `Self::True`s in here. write!(f, "(")?; for (i, operand) in operands.iter().enumerate() { if i > 0 { @@ -551,7 +577,7 @@ impl fmt::Display for Filter { Self::And(operands) => join(f, operands, "AND"), Self::Or(operands) => join(f, operands, "OR"), Self::Leaf(s) => write!(f, "{s}"), - Self::None => Ok(()), + Self::True => Ok(()), } } } diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index 4f6328413..74b73c47d 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -371,6 +371,5 @@ static MIGRATIONS: Lazy> = include_migrations![ 36: "playlist-blocks", 37: "redo-search-triggers-and-listed", 38: "event-texts", - 39: "event-preview-roles-and-password", - 40: "eth-series-credentials", + 39: "preview-roles-and-credentials", ]; diff --git a/backend/src/db/migrations/39-event-preview-roles-and-password.sql b/backend/src/db/migrations/39-preview-roles-and-credentials.sql similarity index 77% rename from backend/src/db/migrations/39-event-preview-roles-and-password.sql rename to backend/src/db/migrations/39-preview-roles-and-credentials.sql index e19141cbc..565f53b39 100644 --- a/backend/src/db/migrations/39-event-preview-roles-and-password.sql +++ b/backend/src/db/migrations/39-preview-roles-and-credentials.sql @@ -14,30 +14,14 @@ alter table all_events add column credentials credentials, add column preview_roles text[] not null default '{}'; --- For convenience, all read roles are also copied over to preview roles. --- Removing any roles from read will however _not_ remove them from preview, as they --- might also have been added separately and we can't really account for that. -update all_events set preview_roles = read_roles; +alter table series + add column credentials credentials; -create function sync_preview_roles() -returns trigger language plpgsql as $$ -begin - new.preview_roles := ( - select array_agg(distinct role) from unnest(new.preview_roles || new.read_roles) as role - ); - return new; -end; -$$; -create trigger sync_preview_roles_on_change -before insert or update of read_roles, preview_roles on all_events -for each row -execute function sync_preview_roles(); - --- replace outdated view to include preview_roles +-- replace outdated view to include new columnes create or replace view events as select * from all_events where tobira_deletion_timestamp is null; --- add `preview_roles` to `search_events` view as well +-- add `preview_roles` and `has_password` to `search_events` view drop view search_events; create view search_events as select diff --git a/backend/src/db/migrations/40-eth-series-credentials.sql b/backend/src/db/migrations/40-eth-series-credentials.sql deleted file mode 100644 index 9719ccc29..000000000 --- a/backend/src/db/migrations/40-eth-series-credentials.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Adds a credentials column to series that hold series specific passwords and usernames. --- These need to be synced with events that are part of the series. --- This is specific for authentication requirements of the ETH and is only useful when the --- `interpret_eth_passwords` configuration is enabled. - -alter table series add column credentials credentials; - --- When the credentials of a series change, each event that is part of it also needs to be updated. -create function sync_series_credentials() returns trigger language plpgsql as $$ -begin - update all_events set credentials = series.credentials - from series where all_events.series = series.id and series.id = new.id; - return new; -end; -$$; - -create trigger sync_series_credentials_on_change -after update on series -for each row -when (old.credentials is distinct from new.credentials) -execute function sync_series_credentials(); - --- Tobira uploads do not automatically get the credentials of their assigned series, so this needs --- to be done with an additional function and a trigger. -create function sync_credentials_before_event_insert() returns trigger language plpgsql as $$ -begin - select series.credentials into new.credentials - from series - where series.id = new.series; - return new; -end; -$$; - -create trigger sync_series_credentials_before_event_insert -before insert on all_events -for each row -execute function sync_credentials_before_event_insert(); diff --git a/backend/src/search/mod.rs b/backend/src/search/mod.rs index 89954a3ca..276e631db 100644 --- a/backend/src/search/mod.rs +++ b/backend/src/search/mod.rs @@ -42,7 +42,7 @@ pub(crate) use self::{ /// The version of search index schema. Increase whenever there is a change that /// requires an index rebuild. -const VERSION: u32 = 6; +const VERSION: u32 = 7; // ===== Configuration ============================================================================ diff --git a/frontend/src/i18n/locales/en.yaml b/frontend/src/i18n/locales/en.yaml index 38691a73a..9b0757e6e 100644 --- a/frontend/src/i18n/locales/en.yaml +++ b/frontend/src/i18n/locales/en.yaml @@ -180,6 +180,7 @@ video: password: heading: Protected Video sub-heading: Access to this video is restricted. + no-preview-permission: $t(api-remote-errors.view.event) body: > Please enter the username and the password you have received to access this video; please note that these are not your login credentials. diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index b389664a5..730057b37 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -1,4 +1,4 @@ -import { ReactNode, Suspense, useState } from "react"; +import { ReactNode, Suspense } from "react"; import { LuFrown, LuAlertTriangle } from "react-icons/lu"; import { Translation, useTranslation } from "react-i18next"; import { @@ -7,7 +7,7 @@ import { } from "react-relay"; import { unreachable } from "@opencast/appkit"; -import { eventId, getCredentials, isSynced, keyOfId, useAuthenticatedDataQuery } from "../util"; +import { eventId, getCredentials, isSynced, keyOfId } from "../util"; import { GlobalErrorBoundary } from "../util/err"; import { loadQuery } from "../relay"; import { makeRoute, MatchedRoute } from "../rauta"; @@ -19,7 +19,7 @@ import { EmbedQuery } from "./__generated__/EmbedQuery.graphql"; import { EmbedDirectOpencastQuery } from "./__generated__/EmbedDirectOpencastQuery.graphql"; import { EmbedEventData$key } from "./__generated__/EmbedEventData.graphql"; import { PlayerContextProvider } from "../ui/player/PlayerContext"; -import { AuthenticatedDataContext, AuthorizedData, PreviewPlaceholder } from "./Video"; +import { PreviewPlaceholder, useEventWithAuthData } from "./Video"; export const EmbedVideoRoute = makeRoute({ url: ({ videoId }: { videoId: string }) => `/~embed/!v/${keyOfId(videoId)}`, @@ -37,9 +37,11 @@ export const EmbedVideoRoute = makeRoute({ } `; + const creds = getCredentials("event", id); const queryRef = loadQuery(query, { id, - ...getCredentials("event", id), + eventUser: creds?.user, + eventPassword: creds?.password, }); @@ -67,9 +69,11 @@ export const EmbedOpencastVideoRoute = makeRoute({ `; const videoId = decodeURIComponent(matches[1]); + const creds = getCredentials("oc-event", videoId); const queryRef = loadQuery(query, { id: videoId, - ...getCredentials("oc-event", videoId), + eventUser: creds?.user, + eventPassword: creds?.password, }); return matchedEmbedRoute(query, queryRef); @@ -120,12 +124,8 @@ const embedEventFragment = graphql` endTime duration } - authorizedData(user: $eventUser, password: $eventPassword) { - thumbnail - tracks { uri flavor mimetype resolution isMaster } - captions { uri lang } - segments { uri startTime } - } + ... VideoPageAuthorizedData + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) } } `; @@ -138,12 +138,12 @@ type EmbedProps = { const Embed: React.FC = ({ query, queryRef }) => { const fragmentRef = usePreloadedQuery(query, queryRef); - const event = useFragment( + const protoEvent = useFragment( embedEventFragment, fragmentRef.event, ); + const [event, refetch] = useEventWithAuthData(protoEvent); const { t } = useTranslation(); - const [authenticatedData, setAuthenticatedData] = useState(null); if (!event) { return @@ -170,17 +170,9 @@ const Embed: React.FC = ({ query, queryRef }) => { ; } - const authorizedData = useAuthenticatedDataQuery( - event.id, - event.series?.id, - { authorizedData: event.authorizedData }, - ); - - return authorizedData - ? - : - ; - ; + return event.authorizedData + ? + : ; }; export const BlockEmbedRoute = makeRoute({ diff --git a/frontend/src/routes/Realm.tsx b/frontend/src/routes/Realm.tsx index 306447f2f..d0faed771 100644 --- a/frontend/src/routes/Realm.tsx +++ b/frontend/src/routes/Realm.tsx @@ -27,7 +27,6 @@ import { COLORS } from "../color"; import { useMenu } from "../layout/MenuState"; import { ManageNav } from "./manage"; import { BREAKPOINT as NAV_BREAKPOINT } from "../layout/Navigation"; -import { AuthorizedData, AuthenticatedDataContext } from "./Video"; // eslint-disable-next-line @typescript-eslint/quotes @@ -146,7 +145,6 @@ const RealmPage: React.FC = ({ realm }) => { const { t } = useTranslation(); const siteTitle = useTranslatedConfig(CONFIG.siteTitle); const breadcrumbs = realmBreadcrumbs(t, realm.ancestors); - const [authenticatedData, setAuthenticatedData] = useState(null); const title = realm.isMainRoot ? siteTitle : realm.name; useTitle(title, realm.isMainRoot); @@ -168,11 +166,9 @@ const RealmPage: React.FC = ({ realm }) => { {realm.isUserRealm && } )} - - {realm.blocks.length === 0 && realm.isMainRoot - ? - : } - + {realm.blocks.length === 0 && realm.isMainRoot + ? + : } ; }; diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index 70bd086ee..f71bf0ff1 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -51,7 +51,6 @@ import { COLORS } from "../color"; import { BREAKPOINT_MEDIUM } from "../GlobalStyle"; import { eventId, - getCredentials, isExperimentalFlagSet, keyOfId, secondsToTimeString, @@ -162,7 +161,6 @@ const query = graphql` startTime endTime created - hasPassword userIsAuthorized hostRealms { path ancestorNames } textMatches { @@ -523,7 +521,6 @@ const SearchEvent: React.FC = ({ hostRealms, textMatches, matches, - hasPassword, userIsAuthorized, }) => { // TODO: decide what to do in the case of more than two host realms. Direct @@ -532,11 +529,6 @@ const SearchEvent: React.FC = ({ ? DirectVideoRoute.url({ videoId: id }) : VideoRoute.url({ realmPath: hostRealms[0].path, videoID: id }); - // TODO: This check should be done in backend. - const showMatches = userIsAuthorized || ( - hasPassword && getCredentials("event", eventId(keyOfId(id))) - ); - return ( {{ image: @@ -554,7 +546,7 @@ const SearchEvent: React.FC = ({ startTime, endTime, }, - authorizedData: { + authorizedData: !userIsAuthorized ? null : { thumbnail, audioOnly, }, @@ -635,7 +627,7 @@ const SearchEvent: React.FC = ({ {...{ seriesId }} />} {/* Show timeline with matches if there are any */} - {textMatches.length > 0 && showMatches && ( + {textMatches.length > 0 && ( )} , diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index 5fb9e06d7..bc711256b 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -1,15 +1,14 @@ import React, { - createContext, - Dispatch, ReactElement, ReactNode, - SetStateAction, - useContext, useEffect, useRef, useState, } from "react"; -import { graphql, GraphQLTaggedNode, PreloadedQuery, useFragment } from "react-relay/hooks"; +import { + graphql, GraphQLTaggedNode, PreloadedQuery, RefetchFnDynamic, useFragment, + useRefetchableFragment, +} from "react-relay/hooks"; import { useTranslation } from "react-i18next"; import { fetchQuery, OperationType } from "relay-runtime"; import { @@ -19,7 +18,7 @@ import { QRCodeCanvas } from "qrcode.react"; import { match, unreachable, screenWidthAtMost, screenWidthAbove, useColorScheme, Floating, FloatingContainer, FloatingTrigger, WithTooltip, Card, Button, ProtoButton, - bug, + notNullish, } from "@opencast/appkit"; import { VideoObject, WithContext } from "schema-dts"; @@ -46,8 +45,8 @@ import { keyOfId, playlistId, getCredentials, - useAuthenticatedDataQuery, credentialsStorageKey, + Credentials, } from "../util"; import { BREAKPOINT_SMALL, BREAKPOINT_MEDIUM } from "../GlobalStyle"; import { LinkButton } from "../ui/LinkButton"; @@ -90,9 +89,11 @@ import { PlaylistBlockFromPlaylist } from "../ui/Blocks/Playlist"; import { AuthenticationFormState, FormData, AuthenticationForm } from "./Login"; import { VideoAuthorizedDataQuery, - VideoAuthorizedDataQuery$data, } from "./__generated__/VideoAuthorizedDataQuery.graphql"; import { AuthorizedBlockEvent } from "../ui/Blocks/Video"; +import { + VideoPageAuthorizedData$data, VideoPageAuthorizedData$key, +} from "./__generated__/VideoPageAuthorizedData.graphql"; // =========================================================================================== @@ -122,6 +123,7 @@ export const VideoRoute = makeRoute({ ... UserData event: eventById(id: $id) { ... VideoPageEventData + @arguments(eventUser: $eventUser, eventPassword: $eventPassword) ... on AuthorizedEvent { isReferencedByRealm(path: $realmPath) } @@ -134,11 +136,13 @@ export const VideoRoute = makeRoute({ } `; + const creds = getCredentials("event", id); const queryRef = loadQuery(query, { id, realmPath, listId, - ...getCredentials("event", id), + eventUser: creds?.user, + eventPassword: creds?.password, }); return { @@ -146,11 +150,7 @@ export const VideoRoute = makeRoute({ {... { query, queryRef }} nav={data => data.realm ?