Compare commits

...

7 Commits

Author SHA1 Message Date
2ce7479967 chore(main): release 0.11.0 (#648) 2025-09-15 22:42:53 -06:00
b42f7cfc60 feat: remove profanity filter that was there for conference (#652) 2025-09-15 18:19:19 -06:00
c546e69739 fix: zulip stream and topic selection in share dialog (#644)
* fix: zulip stream and topic selection in share dialog

Replace useListCollection with createListCollection to match the working
room edit implementation. This ensures collections update when data loads,
fixing the issue where streams and topics wouldn't appear until navigation.

* fix: wrap createListCollection in useMemo to prevent recreation on every render

Both streamCollection and topicCollection are now memoized to improve performance
and prevent unnecessary re-renders of Combobox components
2025-09-15 12:34:51 -06:00
Igor Monadical
3f1fe8c9bf chore: remove timeout-based auth session logic (#649)
* remove timeout-based auth session logic

* remove timeout-based auth session logic

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-15 14:19:10 -04:00
5f143fe364 fix: zulip and consent handler on the file pipeline (#645) 2025-09-15 10:49:20 -06:00
Igor Monadical
79f161436e chore: meeting user id removal and room id requirement (#635)
* chore: remove meeting user id and make meeting room id required

* meeting room_id optional

* orphaned meeting room ids DATA migration

* ci fix

* fix meeting_room_id_fkey downgrade

* fix migration rollback

* fix: put index back (meeting room id)

* fix: put index back (meeting room id)

* fix: put index back (meeting room id)

* remove noop migrations

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-12 13:07:58 -04:00
Igor Monadical
5cba5d310d chore: sentry and nextjs major bumps (#633)
* chore: remove nextjs-config

* build fix

* sentry update

* nextjs update

* feature flags doc

* update readme

* explicit nextjs env vars + remove feature-unrelated things and obsolete vars from config

* full config removal

* remove force-dynamic from pages

* compile fix

* restore claude-deleted tests

* no sentry backward compat

* better .env.example

* AUTHENTIK_REFRESH_TOKEN_URL not so required

* accommodate auth system to requiredLogin feature

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-12 12:41:44 -04:00
33 changed files with 2540 additions and 741 deletions

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## [0.11.0](https://github.com/Monadical-SAS/reflector/compare/v0.10.0...v0.11.0) (2025-09-16)
### Features
* remove profanity filter that was there for conference ([#652](https://github.com/Monadical-SAS/reflector/issues/652)) ([b42f7cf](https://github.com/Monadical-SAS/reflector/commit/b42f7cfc606783afcee792590efcc78b507468ab))
### Bug Fixes
* zulip and consent handler on the file pipeline ([#645](https://github.com/Monadical-SAS/reflector/issues/645)) ([5f143fe](https://github.com/Monadical-SAS/reflector/commit/5f143fe3640875dcb56c26694254a93189281d17))
* zulip stream and topic selection in share dialog ([#644](https://github.com/Monadical-SAS/reflector/issues/644)) ([c546e69](https://github.com/Monadical-SAS/reflector/commit/c546e69739e68bb74fbc877eb62609928e5b8de6))
## [0.10.0](https://github.com/Monadical-SAS/reflector/compare/v0.9.0...v0.10.0) (2025-09-11) ## [0.10.0](https://github.com/Monadical-SAS/reflector/compare/v0.9.0...v0.10.0) (2025-09-11)

View File

@@ -0,0 +1,36 @@
"""remove user_id from meeting table
Revision ID: 0ce521cda2ee
Revises: 6dec9fb5b46c
Create Date: 2025-09-10 12:40:55.688899
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "0ce521cda2ee"
down_revision: Union[str, None] = "6dec9fb5b46c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("meeting", schema=None) as batch_op:
batch_op.drop_column("user_id")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("meeting", schema=None) as batch_op:
batch_op.add_column(
sa.Column("user_id", sa.VARCHAR(), autoincrement=False, nullable=True)
)
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""clean up orphaned room_id references in meeting table
Revision ID: 2ae3db106d4e
Revises: def1b5867d4c
Create Date: 2025-09-11 10:35:15.759967
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "2ae3db106d4e"
down_revision: Union[str, None] = "def1b5867d4c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Set room_id to NULL for meetings that reference non-existent rooms
op.execute("""
UPDATE meeting
SET room_id = NULL
WHERE room_id IS NOT NULL
AND room_id NOT IN (SELECT id FROM room WHERE id IS NOT NULL)
""")
def downgrade() -> None:
# Cannot restore orphaned references - no operation needed
pass

View File

@@ -0,0 +1,38 @@
"""make meeting room_id required and add foreign key
Revision ID: 6dec9fb5b46c
Revises: 61882a919591
Create Date: 2025-09-10 10:47:06.006819
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6dec9fb5b46c"
down_revision: Union[str, None] = "61882a919591"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("meeting", schema=None) as batch_op:
batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=False)
batch_op.create_foreign_key(
None, "room", ["room_id"], ["id"], ondelete="CASCADE"
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("meeting", schema=None) as batch_op:
batch_op.drop_constraint("meeting_room_id_fkey", type_="foreignkey")
batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=True)
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""make meeting room_id nullable but keep foreign key
Revision ID: def1b5867d4c
Revises: 0ce521cda2ee
Create Date: 2025-09-11 09:42:18.697264
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "def1b5867d4c"
down_revision: Union[str, None] = "0ce521cda2ee"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("meeting", schema=None) as batch_op:
batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("meeting", schema=None) as batch_op:
batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=False)
# ### end Alembic commands ###

View File

@@ -27,7 +27,6 @@ dependencies = [
"prometheus-fastapi-instrumentator>=6.1.0", "prometheus-fastapi-instrumentator>=6.1.0",
"sentencepiece>=0.1.99", "sentencepiece>=0.1.99",
"protobuf>=4.24.3", "protobuf>=4.24.3",
"profanityfilter>=2.0.6",
"celery>=5.3.4", "celery>=5.3.4",
"redis>=5.0.1", "redis>=5.0.1",
"python-jose[cryptography]>=3.3.0", "python-jose[cryptography]>=3.3.0",

View File

@@ -17,8 +17,12 @@ meetings = sa.Table(
sa.Column("host_room_url", sa.String), sa.Column("host_room_url", sa.String),
sa.Column("start_date", sa.DateTime(timezone=True)), sa.Column("start_date", sa.DateTime(timezone=True)),
sa.Column("end_date", sa.DateTime(timezone=True)), sa.Column("end_date", sa.DateTime(timezone=True)),
sa.Column("user_id", sa.String), sa.Column(
sa.Column("room_id", sa.String), "room_id",
sa.String,
sa.ForeignKey("room.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("is_locked", sa.Boolean, nullable=False, server_default=sa.false()), sa.Column("is_locked", sa.Boolean, nullable=False, server_default=sa.false()),
sa.Column("room_mode", sa.String, nullable=False, server_default="normal"), sa.Column("room_mode", sa.String, nullable=False, server_default="normal"),
sa.Column("recording_type", sa.String, nullable=False, server_default="cloud"), sa.Column("recording_type", sa.String, nullable=False, server_default="cloud"),
@@ -80,8 +84,7 @@ class Meeting(BaseModel):
host_room_url: str host_room_url: str
start_date: datetime start_date: datetime
end_date: datetime end_date: datetime
user_id: str | None = None room_id: str | None
room_id: str | None = None
is_locked: bool = False is_locked: bool = False
room_mode: Literal["normal", "group"] = "normal" room_mode: Literal["normal", "group"] = "normal"
recording_type: Literal["none", "local", "cloud"] = "cloud" recording_type: Literal["none", "local", "cloud"] = "cloud"
@@ -100,12 +103,8 @@ class MeetingController:
host_room_url: str, host_room_url: str,
start_date: datetime, start_date: datetime,
end_date: datetime, end_date: datetime,
user_id: str,
room: Room, room: Room,
): ):
"""
Create a new meeting
"""
meeting = Meeting( meeting = Meeting(
id=id, id=id,
room_name=room_name, room_name=room_name,
@@ -113,7 +112,6 @@ class MeetingController:
host_room_url=host_room_url, host_room_url=host_room_url,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
user_id=user_id,
room_id=room.id, room_id=room.id,
is_locked=room.is_locked, is_locked=room.is_locked,
room_mode=room.room_mode, room_mode=room.room_mode,
@@ -125,19 +123,13 @@ class MeetingController:
return meeting return meeting
async def get_all_active(self) -> list[Meeting]: async def get_all_active(self) -> list[Meeting]:
"""
Get active meetings.
"""
query = meetings.select().where(meetings.c.is_active) query = meetings.select().where(meetings.c.is_active)
return await get_database().fetch_all(query) return await get_database().fetch_all(query)
async def get_by_room_name( async def get_by_room_name(
self, self,
room_name: str, room_name: str,
) -> Meeting: ) -> Meeting | None:
"""
Get a meeting by room name.
"""
query = meetings.select().where(meetings.c.room_name == room_name) query = meetings.select().where(meetings.c.room_name == room_name)
result = await get_database().fetch_one(query) result = await get_database().fetch_one(query)
if not result: if not result:
@@ -145,10 +137,7 @@ class MeetingController:
return Meeting(**result) return Meeting(**result)
async def get_active(self, room: Room, current_time: datetime) -> Meeting: async def get_active(self, room: Room, current_time: datetime) -> Meeting | None:
"""
Get latest active meeting for a room.
"""
end_date = getattr(meetings.c, "end_date") end_date = getattr(meetings.c, "end_date")
query = ( query = (
meetings.select() meetings.select()
@@ -168,9 +157,6 @@ class MeetingController:
return Meeting(**result) return Meeting(**result)
async def get_by_id(self, meeting_id: str, **kwargs) -> Meeting | None: async def get_by_id(self, meeting_id: str, **kwargs) -> Meeting | None:
"""
Get a meeting by id
"""
query = meetings.select().where(meetings.c.id == meeting_id) query = meetings.select().where(meetings.c.id == meeting_id)
result = await get_database().fetch_one(query) result = await get_database().fetch_one(query)
if not result: if not result:
@@ -201,7 +187,7 @@ class MeetingConsentController:
result = await get_database().fetch_one(query) result = await get_database().fetch_one(query)
if result is None: if result is None:
return None return None
return MeetingConsent(**result) if result else None return MeetingConsent(**result)
async def upsert(self, consent: MeetingConsent) -> MeetingConsent: async def upsert(self, consent: MeetingConsent) -> MeetingConsent:
"""Create new consent or update existing one for authenticated users""" """Create new consent or update existing one for authenticated users"""

View File

@@ -12,7 +12,7 @@ from pathlib import Path
import av import av
import structlog import structlog
from celery import shared_task from celery import chain, shared_task
from reflector.asynctask import asynctask from reflector.asynctask import asynctask
from reflector.db.rooms import rooms_controller from reflector.db.rooms import rooms_controller
@@ -26,6 +26,8 @@ from reflector.logger import logger
from reflector.pipelines.main_live_pipeline import ( from reflector.pipelines.main_live_pipeline import (
PipelineMainBase, PipelineMainBase,
broadcast_to_sockets, broadcast_to_sockets,
task_cleanup_consent,
task_pipeline_post_to_zulip,
) )
from reflector.processors import ( from reflector.processors import (
AudioFileWriterProcessor, AudioFileWriterProcessor,
@@ -379,6 +381,28 @@ class PipelineMainFile(PipelineMainBase):
await processor.flush() await processor.flush()
@shared_task
@asynctask
async def task_send_webhook_if_needed(*, transcript_id: str):
"""Send webhook if this is a room recording with webhook configured"""
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript:
return
if transcript.source_kind == SourceKind.ROOM and transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.webhook_url:
logger.info(
"Dispatching webhook",
transcript_id=transcript_id,
room_id=room.id,
webhook_url=room.webhook_url,
)
send_transcript_webhook.delay(
transcript_id, room.id, event_id=uuid.uuid4().hex
)
@shared_task @shared_task
@asynctask @asynctask
async def task_pipeline_file_process(*, transcript_id: str): async def task_pipeline_file_process(*, transcript_id: str):
@@ -406,16 +430,10 @@ async def task_pipeline_file_process(*, transcript_id: str):
await pipeline.set_status(transcript_id, "error") await pipeline.set_status(transcript_id, "error")
raise raise
# Trigger webhook if this is a room recording with webhook configured # Run post-processing chain: consent cleanup -> zulip -> webhook
if transcript.source_kind == SourceKind.ROOM and transcript.room_id: post_chain = chain(
room = await rooms_controller.get_by_id(transcript.room_id) task_cleanup_consent.si(transcript_id=transcript_id),
if room and room.webhook_url: task_pipeline_post_to_zulip.si(transcript_id=transcript_id),
logger.info( task_send_webhook_if_needed.si(transcript_id=transcript_id),
"Dispatching webhook task",
transcript_id=transcript_id,
room_id=room.id,
webhook_url=room.webhook_url,
)
send_transcript_webhook.delay(
transcript_id, room.id, event_id=uuid.uuid4().hex
) )
post_chain.delay()

View File

@@ -4,11 +4,8 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import Annotated, TypedDict from typing import Annotated, TypedDict
from profanityfilter import ProfanityFilter
from pydantic import BaseModel, Field, PrivateAttr from pydantic import BaseModel, Field, PrivateAttr
from reflector.redis_cache import redis_cache
class DiarizationSegment(TypedDict): class DiarizationSegment(TypedDict):
"""Type definition for diarization segment containing speaker information""" """Type definition for diarization segment containing speaker information"""
@@ -20,9 +17,6 @@ class DiarizationSegment(TypedDict):
PUNC_RE = re.compile(r"[.;:?!…]") PUNC_RE = re.compile(r"[.;:?!…]")
profanity_filter = ProfanityFilter()
profanity_filter.set_censor("*")
class AudioFile(BaseModel): class AudioFile(BaseModel):
name: str name: str
@@ -124,21 +118,11 @@ def words_to_segments(words: list[Word]) -> list[TranscriptSegment]:
class Transcript(BaseModel): class Transcript(BaseModel):
translation: str | None = None translation: str | None = None
words: list[Word] = None words: list[Word] = []
@property
def raw_text(self):
# Uncensored text
return "".join([word.text for word in self.words])
@redis_cache(prefix="profanity", duration=3600 * 24 * 7)
def _get_censored_text(self, text: str):
return profanity_filter.censor(text).strip()
@property @property
def text(self): def text(self):
# Censored text return "".join([word.text for word in self.words])
return self._get_censored_text(self.raw_text)
@property @property
def human_timestamp(self): def human_timestamp(self):
@@ -170,12 +154,6 @@ class Transcript(BaseModel):
word.start += offset word.start += offset
word.end += offset word.end += offset
def clone(self):
words = [
Word(text=word.text, start=word.start, end=word.end) for word in self.words
]
return Transcript(text=self.text, translation=self.translation, words=words)
def as_segments(self) -> list[TranscriptSegment]: def as_segments(self) -> list[TranscriptSegment]:
return words_to_segments(self.words) return words_to_segments(self.words)

View File

@@ -209,7 +209,6 @@ async def rooms_create_meeting(
host_room_url=whereby_meeting["hostRoomUrl"], host_room_url=whereby_meeting["hostRoomUrl"],
start_date=parse_datetime_with_timezone(whereby_meeting["startDate"]), start_date=parse_datetime_with_timezone(whereby_meeting["startDate"]),
end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]), end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]),
user_id=user_id,
room=room, room=room,
) )
except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError): except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError):

View File

@@ -105,7 +105,6 @@ async def test_cleanup_deletes_associated_meeting_and_recording():
host_room_url="https://example.com/meeting-host", host_room_url="https://example.com/meeting-host",
start_date=old_date, start_date=old_date,
end_date=old_date + timedelta(hours=1), end_date=old_date + timedelta(hours=1),
user_id=None,
room_id=None, room_id=None,
) )
) )
@@ -241,7 +240,6 @@ async def test_meeting_consent_cascade_delete():
host_room_url="https://example.com/cascade-test-host", host_room_url="https://example.com/cascade-test-host",
start_date=datetime.now(timezone.utc), start_date=datetime.now(timezone.utc),
end_date=datetime.now(timezone.utc) + timedelta(hours=1), end_date=datetime.now(timezone.utc) + timedelta(hours=1),
user_id="test-user",
room_id=None, room_id=None,
) )
) )

47
server/uv.lock generated
View File

@@ -1325,15 +1325,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" },
] ]
[[package]]
name = "inflection"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.1.0" version = "2.1.0"
@@ -2311,18 +2302,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c1/bb7e334135859c3a92ec399bc89293ea73f28e815e35b43929c8db6af030/primePy-1.3-py3-none-any.whl", hash = "sha256:5ed443718765be9bf7e2ff4c56cdff71b42140a15b39d054f9d99f0009e2317a", size = 4040, upload-time = "2018-05-29T17:18:17.53Z" }, { url = "https://files.pythonhosted.org/packages/74/c1/bb7e334135859c3a92ec399bc89293ea73f28e815e35b43929c8db6af030/primePy-1.3-py3-none-any.whl", hash = "sha256:5ed443718765be9bf7e2ff4c56cdff71b42140a15b39d054f9d99f0009e2317a", size = 4040, upload-time = "2018-05-29T17:18:17.53Z" },
] ]
[[package]]
name = "profanityfilter"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "inflection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/03/08740b5e0800f9eb9f675c149a497a3f3735e7b04e414bcce64136e7e487/profanityfilter-2.1.0.tar.gz", hash = "sha256:0ede04e92a9d7255faa52b53776518edc6586dda828aca677c74b5994dfdd9d8", size = 7910, upload-time = "2024-11-25T22:31:51.194Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/03/eb18f72dc6e6398e75e3762677f18ab3a773a384b18efd3ed9119844e892/profanityfilter-2.1.0-py2.py3-none-any.whl", hash = "sha256:e1bc07012760fd74512a335abb93a36877831ed26abab78bfe31bebb68f8c844", size = 7483, upload-time = "2024-11-25T22:31:50.129Z" },
]
[[package]] [[package]]
name = "prometheus-client" name = "prometheus-client"
version = "0.22.1" version = "0.22.1"
@@ -3131,7 +3110,6 @@ dependencies = [
{ name = "loguru" }, { name = "loguru" },
{ name = "nltk" }, { name = "nltk" },
{ name = "openai" }, { name = "openai" },
{ name = "profanityfilter" },
{ name = "prometheus-fastapi-instrumentator" }, { name = "prometheus-fastapi-instrumentator" },
{ name = "protobuf" }, { name = "protobuf" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
@@ -3208,7 +3186,6 @@ requires-dist = [
{ name = "loguru", specifier = ">=0.7.0" }, { name = "loguru", specifier = ">=0.7.0" },
{ name = "nltk", specifier = ">=3.8.1" }, { name = "nltk", specifier = ">=3.8.1" },
{ name = "openai", specifier = ">=1.59.7" }, { name = "openai", specifier = ">=1.59.7" },
{ name = "profanityfilter", specifier = ">=2.0.6" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=6.1.0" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=6.1.0" },
{ name = "protobuf", specifier = ">=4.24.3" }, { name = "protobuf", specifier = ">=4.24.3" },
{ name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "psycopg2-binary", specifier = ">=2.9.10" },
@@ -3954,8 +3931,8 @@ dependencies = [
{ name = "typing-extensions", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" }, { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" },
] ]
wheels = [ wheels = [
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:3d05017d19bc99741288e458888283a44b0ee881d53f05f72f8b1cfea8998122" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp311-none-macosx_11_0_arm64.whl" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:a47b7986bee3f61ad217d8a8ce24605809ab425baf349f97de758815edd2ef54" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl" },
] ]
[[package]] [[package]]
@@ -3980,16 +3957,16 @@ dependencies = [
{ name = "typing-extensions", marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" }, { name = "typing-extensions", marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" },
] ]
wheels = [ wheels = [
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:2bfc013dd6efdc8f8223a0241d3529af9f315dffefb53ffa3bf14d3f10127da6" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-linux_s390x.whl" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:680129efdeeec3db5da3f88ee5d28c1b1e103b774aef40f9d638e2cce8f8d8d8" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cb06175284673a581dd91fb1965662ae4ecaba6e5c357aa0ea7bb8b84b6b7eeb" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:7631ef49fbd38d382909525b83696dc12a55d68492ade4ace3883c62b9fc140f" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_amd64.whl" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:41e6fc5ec0914fcdce44ccf338b1d19a441b55cafdd741fd0bf1af3f9e4cfd14" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_arm64.whl" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:0e34e276722ab7dd0dffa9e12fe2135a9b34a0e300c456ed7ad6430229404eb5" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:610f600c102386e581327d5efc18c0d6edecb9820b4140d26163354a99cd800d" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cb9a8ba8137ab24e36bf1742cb79a1294bd374db570f09fc15a5e1318160db4e" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:2be20b2c05a0cce10430cc25f32b689259640d273232b2de357c35729132256d" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl" },
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:99fc421a5d234580e45957a7b02effbf3e1c884a5dd077afc85352c77bf41434" }, { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl" },
] ]
[[package]] [[package]]

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useState } from "react"; import { useState, use } from "react";
import TopicHeader from "./topicHeader"; import TopicHeader from "./topicHeader";
import TopicWords from "./topicWords"; import TopicWords from "./topicWords";
import TopicPlayer from "./topicPlayer"; import TopicPlayer from "./topicPlayer";
@@ -18,14 +18,16 @@ import { useRouter } from "next/navigation";
import { Box, Grid } from "@chakra-ui/react"; import { Box, Grid } from "@chakra-ui/react";
export type TranscriptCorrect = { export type TranscriptCorrect = {
params: { params: Promise<{
transcriptId: string; transcriptId: string;
}; }>;
}; };
export default function TranscriptCorrect({ export default function TranscriptCorrect(props: TranscriptCorrect) {
params: { transcriptId }, const params = use(props.params);
}: TranscriptCorrect) {
const { transcriptId } = params;
const updateTranscriptMutation = useTranscriptUpdate(); const updateTranscriptMutation = useTranscriptUpdate();
const transcript = useTranscriptGet(transcriptId); const transcript = useTranscriptGet(transcriptId);
const stateCurrentTopic = useState<GetTranscriptTopic>(); const stateCurrentTopic = useState<GetTranscriptTopic>();

View File

@@ -5,7 +5,7 @@ import useWaveform from "../useWaveform";
import useMp3 from "../useMp3"; import useMp3 from "../useMp3";
import { TopicList } from "./_components/TopicList"; import { TopicList } from "./_components/TopicList";
import { Topic } from "../webSocketTypes"; import { Topic } from "../webSocketTypes";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState, use } from "react";
import FinalSummary from "./finalSummary"; import FinalSummary from "./finalSummary";
import TranscriptTitle from "../transcriptTitle"; import TranscriptTitle from "../transcriptTitle";
import Player from "../player"; import Player from "../player";
@@ -15,13 +15,14 @@ import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript"; import { TranscriptStatus } from "../../../lib/transcript";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: Promise<{
transcriptId: string; transcriptId: string;
}; }>;
}; };
export default function TranscriptDetails(details: TranscriptDetails) { export default function TranscriptDetails(details: TranscriptDetails) {
const transcriptId = details.params.transcriptId; const params = use(details.params);
const transcriptId = params.transcriptId;
const router = useRouter(); const router = useRouter();
const statusToRedirect = [ const statusToRedirect = [
"idle", "idle",
@@ -43,7 +44,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useEffect(() => { useEffect(() => {
if (waiting) { if (waiting) {
const newUrl = "/transcripts/" + details.params.transcriptId + "/record"; const newUrl = "/transcripts/" + params.transcriptId + "/record";
// Shallow redirection does not work on NextJS 13 // Shallow redirection does not work on NextJS 13
// https://github.com/vercel/next.js/discussions/48110 // https://github.com/vercel/next.js/discussions/48110
// https://github.com/vercel/next.js/discussions/49540 // https://github.com/vercel/next.js/discussions/49540

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, use } from "react";
import Recorder from "../../recorder"; import Recorder from "../../recorder";
import { TopicList } from "../_components/TopicList"; import { TopicList } from "../_components/TopicList";
import { useWebSockets } from "../../useWebSockets"; import { useWebSockets } from "../../useWebSockets";
@@ -14,19 +14,20 @@ import { useTranscriptGet } from "../../../../lib/apiHooks";
import { TranscriptStatus } from "../../../../lib/transcript"; import { TranscriptStatus } from "../../../../lib/transcript";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: Promise<{
transcriptId: string; transcriptId: string;
}; }>;
}; };
const TranscriptRecord = (details: TranscriptDetails) => { const TranscriptRecord = (details: TranscriptDetails) => {
const transcript = useTranscriptGet(details.params.transcriptId); const params = use(details.params);
const transcript = useTranscriptGet(params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState<Topic | null>(null); const useActiveTopic = useState<Topic | null>(null);
const webSockets = useWebSockets(details.params.transcriptId); const webSockets = useWebSockets(params.transcriptId);
const mp3 = useMp3(details.params.transcriptId, true); const mp3 = useMp3(params.transcriptId, true);
const router = useRouter(); const router = useRouter();
@@ -47,7 +48,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
if (newStatus && (newStatus == "ended" || newStatus == "error")) { if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting"); console.log(newStatus, "redirecting");
const newUrl = "/transcripts/" + details.params.transcriptId; const newUrl = "/transcripts/" + params.transcriptId;
router.replace(newUrl); router.replace(newUrl);
} }
}, [webSockets.status?.value, transcript.data?.status]); }, [webSockets.status?.value, transcript.data?.status]);
@@ -75,7 +76,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
<WaveformLoading /> <WaveformLoading />
) : ( ) : (
// todo: only start recording animation when you get "recorded" status // todo: only start recording animation when you get "recorded" status
<Recorder transcriptId={details.params.transcriptId} status={status} /> <Recorder transcriptId={params.transcriptId} status={status} />
)} )}
<VStack <VStack
align={"left"} align={"left"}
@@ -98,7 +99,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
topics={webSockets.topics} topics={webSockets.topics}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
autoscroll={true} autoscroll={true}
transcriptId={details.params.transcriptId} transcriptId={params.transcriptId}
status={status} status={status}
currentTranscriptText={webSockets.accumulatedText} currentTranscriptText={webSockets.accumulatedText}
/> />

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, use } from "react";
import { useWebSockets } from "../../useWebSockets"; import { useWebSockets } from "../../useWebSockets";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -9,18 +9,19 @@ import FileUploadButton from "../../fileUploadButton";
import { useTranscriptGet } from "../../../../lib/apiHooks"; import { useTranscriptGet } from "../../../../lib/apiHooks";
type TranscriptUpload = { type TranscriptUpload = {
params: { params: Promise<{
transcriptId: string; transcriptId: string;
}; }>;
}; };
const TranscriptUpload = (details: TranscriptUpload) => { const TranscriptUpload = (details: TranscriptUpload) => {
const transcript = useTranscriptGet(details.params.transcriptId); const params = use(details.params);
const transcript = useTranscriptGet(params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
const webSockets = useWebSockets(details.params.transcriptId); const webSockets = useWebSockets(params.transcriptId);
const mp3 = useMp3(details.params.transcriptId, true); const mp3 = useMp3(params.transcriptId, true);
const router = useRouter(); const router = useRouter();
@@ -50,7 +51,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
if (newStatus && (newStatus == "ended" || newStatus == "error")) { if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting"); console.log(newStatus, "redirecting");
const newUrl = "/transcripts/" + details.params.transcriptId; const newUrl = "/transcripts/" + params.transcriptId;
router.replace(newUrl); router.replace(newUrl);
} }
}, [webSockets.status?.value, transcript.data?.status]); }, [webSockets.status?.value, transcript.data?.status]);
@@ -84,7 +85,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
Please select the file, supported formats: .mp3, m4a, .wav, Please select the file, supported formats: .mp3, m4a, .wav,
.mp4, .mov or .webm .mp4, .mov or .webm
</Text> </Text>
<FileUploadButton transcriptId={details.params.transcriptId} /> <FileUploadButton transcriptId={params.transcriptId} />
</> </>
)} )}
{status && status == "uploaded" && ( {status && status == "uploaded" && (

View File

@@ -14,8 +14,7 @@ import {
Checkbox, Checkbox,
Combobox, Combobox,
Spinner, Spinner,
useFilter, createListCollection,
useListCollection,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { TbBrandZulip } from "react-icons/tb"; import { TbBrandZulip } from "react-icons/tb";
import { import {
@@ -48,8 +47,6 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
const { data: topics = [] } = useZulipTopics(selectedStreamId); const { data: topics = [] } = useZulipTopics(selectedStreamId);
const postToZulipMutation = useTranscriptPostToZulip(); const postToZulipMutation = useTranscriptPostToZulip();
const { contains } = useFilter({ sensitivity: "base" });
const streamItems = useMemo(() => { const streamItems = useMemo(() => {
return streams.map((stream: Stream) => ({ return streams.map((stream: Stream) => ({
label: stream.name, label: stream.name,
@@ -64,17 +61,21 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
})); }));
}, [topics]); }, [topics]);
const { collection: streamItemsCollection, filter: streamItemsFilter } = const streamCollection = useMemo(
useListCollection({ () =>
initialItems: streamItems, createListCollection({
filter: contains, items: streamItems,
}); }),
[streamItems],
);
const { collection: topicItemsCollection, filter: topicItemsFilter } = const topicCollection = useMemo(
useListCollection({ () =>
initialItems: topicItems, createListCollection({
filter: contains, items: topicItems,
}); }),
[topicItems],
);
// Update selected stream ID when stream changes // Update selected stream ID when stream changes
useEffect(() => { useEffect(() => {
@@ -156,15 +157,12 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Flex align="center" gap={2}> <Flex align="center" gap={2}>
<Text>#</Text> <Text>#</Text>
<Combobox.Root <Combobox.Root
collection={streamItemsCollection} collection={streamCollection}
value={stream ? [stream] : []} value={stream ? [stream] : []}
onValueChange={(e) => { onValueChange={(e) => {
setTopic(undefined); setTopic(undefined);
setStream(e.value[0]); setStream(e.value[0]);
}} }}
onInputValueChange={(e) =>
streamItemsFilter(e.inputValue)
}
openOnClick={true} openOnClick={true}
positioning={{ positioning={{
strategy: "fixed", strategy: "fixed",
@@ -181,7 +179,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Combobox.Positioner> <Combobox.Positioner>
<Combobox.Content> <Combobox.Content>
<Combobox.Empty>No streams found</Combobox.Empty> <Combobox.Empty>No streams found</Combobox.Empty>
{streamItemsCollection.items.map((item) => ( {streamItems.map((item) => (
<Combobox.Item key={item.value} item={item}> <Combobox.Item key={item.value} item={item}>
{item.label} {item.label}
</Combobox.Item> </Combobox.Item>
@@ -197,12 +195,9 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Flex align="center" gap={2}> <Flex align="center" gap={2}>
<Text visibility="hidden">#</Text> <Text visibility="hidden">#</Text>
<Combobox.Root <Combobox.Root
collection={topicItemsCollection} collection={topicCollection}
value={topic ? [topic] : []} value={topic ? [topic] : []}
onValueChange={(e) => setTopic(e.value[0])} onValueChange={(e) => setTopic(e.value[0])}
onInputValueChange={(e) =>
topicItemsFilter(e.inputValue)
}
openOnClick openOnClick
selectionBehavior="replace" selectionBehavior="replace"
skipAnimationOnMount={true} skipAnimationOnMount={true}
@@ -222,7 +217,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
<Combobox.Positioner> <Combobox.Positioner>
<Combobox.Content> <Combobox.Content>
<Combobox.Empty>No topics found</Combobox.Empty> <Combobox.Empty>No topics found</Combobox.Empty>
{topicItemsCollection.items.map((item) => ( {topicItems.map((item) => (
<Combobox.Item key={item.value} item={item}> <Combobox.Item key={item.value} item={item}>
{item.label} {item.label}
<Combobox.ItemIndicator /> <Combobox.ItemIndicator />

View File

@@ -7,6 +7,7 @@ import {
useState, useState,
useContext, useContext,
RefObject, RefObject,
use,
} from "react"; } from "react";
import { import {
Box, Box,
@@ -30,9 +31,9 @@ import { FaBars } from "react-icons/fa6";
import { useAuth } from "../lib/AuthProvider"; import { useAuth } from "../lib/AuthProvider";
export type RoomDetails = { export type RoomDetails = {
params: { params: Promise<{
roomName: string; roomName: string;
}; }>;
}; };
// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially // stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
@@ -255,9 +256,10 @@ const useWhereby = () => {
}; };
export default function Room(details: RoomDetails) { export default function Room(details: RoomDetails) {
const params = use(details.params);
const wherebyLoaded = useWhereby(); const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null); const wherebyRef = useRef<HTMLElement>(null);
const roomName = details.params.roomName; const roomName = params.roomName;
const meeting = useRoomMeeting(roomName); const meeting = useRoomMeeting(roomName);
const router = useRouter(); const router = useRouter();
const status = useAuth().status; const status = useAuth().status;

View File

@@ -1,6 +1,6 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import { authOptions } from "../../../lib/authBackend"; import { authOptions } from "../../../lib/authBackend";
const handler = NextAuth(authOptions); const handler = NextAuth(authOptions());
export { handler as GET, handler as POST }; export { handler as GET, handler as POST };

View File

@@ -6,6 +6,7 @@ import ErrorMessage from "./(errors)/errorMessage";
import { RecordingConsentProvider } from "./recordingConsentContext"; import { RecordingConsentProvider } from "./recordingConsentContext";
import { ErrorBoundary } from "@sentry/nextjs"; import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers"; import { Providers } from "./providers";
import { assertExistsAndNonEmptyString } from "./lib/utils";
const poppins = Poppins({ const poppins = Poppins({
subsets: ["latin"], subsets: ["latin"],
@@ -20,8 +21,13 @@ export const viewport: Viewport = {
maximumScale: 1, maximumScale: 1,
}; };
const NEXT_PUBLIC_SITE_URL = assertExistsAndNonEmptyString(
process.env.NEXT_PUBLIC_SITE_URL,
"NEXT_PUBLIC_SITE_URL required",
);
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!), metadataBase: new URL(NEXT_PUBLIC_SITE_URL),
title: { title: {
template: "%s Reflector", template: "%s Reflector",
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical", default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",

View File

@@ -9,6 +9,7 @@ import { Session } from "next-auth";
import { SessionAutoRefresh } from "./SessionAutoRefresh"; import { SessionAutoRefresh } from "./SessionAutoRefresh";
import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth"; import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
import { assertExists } from "./utils"; import { assertExists } from "./utils";
import { featureEnabled } from "./features";
type AuthContextType = ( type AuthContextType = (
| { status: "loading" } | { status: "loading" }
@@ -27,33 +28,50 @@ type AuthContextType = (
}; };
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
const isAuthEnabled = featureEnabled("requireLogin");
const noopAuthContext: AuthContextType = {
status: "unauthenticated",
update: async () => {
return null;
},
signIn: async () => {
throw new Error("signIn not supposed to be called");
},
signOut: async () => {
throw new Error("signOut not supposed to be called");
},
};
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, status, update } = useNextAuthSession(); const { data: session, status, update } = useNextAuthSession();
const customSession = session ? assertCustomSession(session) : null;
const contextValue: AuthContextType = { const contextValue: AuthContextType = isAuthEnabled
? {
...(() => { ...(() => {
switch (status) { switch (status) {
case "loading": { case "loading": {
const sessionIsHere = !!customSession; const sessionIsHere = !!session;
switch (sessionIsHere) { // actually exists sometimes; nextAuth types are something else
switch (sessionIsHere as boolean) {
case false: { case false: {
return { status }; return { status };
} }
case true: { case true: {
return { return {
status: "refreshing" as const, status: "refreshing" as const,
user: assertExists(customSession).user, user: assertCustomSession(
assertExists(session as unknown as Session),
).user,
}; };
} }
default: { default: {
const _: never = sessionIsHere;
throw new Error("unreachable"); throw new Error("unreachable");
} }
} }
} }
case "authenticated": { case "authenticated": {
const customSession = assertCustomSession(session);
if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) { if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
// token had expired but next auth still returns "authenticated" so show user unauthenticated state // token had expired but next auth still returns "authenticated" so show user unauthenticated state
return { return {
@@ -85,7 +103,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
update, update,
signIn, signIn,
signOut, signOut,
}; }
: noopAuthContext;
// not useEffect, we need it ASAP // not useEffect, we need it ASAP
// apparently, still no guarantee this code runs before mutations are fired // apparently, still no guarantee this code runs before mutations are fired

View File

@@ -3,11 +3,16 @@
import createClient from "openapi-fetch"; import createClient from "openapi-fetch";
import type { paths } from "../reflector-api"; import type { paths } from "../reflector-api";
import createFetchClient from "openapi-react-query"; import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString } from "./utils"; import { assertExistsAndNonEmptyString, parseNonEmptyString } from "./utils";
import { isBuildPhase } from "./next"; import { isBuildPhase } from "./next";
import { getSession } from "next-auth/react";
import { assertExtendedToken } from "./types";
export const API_URL = !isBuildPhase export const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL) ? assertExistsAndNonEmptyString(
process.env.NEXT_PUBLIC_API_URL,
"NEXT_PUBLIC_API_URL required",
)
: "http://localhost"; : "http://localhost";
// TODO decide strict validation or not // TODO decide strict validation or not
@@ -18,29 +23,29 @@ export const client = createClient<paths>({
baseUrl: API_URL, baseUrl: API_URL,
}); });
const waitForAuthTokenDefinitivePresenceOrAbscence = async () => { // will assert presence/absence of login initially
let tries = 0; const initialSessionPromise = getSession();
let time = 0;
const STEP = 100; const waitForAuthTokenDefinitivePresenceOrAbsence = async () => {
while (currentAuthToken === undefined) { const initialSession = await initialSessionPromise;
await new Promise((resolve) => setTimeout(resolve, STEP)); if (currentAuthToken === undefined) {
time += STEP; currentAuthToken =
tries++; initialSession === null
// most likely first try is more than enough, if it's more there's already something weird happens ? null
if (tries > 10) { : assertExtendedToken(initialSession).accessToken;
// even when there's no auth assumed at all, we probably should explicitly call configureApiAuth(null)
throw new Error(
`Could not get auth token definitive presence/absence in ${time}ms. not calling configureApiAuth?`,
);
}
} }
// otherwise already overwritten by external forces
return currentAuthToken;
}; };
client.use({ client.use({
async onRequest({ request }) { async onRequest({ request }) {
await waitForAuthTokenDefinitivePresenceOrAbscence(); const token = await waitForAuthTokenDefinitivePresenceOrAbsence();
if (currentAuthToken) { if (token !== null) {
request.headers.set("Authorization", `Bearer ${currentAuthToken}`); request.headers.set(
"Authorization",
`Bearer ${parseNonEmptyString(token)}`,
);
} }
// XXX Only set Content-Type if not already set (FormData will set its own boundary) // XXX Only set Content-Type if not already set (FormData will set its own boundary)
// This is a work around for uploading file, we're passing a formdata // This is a work around for uploading file, we're passing a formdata

12
www/app/lib/array.ts Normal file
View File

@@ -0,0 +1,12 @@
export type NonEmptyArray<T> = [T, ...T[]];
export const isNonEmptyArray = <T>(arr: T[]): arr is NonEmptyArray<T> =>
arr.length > 0;
export const assertNonEmptyArray = <T>(
arr: T[],
err?: string,
): NonEmptyArray<T> => {
if (isNonEmptyArray(arr)) {
return arr;
}
throw new Error(err ?? "Expected non-empty array");
};

View File

@@ -19,20 +19,41 @@ import {
} from "./redisTokenCache"; } from "./redisTokenCache";
import { tokenCacheRedis, redlock } from "./redisClient"; import { tokenCacheRedis, redlock } from "./redisClient";
import { isBuildPhase } from "./next"; import { isBuildPhase } from "./next";
import { sequenceThrows } from "./errorUtils";
import { featureEnabled } from "./features";
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE; const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
const CLIENT_ID = !isBuildPhase const getAuthentikClientId = () =>
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_ID) assertExistsAndNonEmptyString(
: "noop"; process.env.AUTHENTIK_CLIENT_ID,
const CLIENT_SECRET = !isBuildPhase "AUTHENTIK_CLIENT_ID required",
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_SECRET) );
: "noop"; const getAuthentikClientSecret = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_SECRET,
"AUTHENTIK_CLIENT_SECRET required",
);
const getAuthentikRefreshTokenUrl = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_REFRESH_TOKEN_URL,
"AUTHENTIK_REFRESH_TOKEN_URL required",
);
export const authOptions: AuthOptions = { export const authOptions = (): AuthOptions =>
featureEnabled("requireLogin")
? {
providers: [ providers: [
AuthentikProvider({ AuthentikProvider({
clientId: CLIENT_ID, ...(() => {
clientSecret: CLIENT_SECRET, const [clientId, clientSecret] = sequenceThrows(
getAuthentikClientId,
getAuthentikClientSecret,
);
return {
clientId,
clientSecret,
};
})(),
issuer: process.env.AUTHENTIK_ISSUER, issuer: process.env.AUTHENTIK_ISSUER,
authorization: { authorization: {
params: { params: {
@@ -114,6 +135,9 @@ export const authOptions: AuthOptions = {
} satisfies CustomSession; } satisfies CustomSession;
}, },
}, },
}
: {
providers: [],
}; };
async function lockedRefreshAccessToken( async function lockedRefreshAccessToken(
@@ -174,16 +198,19 @@ async function lockedRefreshAccessToken(
} }
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> { async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
const [url, clientId, clientSecret] = sequenceThrows(
getAuthentikRefreshTokenUrl,
getAuthentikClientId,
getAuthentikClientSecret,
);
try { try {
const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
const options = { const options = {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
body: new URLSearchParams({ body: new URLSearchParams({
client_id: process.env.AUTHENTIK_CLIENT_ID as string, client_id: clientId,
client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string, client_secret: clientSecret,
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: token.refreshToken as string, refresh_token: token.refreshToken as string,
}).toString(), }).toString(),

View File

@@ -1,4 +1,6 @@
function shouldShowError(error: Error | null | undefined) { import { isNonEmptyArray, NonEmptyArray } from "./array";
export function shouldShowError(error: Error | null | undefined) {
if ( if (
error?.name == "ResponseError" && error?.name == "ResponseError" &&
(error["response"].status == 404 || error["response"].status == 403) (error["response"].status == 404 || error["response"].status == 403)
@@ -8,4 +10,40 @@ function shouldShowError(error: Error | null | undefined) {
return true; return true;
} }
export { shouldShowError }; const defaultMergeErrors = (ex: NonEmptyArray<unknown>): unknown => {
try {
return new Error(
ex
.map((e) =>
e ? (e.toString ? e.toString() : JSON.stringify(e)) : `${e}`,
)
.join("\n"),
);
} catch (e) {
console.error("Error merging errors:", e);
return ex[0];
}
};
type ReturnTypes<T extends readonly (() => any)[]> = {
[K in keyof T]: T[K] extends () => infer R ? R : never;
};
// sequence semantic for "throws"
// calls functions passed and collects its thrown values
export function sequenceThrows<Fns extends readonly (() => any)[]>(
...fs: Fns
): ReturnTypes<Fns> {
const results: unknown[] = [];
const errors: unknown[] = [];
for (const f of fs) {
try {
results.push(f());
} catch (e) {
errors.push(e);
}
}
if (errors.length) throw defaultMergeErrors(errors as NonEmptyArray<unknown>);
return results as ReturnTypes<Fns>;
}

View File

@@ -11,11 +11,11 @@ export type FeatureName = (typeof FEATURES)[number];
export type Features = Readonly<Record<FeatureName, boolean>>; export type Features = Readonly<Record<FeatureName, boolean>>;
export const DEFAULT_FEATURES: Features = { export const DEFAULT_FEATURES: Features = {
requireLogin: false, requireLogin: true,
privacy: true, privacy: true,
browse: false, browse: true,
sendToZulip: false, sendToZulip: true,
rooms: false, rooms: true,
} as const; } as const;
function parseBooleanEnv( function parseBooleanEnv(

View File

@@ -21,7 +21,7 @@ export interface CustomSession extends Session {
// assumption that JWT is JWTWithAccessToken - we set it in jwt callback of auth; typing isn't strong around there // assumption that JWT is JWTWithAccessToken - we set it in jwt callback of auth; typing isn't strong around there
// but the assumption is crucial to auth working // but the assumption is crucial to auth working
export const assertExtendedToken = <T>( export const assertExtendedToken = <T>(
t: T, t: Exclude<T, null | undefined>,
): T & { ): T & {
accessTokenExpires: number; accessTokenExpires: number;
accessToken: string; accessToken: string;
@@ -45,7 +45,7 @@ export const assertExtendedToken = <T>(
}; };
export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>( export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>(
t: T, t: Exclude<T, null | undefined>,
): T & { ): T & {
accessTokenExpires: number; accessTokenExpires: number;
accessToken: string; accessToken: string;
@@ -55,7 +55,7 @@ export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>(
} => { } => {
const extendedToken = assertExtendedToken(t); const extendedToken = assertExtendedToken(t);
if (typeof (extendedToken.user as any)?.id === "string") { if (typeof (extendedToken.user as any)?.id === "string") {
return t as T & { return t as Exclude<T, null | undefined> & {
accessTokenExpires: number; accessTokenExpires: number;
accessToken: string; accessToken: string;
user: U & { user: U & {
@@ -67,7 +67,9 @@ export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>(
}; };
// best attempt to check the session is valid // best attempt to check the session is valid
export const assertCustomSession = <S extends Session>(s: S): CustomSession => { export const assertCustomSession = <T extends Session>(
s: Exclude<T, null | undefined>,
): CustomSession => {
const r = assertExtendedTokenAndUserId(s); const r = assertExtendedTokenAndUserId(s);
// no other checks for now // no other checks for now
return r as CustomSession; return r as CustomSession;

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, use } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -30,9 +30,9 @@ const FORM_FIELDS = {
}; };
export type WebinarDetails = { export type WebinarDetails = {
params: { params: Promise<{
title: string; title: string;
}; }>;
}; };
export type Webinar = { export type Webinar = {
@@ -63,7 +63,8 @@ const WEBINARS: Webinar[] = [
]; ];
export default function WebinarPage(details: WebinarDetails) { export default function WebinarPage(details: WebinarDetails) {
const title = details.params.title; const params = use(details.params);
const title = params.title;
const webinar = WEBINARS.find((webinar) => webinar.title === title); const webinar = WEBINARS.find((webinar) => webinar.title === title);
if (!webinar) { if (!webinar) {
return notFound(); return notFound();

View File

@@ -23,3 +23,5 @@ if (SENTRY_DSN) {
replaysSessionSampleRate: 0.0, replaysSessionSampleRate: 0.0,
}); });
} }
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

9
www/instrumentation.ts Normal file
View File

@@ -0,0 +1,9 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}

View File

@@ -1,7 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone", output: "standalone",
experimental: { esmExternals: "loose" },
env: { env: {
IS_CI: process.env.IS_CI, IS_CI: process.env.IS_CI,
}, },

View File

@@ -17,19 +17,19 @@
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@sentry/nextjs": "^7.77.0", "@sentry/nextjs": "^10.11.0",
"@tanstack/react-query": "^5.85.9", "@tanstack/react-query": "^5.85.9",
"@types/ioredis": "^5.0.0", "@types/ioredis": "^5.0.0",
"@whereby.com/browser-sdk": "^3.3.4", "@whereby.com/browser-sdk": "^3.3.4",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"axios": "^1.8.2", "axios": "^1.8.2",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-config-next": "^14.2.31", "eslint-config-next": "^15.5.3",
"fontawesome": "^5.6.3", "fontawesome": "^5.6.3",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
"jest-worker": "^29.6.2", "jest-worker": "^29.6.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^14.2.30", "next": "^15.5.3",
"next-auth": "^4.24.7", "next-auth": "^4.24.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.4.3", "nuqs": "^2.4.3",

2379
www/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff