diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index e143d061d..3a6f1930d 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -7,7 +7,7 @@ import { } from "react-relay"; import { unreachable } from "@opencast/appkit"; -import { eventId, getCredentials, isSynced, keyOfId } from "../util"; +import { eventId, getCredentials, isSynced, keyOfId, useAuthenticatedDataQuery } from "../util"; import { GlobalErrorBoundary } from "../util/err"; import { loadQuery } from "../relay"; import { makeRoute, MatchedRoute } from "../rauta"; @@ -39,7 +39,7 @@ export const EmbedVideoRoute = makeRoute({ const queryRef = loadQuery(query, { id, - ...getCredentials("event" + id), + ...getCredentials("event", id), }); @@ -69,7 +69,7 @@ export const EmbedOpencastVideoRoute = makeRoute({ const videoId = decodeURIComponent(matches[1]); const queryRef = loadQuery(query, { id: videoId, - ...getCredentials(videoId), + ...getCredentials("oc-event", videoId), }); return matchedEmbedRoute(query, queryRef); @@ -113,7 +113,7 @@ const embedEventFragment = graphql` description canWrite hasPassword - series { title opencastId } + series { title id opencastId } syncedData { updated startTime @@ -169,11 +169,14 @@ const Embed: React.FC = ({ query, queryRef }) => { ; } - return event.authorizedData - ? + const authorizedData = useAuthenticatedDataQuery( + event.id, + event.series?.id, + { authorizedData: event.authorizedData }, + ); + + return authorizedData + ? : ; }; diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index a8d32e753..70bd086ee 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -533,7 +533,9 @@ const SearchEvent: React.FC = ({ : VideoRoute.url({ realmPath: hostRealms[0].path, videoID: id }); // TODO: This check should be done in backend. - const showMatches = userIsAuthorized || (hasPassword && getCredentials(keyOfId(id))); + const showMatches = userIsAuthorized || ( + hasPassword && getCredentials("event", eventId(keyOfId(id))) + ); return ( {{ @@ -544,6 +546,9 @@ const SearchEvent: React.FC = ({ title, isLive, created, + series: seriesId ? { + id: seriesId, + } : null, syncedData: { duration, startTime, diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index ea822920e..bb9cc950c 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -35,6 +35,8 @@ import { keyOfId, playlistId, getCredentials, + useAuthenticatedDataQuery, + credentialsStorageKey, } from "../util"; import { BREAKPOINT_SMALL, BREAKPOINT_MEDIUM } from "../GlobalStyle"; import { LinkButton } from "../ui/LinkButton"; @@ -125,7 +127,7 @@ export const VideoRoute = makeRoute({ id, realmPath, listId, - ...getCredentials(videoId), + ...getCredentials("event", id), }); return { @@ -194,7 +196,7 @@ export const OpencastVideoRoute = makeRoute({ id, realmPath, listId, - ...getCredentials(id), + ...getCredentials("oc-event", id), }); return { @@ -261,11 +263,11 @@ export const DirectVideoRoute = makeRoute({ playlist: playlistById(id: $listId) { ...PlaylistBlockPlaylistData } } `; - const videoId = decodeURIComponent(params[1]); + const id = eventId(decodeURIComponent(params[1])); const queryRef = loadQuery(query, { - id: eventId(videoId), + id, listId: makeListId(url.searchParams.get("list")), - ...getCredentials(videoId), + ...getCredentials("event", id), }); return matchedDirectRoute(query, queryRef); @@ -302,7 +304,7 @@ export const DirectOpencastVideoRoute = makeRoute({ const queryRef = loadQuery(query, { id, listId: makeListId(url.searchParams.get("list")), - ...getCredentials(id), + ...getCredentials("oc-event", id), }); return matchedDirectRoute(query, queryRef); @@ -470,11 +472,27 @@ const VideoPage: React.FC = ({ eventRef, realmRef, playlistRef, basePath if (event.__typename !== "AuthorizedEvent") { return unreachable(); } - if (!isSynced(event)) { return ; } + // If the event is password protected this will check if there are credentials for this event's + // series are stored, and if so, skip the authentication. + // Ideally this would happen at the top level in the `makeRoute` call, but at that point the + // series id isn't known. To prevent unnecessary queries, the hook is also passed the authorized + // data of this event. If that is neither null nor undefined, nothing is fetched. + // + // This extra check is particularly useful in this specific component, where we might run into a + // situation where an event has been previously authenticated and its credentials are stored + // with both its own ID (with which it is possible to already fetch the authenticated data in + // the initial video page query) and its series ID. So when the authenticated data is already + // present, it shouldn't be fetched a second time. + const authorizedData = useAuthenticatedDataQuery( + event.id, + event.series?.id, + { authorizedData: event.authorizedData }, + ); + const breadcrumbs = realm.isMainRoot ? [] : realmBreadcrumbs(t, realm.ancestors.concat(realm)); const { hasStarted, hasEnded } = getEventTimeInfo(event); const isCurrentlyLive = hasStarted === true && hasEnded === false; @@ -503,12 +521,9 @@ const VideoPage: React.FC = ({ eventRef, realmRef, playlistRef, basePath - {event.authorizedData + {authorizedData ? @@ -615,9 +630,14 @@ const ProtectedPlayer: React.FC = ({ event, embedded }) => // The check will return a result for either ID regardless of its kind, as long as // one of them is stored. const storage = isRealUser(user) ? window.localStorage : window.sessionStorage; - storage.setItem(CREDENTIALS_STORAGE_KEY + keyOfId(event.id), credentials); - storage.setItem(CREDENTIALS_STORAGE_KEY + event.opencastId, credentials); + storage.setItem(credentialsStorageKey("event", event.id), credentials); + storage.setItem(credentialsStorageKey("oc-event", event.opencastId), credentials); + // We also store the series id of the event. If other events of that series use + // the same credentials, they will also be unlocked. + if (event.series?.id) { + storage.setItem(credentialsStorageKey("series", event.series.id), credentials); + } }, error: (error: Error) => { setAuthError(error.message); diff --git a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx index 4fed75472..35dadac04 100644 --- a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx +++ b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx @@ -43,7 +43,7 @@ export const EditVideoBlock: React.FC = ({ block: blockRef ... on AuthorizedEvent { id title - series { title } + series { id title } created isLive creators @@ -82,7 +82,12 @@ export const EditVideoBlock: React.FC = ({ block: blockRef const { formState: { errors } } = form; const currentEvent = event?.__typename === "AuthorizedEvent" - ? { ...event, ...event.syncedData, seriesTitle: event.series?.title } + ? { + ...event, + ...event.syncedData, + seriesId: event.series?.id, + seriesTitle: event.series?.title, + } : undefined; return data}> @@ -138,6 +143,7 @@ const EventSelector: React.FC = ({ onChange, onBlur, default items { id title + seriesId seriesTitle creators thumbnail @@ -168,7 +174,10 @@ const EventSelector: React.FC = ({ onChange, onBlur, default id: item.id.replace(/^es/, "ev"), syncedData: item, authorizedData: item, - series: item.seriesTitle == null ? null : { title: item.seriesTitle }, + series: (item.seriesTitle == null || item.seriesId == null) ? null : { + id: item.seriesId, + title: item.seriesTitle, + }, }))); }, start: () => {}, diff --git a/frontend/src/routes/manage/Video/Shared.tsx b/frontend/src/routes/manage/Video/Shared.tsx index 009a68efd..70bd5c0fd 100644 --- a/frontend/src/routes/manage/Video/Shared.tsx +++ b/frontend/src/routes/manage/Video/Shared.tsx @@ -98,6 +98,7 @@ const query = graphql` tracks { flavor resolution mimetype uri } } series { + id title opencastId ...SeriesBlockSeriesData diff --git a/frontend/src/routes/manage/Video/index.tsx b/frontend/src/routes/manage/Video/index.tsx index b0e78e1c1..167d68cae 100644 --- a/frontend/src/routes/manage/Video/index.tsx +++ b/frontend/src/routes/manage/Video/index.tsx @@ -77,6 +77,7 @@ const query = graphql` } items { id title created description isLive tobiraDeletionTimestamp + series { id } syncedData { duration updated startTime endTime } diff --git a/frontend/src/ui/Blocks/Video.tsx b/frontend/src/ui/Blocks/Video.tsx index 47d6d28e7..55b3d7332 100644 --- a/frontend/src/ui/Blocks/Video.tsx +++ b/frontend/src/ui/Blocks/Video.tsx @@ -38,7 +38,7 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { description canWrite hasPassword - series { title opencastId } + series { title id opencastId } syncedData { duration updated @@ -69,9 +69,11 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { return unreachable(); } - const authenticatedData = useAuthenticatedDataQuery(keyOfId(event.id)); - const authorizedData = event.authorizedData - ?? authenticatedData.authorizedEvent?.authorizedData; + const authorizedData = useAuthenticatedDataQuery( + event.id, + event.series?.id, + { authorizedData: event.authorizedData }, + ); return
@@ -79,10 +81,7 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { {authorizedData && isSynced(event) ? : diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index d0e45c513..467ee6970 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -12,7 +12,7 @@ import { import { useColorScheme } from "@opencast/appkit"; import { COLORS } from "../color"; -import { keyOfId, useAuthenticatedDataQuery } from "../util"; +import { useAuthenticatedDataQuery } from "../util"; type ThumbnailProps = JSX.IntrinsicElements["div"] & { @@ -22,6 +22,9 @@ type ThumbnailProps = JSX.IntrinsicElements["div"] & { title: string; isLive: boolean; created: string; + series?: { + id: string; + } | null; syncedData?: { duration: number; startTime?: string | null; @@ -53,9 +56,9 @@ export const Thumbnail: React.FC = ({ }) => { const { t } = useTranslation(); const isDark = useColorScheme().scheme === "dark"; - const authenticatedData = useAuthenticatedDataQuery(keyOfId(event.id)); + const authenticatedData = useAuthenticatedDataQuery(event.id, event.series?.id); const authorizedThumbnail = event.authorizedData?.thumbnail - ?? authenticatedData.authorizedEvent?.authorizedData?.thumbnail; + ?? authenticatedData?.thumbnail; const isUpcoming = isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive); const audioOnly = event.authorizedData ? ( diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts index e44337827..633161a87 100644 --- a/frontend/src/util/index.ts +++ b/frontend/src/util/index.ts @@ -7,7 +7,7 @@ import { useLazyLoadQuery } from "react-relay"; import CONFIG, { TranslatedString } from "../config"; import { TimeUnit } from "../ui/Input"; -import { authorizedDataQuery, CREDENTIALS_STORAGE_KEY } from "../routes/Video"; +import { AuthorizedData, authorizedDataQuery, CREDENTIALS_STORAGE_KEY } from "../routes/Video"; import { VideoAuthorizedDataQuery$data, } from "../routes/__generated__/VideoAuthorizedDataQuery.graphql"; @@ -234,35 +234,51 @@ interface AuthenticatedData extends OperationType { /** * Returns `authorizedData` of password protected events by fetching it from the API, * if the correct credentials were supplied. - * This will not send a request when there are no credentials. + * This will not send a request when there are no credentials and instead return the + * event's authorized data if that was already present and passed to this hook. */ -export const useAuthenticatedDataQuery = (id: string) => { - const credentials = getCredentials(keyOfId(id)); - return useLazyLoadQuery( +export const useAuthenticatedDataQuery = ( + eventID: string, + seriesID?: string, + authData?: AuthorizedData | null, +) => { + // If `id` is coming from a search event, the prefix might be `es` or `ss`, but + // the query and storage need it to be a regular event/series id (i.e. with prefix `ev`/`sr`). + const credentials = getCredentials("event", eventId(keyOfId(eventID))) ?? ( + seriesID && getCredentials("series", seriesId(keyOfId(seriesID))) + ); + const authenticatedData = useLazyLoadQuery( authorizedDataQuery, - // If `id` is coming from a search event, the prefix might be `es`, but - // the query needs it to be an event id (i.e. with prefix `ev`). - { eventId: eventId(keyOfId(id)), ...credentials }, - // This will only query the data for events with credentials. - // Unnecessary queries are prevented. - { fetchPolicy: !credentials ? "store-only" : "store-or-network" } + { eventId: eventId(keyOfId(eventID)), ...credentials }, + // This will only query the data for events with stored credentials and/or yet unknown + // authorized data. This should help to prevent unnecessary queries. + { fetchPolicy: credentials && !authData ? "store-or-network" : "store-only" } ); + + return authData?.authorizedData ?? authenticatedData?.authorizedEvent?.authorizedData; }; /** * Returns stored credentials of events. * + * Three kinds of IDs are stored when a user authenticates for an event: * We need to store both Tobira ID and Opencast ID, since the video route can be accessed * via both kinds. For this, both IDs are queried from the DB. * The check for already stored credentials however happens in the same query, * so we only have access to the single event ID from the url. * In order to have a successful check when visiting a video page with either Tobira ID * or Opencast ID in the url, this check accepts both ID kinds. + * Lastly, we also store the series ID of an event. If other events of that series use + * the same credentials, authenticating for the current event will also unlock + * these other events. */ -export const getCredentials = (eventId: string): Credentials => { - const credentials = window.localStorage.getItem(CREDENTIALS_STORAGE_KEY + eventId) - ?? window.sessionStorage.getItem(CREDENTIALS_STORAGE_KEY + eventId); +type IdKind = "event" | "oc-event" | "series"; +export const getCredentials = (kind: IdKind, id: string): Credentials => { + const credentials = window.localStorage.getItem(credentialsStorageKey(kind, id)) + ?? window.sessionStorage.getItem(credentialsStorageKey(kind, id)); return credentials && JSON.parse(credentials); }; +export const credentialsStorageKey = (kind: IdKind, id: string) => + CREDENTIALS_STORAGE_KEY + kind + "-" + id;