mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 20:59:05 +00:00
Merge branch 'main' into mathieu/calendar-integration-rebased
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.10.0](https://github.com/Monadical-SAS/reflector/compare/v0.9.0...v0.10.0) (2025-09-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* replace nextjs-config with environment variables ([#632](https://github.com/Monadical-SAS/reflector/issues/632)) ([369ecdf](https://github.com/Monadical-SAS/reflector/commit/369ecdff13f3862d926a9c0b87df52c9d94c4dde))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* anonymous users transcript permissions ([#621](https://github.com/Monadical-SAS/reflector/issues/621)) ([f81fe99](https://github.com/Monadical-SAS/reflector/commit/f81fe9948a9237b3e0001b2d8ca84f54d76878f9))
|
||||||
|
* auth post ([#624](https://github.com/Monadical-SAS/reflector/issues/624)) ([cde99ca](https://github.com/Monadical-SAS/reflector/commit/cde99ca2716f84ba26798f289047732f0448742e))
|
||||||
|
* auth post ([#626](https://github.com/Monadical-SAS/reflector/issues/626)) ([3b85ff3](https://github.com/Monadical-SAS/reflector/commit/3b85ff3bdf4fb053b103070646811bc990c0e70a))
|
||||||
|
* auth post ([#627](https://github.com/Monadical-SAS/reflector/issues/627)) ([962038e](https://github.com/Monadical-SAS/reflector/commit/962038ee3f2a555dc3c03856be0e4409456e0996))
|
||||||
|
* missing follow_redirects=True on modal endpoint ([#630](https://github.com/Monadical-SAS/reflector/issues/630)) ([fc363bd](https://github.com/Monadical-SAS/reflector/commit/fc363bd49b17b075e64f9186e5e0185abc325ea7))
|
||||||
|
* sync backend and frontend token refresh logic ([#614](https://github.com/Monadical-SAS/reflector/issues/614)) ([5a5b323](https://github.com/Monadical-SAS/reflector/commit/5a5b3233820df9536da75e87ce6184a983d4713a))
|
||||||
|
|
||||||
## [0.9.0](https://github.com/Monadical-SAS/reflector/compare/v0.8.2...v0.9.0) (2025-09-06)
|
## [0.9.0](https://github.com/Monadical-SAS/reflector/compare/v0.8.2...v0.9.0) (2025-09-06)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ pnpm install
|
|||||||
|
|
||||||
# Copy configuration templates
|
# Copy configuration templates
|
||||||
cp .env_template .env
|
cp .env_template .env
|
||||||
cp config-template.ts config.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Development:**
|
**Development:**
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -99,11 +99,10 @@ Start with `cd www`.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
cp .env_template .env
|
cp .env.example .env
|
||||||
cp config-template.ts config.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, fill in the environment variables in `.env` and the configuration in `config.ts` as needed. If you are unsure on how to proceed, ask in Zulip.
|
Then, fill in the environment variables in `.env` as needed. If you are unsure on how to proceed, ask in Zulip.
|
||||||
|
|
||||||
**Run in development mode**
|
**Run in development mode**
|
||||||
|
|
||||||
@@ -168,3 +167,34 @@ You can manually process an audio file by calling the process tool:
|
|||||||
```bash
|
```bash
|
||||||
uv run python -m reflector.tools.process path/to/audio.wav
|
uv run python -m reflector.tools.process path/to/audio.wav
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Feature Flags
|
||||||
|
|
||||||
|
Reflector uses environment variable-based feature flags to control application functionality. These flags allow you to enable or disable features without code changes.
|
||||||
|
|
||||||
|
### Available Feature Flags
|
||||||
|
|
||||||
|
| Feature Flag | Environment Variable |
|
||||||
|
|-------------|---------------------|
|
||||||
|
| `requireLogin` | `NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN` |
|
||||||
|
| `privacy` | `NEXT_PUBLIC_FEATURE_PRIVACY` |
|
||||||
|
| `browse` | `NEXT_PUBLIC_FEATURE_BROWSE` |
|
||||||
|
| `sendToZulip` | `NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP` |
|
||||||
|
| `rooms` | `NEXT_PUBLIC_FEATURE_ROOMS` |
|
||||||
|
|
||||||
|
### Setting Feature Flags
|
||||||
|
|
||||||
|
Feature flags are controlled via environment variables using the pattern `NEXT_PUBLIC_FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Enable user authentication requirement
|
||||||
|
NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
|
||||||
|
|
||||||
|
# Disable browse functionality
|
||||||
|
NEXT_PUBLIC_FEATURE_BROWSE=false
|
||||||
|
|
||||||
|
# Enable Zulip integration
|
||||||
|
NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
|
||||||
|
```
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -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
|
||||||
@@ -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 ###
|
||||||
@@ -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 ###
|
||||||
@@ -18,8 +18,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"),
|
||||||
@@ -86,8 +90,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"
|
||||||
@@ -109,14 +112,10 @@ 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,
|
||||||
calendar_event_id: str | None = None,
|
calendar_event_id: str | None = None,
|
||||||
calendar_metadata: dict[str, Any] | None = None,
|
calendar_metadata: dict[str, Any] | None = None,
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
Create a new meeting
|
|
||||||
"""
|
|
||||||
meeting = Meeting(
|
meeting = Meeting(
|
||||||
id=id,
|
id=id,
|
||||||
room_name=room_name,
|
room_name=room_name,
|
||||||
@@ -124,7 +123,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,
|
||||||
@@ -138,9 +136,6 @@ 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)
|
||||||
|
|
||||||
@@ -150,8 +145,9 @@ class MeetingController:
|
|||||||
) -> Meeting | None:
|
) -> Meeting | None:
|
||||||
"""
|
"""
|
||||||
Get a meeting by room name.
|
Get a meeting by room name.
|
||||||
|
For backward compatibility, returns the most recent meeting.
|
||||||
"""
|
"""
|
||||||
query = meetings.select().where(meetings.c.room_name == room_name)
|
query = meetings.select().where(meetings.c.room_name == room_name).order_by(end_date.desc())
|
||||||
result = await get_database().fetch_one(query)
|
result = await get_database().fetch_one(query)
|
||||||
if not result:
|
if not result:
|
||||||
return None
|
return None
|
||||||
@@ -219,9 +215,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:
|
||||||
@@ -261,7 +254,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:
|
||||||
if consent.user_id:
|
if consent.user_id:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from pydantic.types import PositiveInt
|
from pydantic.types import PositiveInt
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from reflector.utils.string import NonEmptyString
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
@@ -120,7 +122,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Whereby integration
|
# Whereby integration
|
||||||
WHEREBY_API_URL: str = "https://api.whereby.dev/v1"
|
WHEREBY_API_URL: str = "https://api.whereby.dev/v1"
|
||||||
WHEREBY_API_KEY: str | None = None
|
WHEREBY_API_KEY: NonEmptyString | None = None
|
||||||
WHEREBY_WEBHOOK_SECRET: str | None = None
|
WHEREBY_WEBHOOK_SECRET: str | None = None
|
||||||
AWS_WHEREBY_ACCESS_KEY_ID: str | None = None
|
AWS_WHEREBY_ACCESS_KEY_ID: str | None = None
|
||||||
AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None
|
AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ NonEmptyString = Annotated[
|
|||||||
non_empty_string_adapter = TypeAdapter(NonEmptyString)
|
non_empty_string_adapter = TypeAdapter(NonEmptyString)
|
||||||
|
|
||||||
|
|
||||||
def parse_non_empty_string(s: str) -> NonEmptyString:
|
def parse_non_empty_string(s: str, error: str | None = None) -> NonEmptyString:
|
||||||
|
try:
|
||||||
return non_empty_string_adapter.validate_python(s)
|
return non_empty_string_adapter.validate_python(s)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"{e}: {error}" if error else e) from e
|
||||||
|
|
||||||
|
|
||||||
def try_parse_non_empty_string(s: str) -> NonEmptyString | None:
|
def try_parse_non_empty_string(s: str) -> NonEmptyString | None:
|
||||||
|
|||||||
@@ -261,7 +261,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):
|
||||||
|
|||||||
@@ -1,18 +1,60 @@
|
|||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from reflector.db.rooms import Room
|
from reflector.db.rooms import Room
|
||||||
from reflector.settings import settings
|
from reflector.settings import settings
|
||||||
|
from reflector.utils.string import parse_non_empty_string
|
||||||
|
|
||||||
HEADERS = {
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_headers():
|
||||||
|
api_key = parse_non_empty_string(
|
||||||
|
settings.WHEREBY_API_KEY, "WHEREBY_API_KEY value is required."
|
||||||
|
)
|
||||||
|
return {
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
"Authorization": f"Bearer {settings.WHEREBY_API_KEY}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
TIMEOUT = 10 # seconds
|
TIMEOUT = 10 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _get_whereby_s3_auth():
|
||||||
|
errors = []
|
||||||
|
try:
|
||||||
|
bucket_name = parse_non_empty_string(
|
||||||
|
settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
|
||||||
|
"RECORDING_STORAGE_AWS_BUCKET_NAME value is required.",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(e)
|
||||||
|
try:
|
||||||
|
key_id = parse_non_empty_string(
|
||||||
|
settings.AWS_WHEREBY_ACCESS_KEY_ID,
|
||||||
|
"AWS_WHEREBY_ACCESS_KEY_ID value is required.",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(e)
|
||||||
|
try:
|
||||||
|
key_secret = parse_non_empty_string(
|
||||||
|
settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
|
||||||
|
"AWS_WHEREBY_ACCESS_KEY_SECRET value is required.",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(e)
|
||||||
|
if len(errors) > 0:
|
||||||
|
raise Exception(
|
||||||
|
f"Failed to get Whereby auth settings: {', '.join(str(e) for e in errors)}"
|
||||||
|
)
|
||||||
|
return bucket_name, key_id, key_secret
|
||||||
|
|
||||||
|
|
||||||
async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
|
async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
|
||||||
|
s3_bucket_name, s3_key_id, s3_key_secret = _get_whereby_s3_auth()
|
||||||
data = {
|
data = {
|
||||||
"isLocked": room.is_locked,
|
"isLocked": room.is_locked,
|
||||||
"roomNamePrefix": room_name_prefix,
|
"roomNamePrefix": room_name_prefix,
|
||||||
@@ -23,23 +65,26 @@ async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
|
|||||||
"type": room.recording_type,
|
"type": room.recording_type,
|
||||||
"destination": {
|
"destination": {
|
||||||
"provider": "s3",
|
"provider": "s3",
|
||||||
"bucket": settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
|
"bucket": s3_bucket_name,
|
||||||
"accessKeyId": settings.AWS_WHEREBY_ACCESS_KEY_ID,
|
"accessKeyId": s3_key_id,
|
||||||
"accessKeySecret": settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
|
"accessKeySecret": s3_key_secret,
|
||||||
"fileFormat": "mp4",
|
"fileFormat": "mp4",
|
||||||
},
|
},
|
||||||
"startTrigger": room.recording_trigger,
|
"startTrigger": room.recording_trigger,
|
||||||
},
|
},
|
||||||
"fields": ["hostRoomUrl"],
|
"fields": ["hostRoomUrl"],
|
||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{settings.WHEREBY_API_URL}/meetings",
|
f"{settings.WHEREBY_API_URL}/meetings",
|
||||||
headers=HEADERS,
|
headers=_get_headers(),
|
||||||
json=data,
|
json=data,
|
||||||
timeout=TIMEOUT,
|
timeout=TIMEOUT,
|
||||||
)
|
)
|
||||||
|
if response.status_code == 403:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to create meeting: access denied on Whereby: {response.text}"
|
||||||
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@@ -48,7 +93,7 @@ async def get_room_sessions(room_name: str):
|
|||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"{settings.WHEREBY_API_URL}/insights/room-sessions?roomName={room_name}",
|
f"{settings.WHEREBY_API_URL}/insights/room-sessions?roomName={room_name}",
|
||||||
headers=HEADERS,
|
headers=_get_headers(),
|
||||||
timeout=TIMEOUT,
|
timeout=TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
34
www/.env.example
Normal file
34
www/.env.example
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Environment
|
||||||
|
ENVIRONMENT=development
|
||||||
|
NEXT_PUBLIC_ENV=development
|
||||||
|
|
||||||
|
# Site Configuration
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Nextauth envs
|
||||||
|
# not used in app code but in lib code
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET=your-nextauth-secret-here
|
||||||
|
# / Nextauth envs
|
||||||
|
|
||||||
|
# Authentication (Authentik OAuth/OIDC)
|
||||||
|
AUTHENTIK_ISSUER=https://authentik.example.com/application/o/reflector
|
||||||
|
AUTHENTIK_REFRESH_TOKEN_URL=https://authentik.example.com/application/o/token/
|
||||||
|
AUTHENTIK_CLIENT_ID=your-client-id-here
|
||||||
|
AUTHENTIK_CLIENT_SECRET=your-client-secret-here
|
||||||
|
|
||||||
|
# Feature Flags
|
||||||
|
# NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
|
||||||
|
# NEXT_PUBLIC_FEATURE_PRIVACY=false
|
||||||
|
# NEXT_PUBLIC_FEATURE_BROWSE=true
|
||||||
|
# NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
|
||||||
|
# NEXT_PUBLIC_FEATURE_ROOMS=true
|
||||||
|
|
||||||
|
# API URLs
|
||||||
|
NEXT_PUBLIC_API_URL=http://127.0.0.1:1250
|
||||||
|
NEXT_PUBLIC_WEBSOCKET_URL=ws://127.0.0.1:1250
|
||||||
|
NEXT_PUBLIC_AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
|
||||||
|
|
||||||
|
# Sentry
|
||||||
|
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
|
||||||
|
# SENTRY_IGNORE_API_RESOLUTION_ERROR=1
|
||||||
1
www/.gitignore
vendored
1
www/.gitignore
vendored
@@ -40,7 +40,6 @@ next-env.d.ts
|
|||||||
# Sentry Auth Token
|
# Sentry Auth Token
|
||||||
.sentryclirc
|
.sentryclirc
|
||||||
|
|
||||||
config.ts
|
|
||||||
|
|
||||||
# openapi logs
|
# openapi logs
|
||||||
openapi-ts-error-*.log
|
openapi-ts-error-*.log
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Container, Flex, Link } from "@chakra-ui/react";
|
import { Container, Flex, Link } from "@chakra-ui/react";
|
||||||
import { getConfig } from "../lib/edgeConfig";
|
import { featureEnabled } from "../lib/features";
|
||||||
import NextLink from "next/link";
|
import NextLink from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import UserInfo from "../(auth)/userInfo";
|
import UserInfo from "../(auth)/userInfo";
|
||||||
@@ -11,8 +11,6 @@ export default async function AppLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const config = await getConfig();
|
|
||||||
const { requireLogin, privacy, browse, rooms } = config.features;
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
minW="100vw"
|
minW="100vw"
|
||||||
@@ -58,7 +56,7 @@ export default async function AppLayout({
|
|||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</Link>
|
</Link>
|
||||||
{browse ? (
|
{featureEnabled("browse") ? (
|
||||||
<>
|
<>
|
||||||
·
|
·
|
||||||
<Link href="/browse" as={NextLink} className="font-light px-2">
|
<Link href="/browse" as={NextLink} className="font-light px-2">
|
||||||
@@ -68,7 +66,7 @@ export default async function AppLayout({
|
|||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
)}
|
)}
|
||||||
{rooms ? (
|
{featureEnabled("rooms") ? (
|
||||||
<>
|
<>
|
||||||
·
|
·
|
||||||
<Link href="/rooms" as={NextLink} className="font-light px-2">
|
<Link href="/rooms" as={NextLink} className="font-light px-2">
|
||||||
@@ -78,7 +76,7 @@ export default async function AppLayout({
|
|||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
)}
|
)}
|
||||||
{requireLogin ? (
|
{featureEnabled("requireLogin") ? (
|
||||||
<>
|
<>
|
||||||
·
|
·
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import ScrollToBottom from "../../scrollToBottom";
|
|||||||
import { Topic } from "../../webSocketTypes";
|
import { Topic } from "../../webSocketTypes";
|
||||||
import useParticipants from "../../useParticipants";
|
import useParticipants from "../../useParticipants";
|
||||||
import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
|
import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
|
||||||
import { featureEnabled } from "../../../../domainContext";
|
|
||||||
import { TopicItem } from "./TopicItem";
|
import { TopicItem } from "./TopicItem";
|
||||||
import { TranscriptStatus } from "../../../../lib/transcript";
|
import { TranscriptStatus } from "../../../../lib/transcript";
|
||||||
|
|
||||||
|
import { featureEnabled } from "../../../../lib/features";
|
||||||
|
|
||||||
type TopicListProps = {
|
type TopicListProps = {
|
||||||
topics: Topic[];
|
topics: Topic[];
|
||||||
useActiveTopic: [
|
useActiveTopic: [
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" && (
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useRouter } from "next/navigation";
|
|||||||
import useCreateTranscript from "../createTranscript";
|
import useCreateTranscript from "../createTranscript";
|
||||||
import SelectSearch from "react-select-search";
|
import SelectSearch from "react-select-search";
|
||||||
import { supportedLanguages } from "../../../supportedLanguages";
|
import { supportedLanguages } from "../../../supportedLanguages";
|
||||||
import { featureEnabled } from "../../../domainContext";
|
|
||||||
import {
|
import {
|
||||||
Flex,
|
Flex,
|
||||||
Box,
|
Box,
|
||||||
@@ -21,10 +20,9 @@ import {
|
|||||||
Spacer,
|
Spacer,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useAuth } from "../../../lib/AuthProvider";
|
import { useAuth } from "../../../lib/AuthProvider";
|
||||||
import type { components } from "../../../reflector-api";
|
import { featureEnabled } from "../../../lib/features";
|
||||||
|
|
||||||
const TranscriptCreate = () => {
|
const TranscriptCreate = () => {
|
||||||
const isClient = typeof window !== "undefined";
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const isAuthenticated = auth.status === "authenticated";
|
const isAuthenticated = auth.status === "authenticated";
|
||||||
@@ -176,7 +174,7 @@ const TranscriptCreate = () => {
|
|||||||
placeholder="Choose your language"
|
placeholder="Choose your language"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{isClient && !loading ? (
|
{!loading ? (
|
||||||
permissionOk ? (
|
permissionOk ? (
|
||||||
<Spacer />
|
<Spacer />
|
||||||
) : permissionDenied ? (
|
) : permissionDenied ? (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { featureEnabled } from "../../domainContext";
|
|
||||||
|
|
||||||
import { ShareMode, toShareMode } from "../../lib/shareMode";
|
import { ShareMode, toShareMode } from "../../lib/shareMode";
|
||||||
import type { components } from "../../reflector-api";
|
import type { components } from "../../reflector-api";
|
||||||
@@ -24,6 +23,8 @@ import ShareCopy from "./shareCopy";
|
|||||||
import ShareZulip from "./shareZulip";
|
import ShareZulip from "./shareZulip";
|
||||||
import { useAuth } from "../../lib/AuthProvider";
|
import { useAuth } from "../../lib/AuthProvider";
|
||||||
|
|
||||||
|
import { featureEnabled } from "../../lib/features";
|
||||||
|
|
||||||
type ShareAndPrivacyProps = {
|
type ShareAndPrivacyProps = {
|
||||||
finalSummaryRef: any;
|
finalSummaryRef: any;
|
||||||
transcriptResponse: GetTranscript;
|
transcriptResponse: GetTranscript;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useRef, useEffect, use } from "react";
|
import React, { useState, useRef, useEffect, use } from "react";
|
||||||
import { featureEnabled } from "../../domainContext";
|
|
||||||
import { Button, Flex, Input, Text } from "@chakra-ui/react";
|
import { Button, Flex, Input, Text } from "@chakra-ui/react";
|
||||||
import QRCode from "react-qr-code";
|
import QRCode from "react-qr-code";
|
||||||
|
|
||||||
|
import { featureEnabled } from "../../lib/features";
|
||||||
|
|
||||||
type ShareLinkProps = {
|
type ShareLinkProps = {
|
||||||
transcriptId: string;
|
transcriptId: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { featureEnabled } from "../../domainContext";
|
|
||||||
import type { components } from "../../reflector-api";
|
import type { components } from "../../reflector-api";
|
||||||
|
|
||||||
type GetTranscript = components["schemas"]["GetTranscript"];
|
type GetTranscript = components["schemas"]["GetTranscript"];
|
||||||
@@ -25,6 +24,8 @@ import {
|
|||||||
useTranscriptPostToZulip,
|
useTranscriptPostToZulip,
|
||||||
} from "../../lib/apiHooks";
|
} from "../../lib/apiHooks";
|
||||||
|
|
||||||
|
import { featureEnabled } from "../../lib/features";
|
||||||
|
|
||||||
type ShareZulipProps = {
|
type ShareZulipProps = {
|
||||||
transcriptResponse: GetTranscript;
|
transcriptResponse: GetTranscript;
|
||||||
topicsResponse: GetTranscriptTopic[];
|
topicsResponse: GetTranscriptTopic[];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { DomainContext } from "../../domainContext";
|
|
||||||
import { useTranscriptGet } from "../../lib/apiHooks";
|
import { useTranscriptGet } from "../../lib/apiHooks";
|
||||||
import { useAuth } from "../../lib/AuthProvider";
|
import { useAuth } from "../../lib/AuthProvider";
|
||||||
|
import { API_URL } from "../../lib/apiClient";
|
||||||
|
|
||||||
export type Mp3Response = {
|
export type Mp3Response = {
|
||||||
media: HTMLMediaElement | null;
|
media: HTMLMediaElement | null;
|
||||||
@@ -19,7 +19,6 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
|
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
|
||||||
const { api_url } = useContext(DomainContext);
|
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const accessTokenInfo =
|
const accessTokenInfo =
|
||||||
auth.status === "authenticated" ? auth.accessToken : null;
|
auth.status === "authenticated" ? auth.accessToken : null;
|
||||||
@@ -78,7 +77,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
|||||||
|
|
||||||
// Audio is not deleted, proceed to load it
|
// Audio is not deleted, proceed to load it
|
||||||
audioElement = document.createElement("audio");
|
audioElement = document.createElement("audio");
|
||||||
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
|
audioElement.src = `${API_URL}/v1/transcripts/${transcriptId}/audio/mp3`;
|
||||||
audioElement.crossOrigin = "anonymous";
|
audioElement.crossOrigin = "anonymous";
|
||||||
audioElement.preload = "auto";
|
audioElement.preload = "auto";
|
||||||
|
|
||||||
@@ -110,7 +109,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
|||||||
if (handleError) audioElement.removeEventListener("error", handleError);
|
if (handleError) audioElement.removeEventListener("error", handleError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [transcriptId, transcript, later, api_url]);
|
}, [transcriptId, transcript, later]);
|
||||||
|
|
||||||
const getNow = () => {
|
const getNow = () => {
|
||||||
setLater(false);
|
setLater(false);
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Topic, FinalSummary, Status } from "./webSocketTypes";
|
import { Topic, FinalSummary, Status } from "./webSocketTypes";
|
||||||
import { useError } from "../../(errors)/errorContext";
|
import { useError } from "../../(errors)/errorContext";
|
||||||
import { DomainContext } from "../../domainContext";
|
|
||||||
import type { components } from "../../reflector-api";
|
import type { components } from "../../reflector-api";
|
||||||
type AudioWaveform = components["schemas"]["AudioWaveform"];
|
type AudioWaveform = components["schemas"]["AudioWaveform"];
|
||||||
type GetTranscriptSegmentTopic =
|
type GetTranscriptSegmentTopic =
|
||||||
components["schemas"]["GetTranscriptSegmentTopic"];
|
components["schemas"]["GetTranscriptSegmentTopic"];
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { $api } from "../../lib/apiClient";
|
import { $api, WEBSOCKET_URL } from "../../lib/apiClient";
|
||||||
|
|
||||||
export type UseWebSockets = {
|
export type UseWebSockets = {
|
||||||
transcriptTextLive: string;
|
transcriptTextLive: string;
|
||||||
@@ -37,7 +36,6 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
|||||||
const [status, setStatus] = useState<Status | null>(null);
|
const [status, setStatus] = useState<Status | null>(null);
|
||||||
const { setError } = useError();
|
const { setError } = useError();
|
||||||
|
|
||||||
const { websocket_url: websocketUrl } = useContext(DomainContext);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [accumulatedText, setAccumulatedText] = useState<string>("");
|
const [accumulatedText, setAccumulatedText] = useState<string>("");
|
||||||
@@ -328,7 +326,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
|||||||
|
|
||||||
if (!transcriptId) return;
|
if (!transcriptId) return;
|
||||||
|
|
||||||
const url = `${websocketUrl}/v1/transcripts/${transcriptId}/events`;
|
const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`;
|
||||||
let ws = new WebSocket(url);
|
let ws = new WebSocket(url);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@@ -494,7 +492,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
|||||||
return () => {
|
return () => {
|
||||||
ws.close();
|
ws.close();
|
||||||
};
|
};
|
||||||
}, [transcriptId, websocketUrl]);
|
}, [transcriptId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transcriptTextLive,
|
transcriptTextLive,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useContext,
|
useContext,
|
||||||
RefObject,
|
RefObject,
|
||||||
|
use,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -37,9 +38,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
|
||||||
@@ -262,9 +263,11 @@ 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 router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const status = auth.status;
|
const status = auth.status;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
import { DomainConfig } from "./lib/edgeConfig";
|
|
||||||
|
|
||||||
type DomainContextType = Omit<DomainConfig, "auth_callback_url">;
|
|
||||||
|
|
||||||
export const DomainContext = createContext<DomainContextType>({
|
|
||||||
features: {
|
|
||||||
requireLogin: false,
|
|
||||||
privacy: true,
|
|
||||||
browse: false,
|
|
||||||
sendToZulip: false,
|
|
||||||
},
|
|
||||||
api_url: "",
|
|
||||||
websocket_url: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DomainContextProvider = ({
|
|
||||||
config,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
config: DomainConfig;
|
|
||||||
children: any;
|
|
||||||
}) => {
|
|
||||||
const [context, setContext] = useState<DomainContextType>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!config) return;
|
|
||||||
const { auth_callback_url, ...others } = config;
|
|
||||||
setContext(others);
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
if (!context) return;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DomainContext.Provider value={context}>{children}</DomainContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get feature config client-side with
|
|
||||||
export const featureEnabled = (
|
|
||||||
featureName: "requireLogin" | "privacy" | "browse" | "sendToZulip",
|
|
||||||
) => {
|
|
||||||
const context = useContext(DomainContext);
|
|
||||||
|
|
||||||
return context.features[featureName] as boolean | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get config server-side (out of react) : see lib/edgeConfig.
|
|
||||||
@@ -3,11 +3,10 @@ import { Metadata, Viewport } from "next";
|
|||||||
import { Poppins } from "next/font/google";
|
import { Poppins } from "next/font/google";
|
||||||
import { ErrorProvider } from "./(errors)/errorContext";
|
import { ErrorProvider } from "./(errors)/errorContext";
|
||||||
import ErrorMessage from "./(errors)/errorMessage";
|
import ErrorMessage from "./(errors)/errorMessage";
|
||||||
import { DomainContextProvider } from "./domainContext";
|
|
||||||
import { RecordingConsentProvider } from "./recordingConsentContext";
|
import { RecordingConsentProvider } from "./recordingConsentContext";
|
||||||
import { getConfig } from "./lib/edgeConfig";
|
|
||||||
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"],
|
||||||
@@ -22,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",
|
||||||
@@ -68,12 +72,9 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={poppins.className} suppressHydrationWarning>
|
<html lang="en" className={poppins.className} suppressHydrationWarning>
|
||||||
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
|
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
|
||||||
<DomainContextProvider config={config}>
|
|
||||||
<RecordingConsentProvider>
|
<RecordingConsentProvider>
|
||||||
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
|
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
|
||||||
<ErrorProvider>
|
<ErrorProvider>
|
||||||
@@ -82,7 +83,6 @@ export default async function RootLayout({
|
|||||||
</ErrorProvider>
|
</ErrorProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</RecordingConsentProvider>
|
</RecordingConsentProvider>
|
||||||
</DomainContextProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,10 +6,17 @@ import createFetchClient from "openapi-react-query";
|
|||||||
import { assertExistsAndNonEmptyString } from "./utils";
|
import { assertExistsAndNonEmptyString } from "./utils";
|
||||||
import { isBuildPhase } from "./next";
|
import { isBuildPhase } from "./next";
|
||||||
|
|
||||||
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
|
||||||
|
export const WEBSOCKET_URL =
|
||||||
|
process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250";
|
||||||
|
|
||||||
export const client = createClient<paths>({
|
export const client = createClient<paths>({
|
||||||
baseUrl: API_URL,
|
baseUrl: API_URL,
|
||||||
});
|
});
|
||||||
|
|||||||
12
www/app/lib/array.ts
Normal file
12
www/app/lib/array.ts
Normal 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");
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { assertExistsAndNonEmptyString } from "./utils";
|
||||||
|
|
||||||
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
|
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
|
||||||
// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min
|
// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min
|
||||||
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
|
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { get } from "@vercel/edge-config";
|
|
||||||
import { isBuildPhase } from "./next";
|
|
||||||
|
|
||||||
type EdgeConfig = {
|
|
||||||
[domainWithDash: string]: {
|
|
||||||
features: {
|
|
||||||
[featureName in
|
|
||||||
| "requireLogin"
|
|
||||||
| "privacy"
|
|
||||||
| "browse"
|
|
||||||
| "sendToZulip"]: boolean;
|
|
||||||
};
|
|
||||||
auth_callback_url: string;
|
|
||||||
websocket_url: string;
|
|
||||||
api_url: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DomainConfig = EdgeConfig["domainWithDash"];
|
|
||||||
|
|
||||||
// Edge config main keys can only be alphanumeric and _ or -
|
|
||||||
export function edgeKeyToDomain(key: string) {
|
|
||||||
return key.replaceAll("_", ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function edgeDomainToKey(domain: string) {
|
|
||||||
return domain.replaceAll(".", "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
// get edge config server-side (prefer DomainContext when available), domain is the hostname
|
|
||||||
export async function getConfig() {
|
|
||||||
if (process.env.NEXT_PUBLIC_ENV === "development") {
|
|
||||||
try {
|
|
||||||
return require("../../config").localConfig;
|
|
||||||
} catch (e) {
|
|
||||||
// next build() WILL try to execute the require above even if conditionally protected
|
|
||||||
// but thank god it at least runs catch{} block properly
|
|
||||||
if (!isBuildPhase) throw new Error(e);
|
|
||||||
return require("../../config-template").localConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
|
|
||||||
let config = await get(edgeDomainToKey(domain));
|
|
||||||
|
|
||||||
if (typeof config !== "object") {
|
|
||||||
console.warn("No config for this domain, falling back to default");
|
|
||||||
config = await get(edgeDomainToKey("default"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof config !== "object") throw Error("Error fetching config");
|
|
||||||
|
|
||||||
return config as DomainConfig;
|
|
||||||
}
|
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
55
www/app/lib/features.ts
Normal file
55
www/app/lib/features.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
export const FEATURES = [
|
||||||
|
"requireLogin",
|
||||||
|
"privacy",
|
||||||
|
"browse",
|
||||||
|
"sendToZulip",
|
||||||
|
"rooms",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type FeatureName = (typeof FEATURES)[number];
|
||||||
|
|
||||||
|
export type Features = Readonly<Record<FeatureName, boolean>>;
|
||||||
|
|
||||||
|
export const DEFAULT_FEATURES: Features = {
|
||||||
|
requireLogin: true,
|
||||||
|
privacy: true,
|
||||||
|
browse: true,
|
||||||
|
sendToZulip: true,
|
||||||
|
rooms: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function parseBooleanEnv(
|
||||||
|
value: string | undefined,
|
||||||
|
defaultValue: boolean = false,
|
||||||
|
): boolean {
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
return value.toLowerCase() === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
|
||||||
|
const features: Features = {
|
||||||
|
requireLogin: parseBooleanEnv(
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN,
|
||||||
|
DEFAULT_FEATURES.requireLogin,
|
||||||
|
),
|
||||||
|
privacy: parseBooleanEnv(
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_PRIVACY,
|
||||||
|
DEFAULT_FEATURES.privacy,
|
||||||
|
),
|
||||||
|
browse: parseBooleanEnv(
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_BROWSE,
|
||||||
|
DEFAULT_FEATURES.browse,
|
||||||
|
),
|
||||||
|
sendToZulip: parseBooleanEnv(
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP,
|
||||||
|
DEFAULT_FEATURES.sendToZulip,
|
||||||
|
),
|
||||||
|
rooms: parseBooleanEnv(
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_ROOMS,
|
||||||
|
DEFAULT_FEATURES.rooms,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const featureEnabled = (featureName: FeatureName): boolean => {
|
||||||
|
return features[featureName];
|
||||||
|
};
|
||||||
@@ -72,3 +72,7 @@ export const assertCustomSession = <S extends Session>(s: S): CustomSession => {
|
|||||||
// no other checks for now
|
// no other checks for now
|
||||||
return r as CustomSession;
|
return r as CustomSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Mutable<T> = {
|
||||||
|
-readonly [P in keyof T]: T[P];
|
||||||
|
};
|
||||||
|
|||||||
@@ -171,5 +171,6 @@ export const assertNotExists = <T>(
|
|||||||
|
|
||||||
export const assertExistsAndNonEmptyString = (
|
export const assertExistsAndNonEmptyString = (
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
|
err?: string,
|
||||||
): NonEmptyString =>
|
): NonEmptyString =>
|
||||||
parseNonEmptyString(assertExists(value, "Expected non-empty string"));
|
parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { ChakraProvider } from "@chakra-ui/react";
|
import { ChakraProvider } from "@chakra-ui/react";
|
||||||
import system from "./styles/theme";
|
import system from "./styles/theme";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
import { WherebyProvider } from "@whereby.com/browser-sdk/react";
|
|
||||||
import { Toaster } from "./components/ui/toaster";
|
import { Toaster } from "./components/ui/toaster";
|
||||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -11,6 +11,14 @@ import { queryClient } from "./lib/queryClient";
|
|||||||
import { AuthProvider } from "./lib/AuthProvider";
|
import { AuthProvider } from "./lib/AuthProvider";
|
||||||
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
|
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
|
||||||
|
|
||||||
|
const WherebyProvider = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@whereby.com/browser-sdk/react").then((mod) => ({
|
||||||
|
default: mod.WherebyProvider,
|
||||||
|
})),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<NuqsAdapter>
|
<NuqsAdapter>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
export const localConfig = {
|
|
||||||
features: {
|
|
||||||
requireLogin: true,
|
|
||||||
privacy: true,
|
|
||||||
browse: true,
|
|
||||||
sendToZulip: true,
|
|
||||||
rooms: true,
|
|
||||||
},
|
|
||||||
api_url: "http://127.0.0.1:1250",
|
|
||||||
websocket_url: "ws://127.0.0.1:1250",
|
|
||||||
auth_callback_url: "http://localhost:3000/auth-callback",
|
|
||||||
zulip_streams: "", // Find the value on zulip
|
|
||||||
};
|
|
||||||
@@ -23,3 +23,5 @@ if (SENTRY_DSN) {
|
|||||||
replaysSessionSampleRate: 0.0,
|
replaysSessionSampleRate: 0.0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
9
www/instrumentation.ts
Normal file
9
www/instrumentation.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { withAuth } from "next-auth/middleware";
|
import { withAuth } from "next-auth/middleware";
|
||||||
import { getConfig } from "./app/lib/edgeConfig";
|
import { featureEnabled } from "./app/lib/features";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { PROTECTED_PAGES } from "./app/lib/auth";
|
import { PROTECTED_PAGES } from "./app/lib/auth";
|
||||||
|
|
||||||
@@ -19,13 +19,12 @@ export const config = {
|
|||||||
|
|
||||||
export default withAuth(
|
export default withAuth(
|
||||||
async function middleware(request) {
|
async function middleware(request) {
|
||||||
const config = await getConfig();
|
|
||||||
const pathname = request.nextUrl.pathname;
|
const pathname = request.nextUrl.pathname;
|
||||||
|
|
||||||
// feature-flags protected paths
|
// feature-flags protected paths
|
||||||
if (
|
if (
|
||||||
(!config.features.browse && pathname.startsWith("/browse")) ||
|
(!featureEnabled("browse") && pathname.startsWith("/browse")) ||
|
||||||
(!config.features.rooms && pathname.startsWith("/rooms"))
|
(!featureEnabled("rooms") && pathname.startsWith("/rooms"))
|
||||||
) {
|
) {
|
||||||
return NextResponse.redirect(request.nextUrl.origin);
|
return NextResponse.redirect(request.nextUrl.origin);
|
||||||
}
|
}
|
||||||
@@ -33,10 +32,8 @@ export default withAuth(
|
|||||||
{
|
{
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async authorized({ req, token }) {
|
async authorized({ req, token }) {
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config.features.requireLogin &&
|
featureEnabled("requireLogin") &&
|
||||||
PROTECTED_PAGES.test(req.nextUrl.pathname)
|
PROTECTED_PAGES.test(req.nextUrl.pathname)
|
||||||
) {
|
) {
|
||||||
return !!token;
|
return !!token;
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,20 +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",
|
||||||
"@vercel/edge-config": "^0.4.1",
|
|
||||||
"@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",
|
||||||
@@ -63,8 +62,7 @@
|
|||||||
"jest": "^30.1.3",
|
"jest": "^30.1.3",
|
||||||
"openapi-typescript": "^7.9.1",
|
"openapi-typescript": "^7.9.1",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"ts-jest": "^29.4.1",
|
"ts-jest": "^29.4.1"
|
||||||
"vercel": "^37.3.0"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
|
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
|
||||||
}
|
}
|
||||||
|
|||||||
4475
www/pnpm-lock.yaml
generated
4475
www/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user