Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente beb0a1394d accept bearer token 2026-08-11 20:52:54 +02:00
13 changed files with 243 additions and 374 deletions
@@ -22,11 +22,6 @@ interface UseFollowComponentProps {
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
topOffset?: number; topOffset?: number;
/**
* Resolved at scroll time, for offsets which can only be measured from the DOM (eg. a sticky header).
* Takes precedence over topOffset. Keep the reference stable, it is a dependency of the follow effect.
*/
getTopOffset?: () => number;
setScrollFlag?: (newValue: boolean) => void; setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null followTrigger?: MaybeString; // this would be an entry id or null
} }
@@ -36,7 +31,6 @@ export default function useFollowComponent({
scrollRef, scrollRef,
doFollow, doFollow,
topOffset = 100, topOffset = 100,
getTopOffset,
setScrollFlag, setScrollFlag,
followTrigger, followTrigger,
}: UseFollowComponentProps) { }: UseFollowComponentProps) {
@@ -50,23 +44,21 @@ export default function useFollowComponent({
setScrollFlag?.(true); setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded // Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
// resolve the offset after layout, so that measured values are up to date scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
const offset = getTopOffset?.() ?? topOffset;
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, offset);
setScrollFlag?.(false); setScrollFlag?.(false);
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset, getTopOffset]); }, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback( const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset?: number) => { (componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef && containerRef) { if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null // @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset ?? getTopOffset?.() ?? topOffset); scrollToComponent(componentRef!, containerRef!, offset);
} }
}, },
[followRef, scrollRef, topOffset, getTopOffset], [followRef, scrollRef, topOffset],
); );
return scrollToRefComponent; return scrollToRefComponent;
@@ -18,14 +18,6 @@
padding-bottom: 95vh; padding-bottom: 95vh;
} }
/* a group and its events, bounds the sticky group header to its own section.
repeats the gap of the surrounding list, which no longer reaches the rows of a section */
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt { .editPrompt {
position: fixed; position: fixed;
z-index: $zindex-dialog; z-index: $zindex-dialog;
+6 -18
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill'; import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -54,21 +54,11 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
// the group of the running event is pinned to the top of the list, the running event needs to clear it
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// account for the list gap, so that the rows do not touch
return header ? header.offsetHeight + 2 : 0;
}, []);
const getTopOffset = useCallback(() => selectedOffset + getStickyOffset(), [getStickyOffset]);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
getTopOffset, topOffset: selectedOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -96,8 +86,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const selectedRect = selectedRef.current.getBoundingClientRect(); const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect(); const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) { if (selectedRect && scrollerRect) {
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck const distanceFromTop = selectedRect.top - scrollerRect.top;
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset; const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold); setLockAutoScroll(hasScrolledOutOfThreshold);
} }
@@ -197,14 +186,13 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
} }
return ( return (
<div className={style.groupSection} key={entry.id}> <Fragment key={entry.id}>
<OperatorGroup <OperatorGroup
ref={isCurrentParent ? stickyHeaderRef : undefined} key={entry.id}
title={entry.title} title={entry.title}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} count={entry.entries.length}
duration={entry.duration} duration={entry.duration}
isLive={isCurrentParent}
/> />
{entry.entries.map((nestedEntryId) => { {entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId]; const nestedEntry = rundown.entries[nestedEntryId];
@@ -251,7 +239,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/> />
); );
})} })}
</div> </Fragment>
); );
} }
return null; return null;
@@ -3,32 +3,17 @@
min-height: 2.5rem; min-height: 2.5rem;
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500); border-left: 0.35rem solid var(--group-colour, $gray-500);
/* the background comes inline from getAccessibleColour, which resolves to an opaque colour background-color: $gray-1350;
and is what keeps the rows from showing through while the header is pinned */ background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
/* stays visible as a header for the events of the group, bound by the group section */
position: sticky;
/* covers the list padding, otherwise the rows scroll through it */
top: -0.25rem;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
letter-spacing: 0; letter-spacing: 0;
} }
/* a group holding the running event is only outlined, the green fill belongs to the event row itself.
the card is already filled with the group colour, so we use the lighter active indicator over a dark
frame, which stays legible against any of them, green groups included */
.live {
box-shadow:
inset 0 0 0 2px $ui-black,
inset 0 0 0 4px $active-indicator;
}
.title { .title {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@@ -1,6 +1,6 @@
import { CSSProperties, Ref, memo } from 'react'; import { CSSProperties, memo } from 'react';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
import style from './OperatorGroup.module.scss'; import style from './OperatorGroup.module.scss';
@@ -10,22 +10,15 @@ interface OperatorGroup {
colour: string; colour: string;
count: number; count: number;
duration: number; duration: number;
/** whether the group contains the currently running event */
isLive?: boolean;
ref?: Ref<HTMLDivElement>;
} }
export default memo(OperatorGroup); export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration, isLive, ref }: OperatorGroup) { function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
const groupColour = colour || '#929292'; const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour); const groupColours = getAccessibleColour(groupColour);
return ( return (
<div <div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
className={cx([style.group, isLive && style.live])}
style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}
ref={ref}
>
<span className={style.title}>{title}</span> <span className={style.title}>{title}</span>
<span className={style.meta}> <span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span> <span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
+2 -34
View File
@@ -103,22 +103,6 @@ $item-height: 3.5rem;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom))); padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
} }
/* a subscribed group and its subscribed events, bounds the sticky header to its own section.
flex rather than block so that the row margins do not collapse, keeping the spacing between
rows of a section equal to the spacing between sections */
.sub-section {
display: flex;
flex-direction: column;
}
/* scoped to the section, the select view shows the same cards in a flat list */
.sub-section .sub--group {
position: sticky;
/* sticky insets constrain the margin box, negate the row margin so the header sits flush */
top: -2px;
z-index: 1;
}
/* ====================== LIST-ITEM ======================*/ /* ====================== LIST-ITEM ======================*/
.sub { .sub {
margin: 2px; margin: 2px;
@@ -212,15 +196,9 @@ $item-height: 3.5rem;
.sub--group { .sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325); box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
/* the card colour is translucent, an opaque base keeps the rows from showing through the sticky header. background:
split into longhands since the shorthand only takes a colour in its last layer */
background-color: var(--background-color-override, $viewer-background-color);
background-image:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%), linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
linear-gradient( var(--card-background-color-override, $viewer-card-bg-color);
var(--card-background-color-override, $viewer-card-bg-color),
var(--card-background-color-override, $viewer-card-bg-color)
);
.sub__binder { .sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color)); background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
@@ -252,16 +230,6 @@ $item-height: 3.5rem;
} }
} }
/* a group holding the running event is only outlined, the green fill belongs to the event row itself */
.sub--group.sub--live {
box-shadow: inset 0 0 0 2px $active-green;
}
/* loaded but not yet running, subtle enough to not compete with the live ring */
.sub--group.sub--armed {
box-shadow: inset 0 0 0 2px $gray-1000;
}
.sub__title { .sub__title {
grid-area: title; grid-area: title;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5'; import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
@@ -22,7 +22,6 @@ import {
CountdownTarget, CountdownTarget,
extendEventData, extendEventData,
getIsLive, getIsLive,
groupSubscriptionTargets,
isOutsideRange, isOutsideRange,
preferredFormat12, preferredFormat12,
preferredFormat24, preferredFormat24,
@@ -49,25 +48,11 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
// The group of the running event is pinned to the top of the list, the running event needs to clear it.
// This is measured rather than estimated: the card scales with the viewport (the font sizes are clamped
// against vw), it grows while live since the running event's title is added, and long titles wrap.
// Measured between 91px and 171px, so a constant would leave the running event behind the header.
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// account for the row margin, so that the rows do not touch
return header ? header.offsetHeight + 2 : 0;
}, []);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
getTopOffset: getStickyOffset, topOffset: 0,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -94,8 +79,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const selectedRect = selectedRef.current.getBoundingClientRect(); const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect(); const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) { if (selectedRect && scrollerRect) {
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck const distanceFromTop = selectedRect.top - scrollerRect.top;
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50; const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold); setLockAutoScroll(hasScrolledOutOfThreshold);
} }
@@ -114,63 +98,41 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return ( return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{sections.map((section) => { {subscribedEvents.map((event) => {
const rows = section.group ? [section.group, ...section.events] : section.events; // while a group is live, surface the running event's title as the secondary line
// the running event anchors the scroll, the group header stays pinned above it const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null; const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return ( return (
<div key={section.group?.id ?? rows[0].id} className='sub-section'> <div
{rows.map((event) => { key={event.id}
// while a group is live, surface the running event's title as the secondary line ref={isLive ? selectedRef : undefined}
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined; className={cx([
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource); 'sub',
const isGroupedEvent = !event.isGroup && Boolean(event.parent); isLive && 'sub--live',
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id; isArmed && 'sub--armed',
// a subscribed group is live when any of its children is the selected/running event event.isGroup && 'sub--group',
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false; isGroupedEvent && 'sub--in-group',
const isArmed = !isLive && activeEntryId === selectedEventId; ])}
// only ever hand the ref to a single row, sharing it would null it out on the next commit data-testid={event.cue}
const isAnchor = isLive && (anchorId === null || event.id === anchorId); >
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined; <div
const countdownEvent = extendEventData( className='sub__binder'
event, style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
currentDay, />
actualStart, <ScheduleTime event={countdownEvent} showExpected={showExpected} />
plannedStart, <SubscriptionStatus event={countdownEvent} />
offset, <div className={cx(['sub__title', !displayTitle && 'subdued'])}>
mode, {event.isGroup && <span className='sub__eyebrow'>Group</span>}
reportData, {displayTitle}
); </div>
const displayTitle = getPropertyValue(event, mainSource ?? 'title'); {secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
return (
<div
key={event.id}
ref={rowRef}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
</div> </div>
); );
})} })}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils'; import { resolveSubscriptionTarget } from './countdown.utils';
/** /**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes. * Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,89 +126,3 @@ describe('resolveSubscriptionTarget()', () => {
expect(result?.liveEntry).toBeNull(); expect(result?.liveEntry).toBeNull();
}); });
}); });
describe('groupSubscriptionTargets()', () => {
/**
* Resolves a group the same way the view does, so that the tests exercise the real target shape
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
*/
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
const resolved = resolveSubscriptionTarget(group, flat);
if (resolved === null) {
throw new Error('test setup: group has no playable children');
}
return resolved;
}
it('returns no sections for an empty subscription list', () => {
expect(groupSubscriptionTargets([])).toEqual([]);
});
it('gives each ungrouped event its own section', () => {
const e1 = makeEvent({ id: 'e1' });
const e2 = makeEvent({ id: 'e2' });
expect(groupSubscriptionTargets([e1, e2])).toEqual([
{ group: null, events: [e1] },
{ group: null, events: [e2] },
]);
});
it('absorbs the children of a subscribed group into its section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1, c2]);
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
});
it('keeps a subscribed group with no subscribed children as an empty section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
});
it('does not absorb an event which belongs to a different group', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const flat = [group1, c1, group2, c2];
const resolved1 = resolveGroup(group1, flat);
const resolved2 = resolveGroup(group2, flat);
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: resolved2, events: [c2] },
]);
});
it('does not absorb an event whose parent group is not subscribed', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
// only the first group is subscribed, so the second group's child stands alone
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: null, events: [c2] },
]);
});
it('closes a section when an ungrouped event follows a group', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const e1 = makeEvent({ id: 'e1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
{ group: resolved, events: [c1] },
{ group: null, events: [e1] },
]);
});
});
@@ -252,44 +252,6 @@ export function resolveSubscriptionTarget(
}; };
} }
/**
* A subscribed group along with the subscribed events which belong to it.
* Events without a subscribed parent group form their own section with no group.
*/
export type CountdownSection = {
group: CountdownTarget | null;
events: CountdownTarget[];
};
/**
* Folds the flat, rundown ordered subscription targets into sections.
* A group opens a section which absorbs the following targets that declare it as parent,
* which allows the group to be rendered as a sticky header for its own events.
*/
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
const sections: CountdownSection[] = [];
let openSection: CountdownSection | null = null;
for (const target of targets) {
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
if (target.isGroup) {
openSection = { group: target, events: [] };
sections.push(openSection);
continue;
}
if (openSection?.group && target.parent === openSection.group.id) {
openSection.events.push(target);
continue;
}
openSection = null;
sections.push({ group: null, events: [target] });
}
return sections;
}
export function extendEventData( export function extendEventData(
event: CountdownTarget, event: CountdownTarget,
currentDay: number, currentDay: number,
-21
View File
@@ -1,21 +0,0 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
/**
* Wraps the app authenticate middleware with support for the Authorization header.
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
* rather than cookies or query params; any other request falls through to the
* app middleware, keeping the behaviour of the shared middleware untouched.
*/
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
if (hasPassword) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
return next();
}
}
return fallback(req, res, next);
};
}
+1 -2
View File
@@ -13,7 +13,6 @@ import { socket } from './adapters/WebsocketAdapter.js';
// Import Routers // Import Routers
import { appRouter } from './api-data/index.js'; import { appRouter } from './api-data/index.js';
import { integrationRouter } from './api-integration/integration.router.js'; import { integrationRouter } from './api-integration/integration.router.js';
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
import { mcpRouter } from './api-mcp/mcp.router.js'; import { mcpRouter } from './api-mcp/mcp.router.js';
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js'; import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
// Services // Services
@@ -102,7 +101,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
app.use(`${prefix}/login`, loginRouter); // router for login flow app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
// serve static external files // serve static external files
app.use( app.use(
@@ -1,6 +1,35 @@
import { describe, expect, it } from 'vitest'; import type { IncomingMessage } from 'node:http';
import { isPublicAssetRequest } from '../authenticate.js'; import type { NextFunction, Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../api-data/session/session.service.js', () => ({
hasPassword: true,
hashedPassword: 'valid-token',
}));
import {
authenticateSocket,
isPublicAssetRequest,
makeAuthenticateMiddleware,
} from '../authenticate.js';
function makeResponse() {
return {
redirect: vi.fn(),
send: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response;
}
function makeHeadersWithFailingAuthorization(cookie?: string) {
return {
cookie,
get authorization(): never {
throw new Error('Authorization header should not be read');
},
};
}
describe('isPublicAssetRequest()', () => { describe('isPublicAssetRequest()', () => {
it('allows root public assets without a prefix', () => { it('allows root public assets without a prefix', () => {
@@ -18,3 +47,102 @@ describe('isPublicAssetRequest()', () => {
expect(isPublicAssetRequest('/backstage', '')).toBe(false); expect(isPublicAssetRequest('/backstage', '')).toBe(false);
}); });
}); });
describe('bearer authentication', () => {
const next = vi.fn() as NextFunction;
beforeEach(() => {
next.mockClear();
});
it('prioritises cookie authentication for API requests', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
query: {},
} as unknown as Request;
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for redirecting routes', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
originalUrl: '/external/image.png',
query: {},
} as unknown as Request;
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for WebSocket handshakes', () => {
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('authenticates API requests with a bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
});
it('authenticates redirecting routes with a bearer token', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
originalUrl: '/stage/external/image.png',
query: {},
} as unknown as Request;
const res = makeResponse();
authenticateAndRedirect(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.redirect).not.toHaveBeenCalled();
});
it('authenticates WebSocket handshakes with a bearer token', () => {
const req = {
headers: { authorization: 'Bearer valid-token' },
} as IncomingMessage;
authenticateSocket({} as never, req, next);
expect(next).toHaveBeenCalledOnce();
expect(next).toHaveBeenCalledWith();
});
it('rejects an invalid bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer invalid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
});
+55 -48
View File
@@ -77,17 +77,16 @@ export function makeAuthenticateMiddleware(prefix: string) {
const loginRedirectBase = `${prefix}/login?redirect=`; const loginRedirectBase = `${prefix}/login?redirect=`;
function authenticate(req: Request, res: Response, next: NextFunction) { function authenticate(req: Request, res: Response, next: NextFunction) {
if (req.query.token) { if (getTokenFromCookies(req.cookies) === hashedPassword) {
if (req.query.token === hashedPassword) { return next();
return next();
}
} }
if (req.cookies?.token) { if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
const tokenFromCookie = getTokenFromCookie(req.cookies.token); return next();
if (tokenFromCookie === hashedPassword) { }
return next();
} if (getTokenFromParams(req.query) === hashedPassword) {
return next();
} }
res.status(401).send('Unauthorized'); res.status(401).send('Unauthorized');
@@ -105,17 +104,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
return next(); return next();
} }
// we expect the token to be in the cookies if (getTokenFromCookies(req.cookies) === hashedPassword) {
if (req.cookies?.token) { return next();
const tokenFromCookie = getTokenFromCookie(req.cookies.token); }
if (tokenFromCookie === hashedPassword) {
return next(); if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
} return next();
} }
// we use query params for generating authenticated URLs and for clients like the companion module // we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests // if the user gives is a token in the query params, we set the cookie to be used in further requests
if (req.query.token === hashedPassword) { if (getTokenFromParams(req.query) === hashedPassword) {
if (hashedPassword !== undefined) { if (hashedPassword !== undefined) {
setSessionCookie(res, hashedPassword, prefix); setSessionCookie(res, hashedPassword, prefix);
} }
@@ -136,33 +135,16 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
return next(); return next();
} }
// check if the token is in the cookie if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
const cookieString = req.headers.cookie;
if (typeof cookieString === 'string') {
const cookies = parseCookie(cookieString);
if (cookies.token) {
const token = getTokenFromCookie(cookies.token);
if (token === hashedPassword) {
return next();
}
}
}
// check if token is in the params - simple string check first
const urlString = req.url || '';
if (urlString.includes(`token=${hashedPassword}`)) {
return next(); return next();
} }
// fallback to full URL parsing for other formats if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
try { return next();
const url = new URL(urlString, `http://${req.headers.host}`); }
const token = url.searchParams.get('token');
if (token === hashedPassword) { if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
return next(); return next();
}
} catch (_) {
// ignore URL parsing errors
} }
return next(new Error('Unauthorized')); return next(new Error('Unauthorized'));
@@ -181,19 +163,18 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
}); });
} }
/** function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
* When calling this function we already know a cookie called 'token' exists const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
* And want to extract its value if (typeof cookieContents !== 'string') {
*/ return undefined;
function getTokenFromCookie(cookieContents: string): string | undefined { }
// Fast path: check if the hashed password is directly in the cookie string
// This avoids JSON parsing for the common case // Fast path: avoid JSON parsing when the expected token can be found directly
const cookieTokenString = '"token":"' + hashedPassword + '}"'; const cookieTokenString = '"token":"' + hashedPassword + '}"';
if (cookieTokenString && cookieContents.includes(cookieTokenString)) { if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
return hashedPassword; return hashedPassword;
} }
// Fallback to JSON parsing for other cases or validation
try { try {
const cookie = JSON.parse(cookieContents); const cookie = JSON.parse(cookieContents);
if (cookie && typeof cookie.token === 'string') { if (cookie && typeof cookie.token === 'string') {
@@ -203,3 +184,29 @@ function getTokenFromCookie(cookieContents: string): string | undefined {
// no error handling to do here // no error handling to do here
} }
} }
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
if (authorization?.startsWith('Bearer ')) {
return authorization.slice(7);
}
}
function getTokenFromParams(
params: string | Record<string, unknown> | undefined,
host?: string,
): string | undefined {
if (typeof params !== 'string') {
return typeof params?.token === 'string' ? params.token : undefined;
}
// Fast path for WebSocket URLs
if (params.includes(`token=${hashedPassword}`)) {
return hashedPassword;
}
try {
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
} catch (_) {
return undefined;
}
}