Merge pull request #309 from Monadical-SAS/feat-sharing

Introduce share mode
This commit is contained in:
2023-11-24 11:52:26 +01:00
committed by GitHub
26 changed files with 467 additions and 228 deletions

View File

@@ -0,0 +1,33 @@
"""add share_mode
Revision ID: 0fea6d96b096
Revises: f819277e5169
Create Date: 2023-11-07 11:12:21.614198
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0fea6d96b096"
down_revision: Union[str, None] = "f819277e5169"
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! ###
op.add_column(
"transcript",
sa.Column("share_mode", sa.String(), server_default="private", nullable=False),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("transcript", "share_mode")
# ### end Alembic commands ###

View File

@@ -2,10 +2,11 @@ import json
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal
from uuid import uuid4 from uuid import uuid4
import sqlalchemy import sqlalchemy
from fastapi import HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from reflector.db import database, metadata from reflector.db import database, metadata
from reflector.processors.types import Word as ProcessorWord from reflector.processors.types import Word as ProcessorWord
@@ -36,6 +37,12 @@ transcripts = sqlalchemy.Table(
), ),
# with user attached, optional # with user attached, optional
sqlalchemy.Column("user_id", sqlalchemy.String), sqlalchemy.Column("user_id", sqlalchemy.String),
sqlalchemy.Column(
"share_mode",
sqlalchemy.String,
nullable=False,
server_default="private",
),
) )
@@ -120,6 +127,7 @@ class Transcript(BaseModel):
events: list[TranscriptEvent] = [] events: list[TranscriptEvent] = []
source_language: str = "en" source_language: str = "en"
target_language: str = "en" target_language: str = "en"
share_mode: Literal["private", "semi-private", "public"] = "private"
audio_location: str = "local" audio_location: str = "local"
def add_event(self, event: str, data: BaseModel) -> TranscriptEvent: def add_event(self, event: str, data: BaseModel) -> TranscriptEvent:
@@ -217,6 +225,7 @@ class TranscriptController:
order_by: str | None = None, order_by: str | None = None,
filter_empty: bool | None = False, filter_empty: bool | None = False,
filter_recording: bool | None = False, filter_recording: bool | None = False,
return_query: bool = False,
) -> list[Transcript]: ) -> list[Transcript]:
""" """
Get all transcripts Get all transcripts
@@ -243,6 +252,9 @@ class TranscriptController:
if filter_recording: if filter_recording:
query = query.filter(transcripts.c.status != "recording") query = query.filter(transcripts.c.status != "recording")
if return_query:
return query
results = await database.fetch_all(query) results = await database.fetch_all(query)
return results return results
@@ -258,6 +270,47 @@ class TranscriptController:
return None return None
return Transcript(**result) return Transcript(**result)
async def get_by_id_for_http(
self,
transcript_id: str,
user_id: str | None,
) -> Transcript:
"""
Get a transcript by ID for HTTP request.
If not found, it will raise a 404 error.
If the user is not allowed to access the transcript, it will raise a 403 error.
This method checks the share mode of the transcript and the user_id
to determine if the user can access the transcript.
"""
query = transcripts.select().where(transcripts.c.id == transcript_id)
result = await database.fetch_one(query)
if not result:
raise HTTPException(status_code=404, detail="Transcript not found")
# if the transcript is anonymous, share mode is not checked
transcript = Transcript(**result)
if transcript.user_id is None:
return transcript
if transcript.share_mode == "private":
# in private mode, only the owner can access the transcript
if transcript.user_id == user_id:
return transcript
elif transcript.share_mode == "semi-private":
# in semi-private mode, only the owner and the users with the link
# can access the transcript
if user_id is not None:
return transcript
elif transcript.share_mode == "public":
# in public mode, everyone can access the transcript
return transcript
raise HTTPException(status_code=403, detail="Transcript access denied")
async def add( async def add(
self, self,
name: str, name: str,

View File

@@ -1,5 +1,5 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Annotated, Optional from typing import Annotated, Literal, Optional
import httpx import httpx
import reflector.auth as auth import reflector.auth as auth
@@ -13,7 +13,8 @@ from fastapi import (
WebSocketDisconnect, WebSocketDisconnect,
status, status,
) )
from fastapi_pagination import Page, paginate from fastapi_pagination import Page
from fastapi_pagination.ext.databases import paginate
from jose import jwt from jose import jwt
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from reflector.db.transcripts import ( from reflector.db.transcripts import (
@@ -49,6 +50,7 @@ def create_access_token(data: dict, expires_delta: timedelta):
class GetTranscript(BaseModel): class GetTranscript(BaseModel):
id: str id: str
user_id: str | None
name: str name: str
status: str status: str
locked: bool locked: bool
@@ -57,6 +59,7 @@ class GetTranscript(BaseModel):
short_summary: str | None short_summary: str | None
long_summary: str | None long_summary: str | None
created_at: datetime created_at: datetime
share_mode: str = Field("private")
source_language: str | None source_language: str | None
target_language: str | None target_language: str | None
@@ -73,6 +76,7 @@ class UpdateTranscript(BaseModel):
title: Optional[str] = Field(None) title: Optional[str] = Field(None)
short_summary: Optional[str] = Field(None) short_summary: Optional[str] = Field(None)
long_summary: Optional[str] = Field(None) long_summary: Optional[str] = Field(None)
share_mode: Optional[Literal["public", "semi-private", "private"]] = Field(None)
class DeletionStatus(BaseModel): class DeletionStatus(BaseModel):
@@ -83,12 +87,19 @@ class DeletionStatus(BaseModel):
async def transcripts_list( async def transcripts_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
): ):
from reflector.db import database
if not user and not settings.PUBLIC_MODE: if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
return paginate( return await paginate(
await transcripts_controller.get_all(user_id=user_id, order_by="-created_at") database,
await transcripts_controller.get_all(
user_id=user_id,
order_by="-created_at",
return_query=True,
),
) )
@@ -166,10 +177,9 @@ async def transcript_get(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id) return await transcripts_controller.get_by_id_for_http(
if not transcript: transcript_id, user_id=user_id
raise HTTPException(status_code=404, detail="Transcript not found") )
return transcript
@router.patch("/transcripts/{transcript_id}", response_model=GetTranscript) @router.patch("/transcripts/{transcript_id}", response_model=GetTranscript)
@@ -193,6 +203,8 @@ async def transcript_update(
values["short_summary"] = info.short_summary values["short_summary"] = info.short_summary
if info.title is not None: if info.title is not None:
values["title"] = info.title values["title"] = info.title
if info.share_mode is not None:
values["share_mode"] = info.share_mode
await transcripts_controller.update(transcript, values) await transcripts_controller.update(transcript, values)
return transcript return transcript
@@ -231,9 +243,27 @@ async def transcript_get_audio_mp3(
except jwt.JWTError: except jwt.JWTError:
raise unauthorized_exception raise unauthorized_exception
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id) transcript = await transcripts_controller.get_by_id_for_http(
if not transcript: transcript_id, user_id=user_id
raise HTTPException(status_code=404, detail="Transcript not found") )
if transcript.audio_location == "storage":
# proxy S3 file, to prevent issue with CORS
url = await transcript.get_audio_url()
headers = {}
copy_headers = ["range", "accept-encoding"]
for header in copy_headers:
if header in request.headers:
headers[header] = request.headers[header]
async with httpx.AsyncClient() as client:
resp = await client.request(request.method, url, headers=headers)
return Response(
content=resp.content,
status_code=resp.status_code,
headers=resp.headers,
)
if transcript.audio_location == "storage": if transcript.audio_location == "storage":
# proxy S3 file, to prevent issue with CORS # proxy S3 file, to prevent issue with CORS
@@ -254,7 +284,7 @@ async def transcript_get_audio_mp3(
) )
if not transcript.audio_mp3_filename.exists(): if not transcript.audio_mp3_filename.exists():
raise HTTPException(status_code=404, detail="Audio not found") raise HTTPException(status_code=500, detail="Audio not found")
truncated_id = str(transcript.id).split("-")[0] truncated_id = str(transcript.id).split("-")[0]
filename = f"recording_{truncated_id}.mp3" filename = f"recording_{truncated_id}.mp3"
@@ -273,9 +303,9 @@ async def transcript_get_audio_waveform(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
) -> AudioWaveform: ) -> AudioWaveform:
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id) transcript = await transcripts_controller.get_by_id_for_http(
if not transcript: transcript_id, user_id=user_id
raise HTTPException(status_code=404, detail="Transcript not found") )
if not transcript.audio_waveform_filename.exists(): if not transcript.audio_waveform_filename.exists():
raise HTTPException(status_code=404, detail="Audio not found") raise HTTPException(status_code=404, detail="Audio not found")
@@ -292,9 +322,9 @@ async def transcript_get_topics(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id) transcript = await transcripts_controller.get_by_id_for_http(
if not transcript: transcript_id, user_id=user_id
raise HTTPException(status_code=404, detail="Transcript not found") )
# convert to GetTranscriptTopic # convert to GetTranscriptTopic
return [ return [
@@ -363,9 +393,9 @@ async def transcript_record_webrtc(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
): ):
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id, user_id=user_id) transcript = await transcripts_controller.get_by_id_for_http(
if not transcript: transcript_id, user_id=user_id
raise HTTPException(status_code=404, detail="Transcript not found") )
if transcript.locked: if transcript.locked:
raise HTTPException(status_code=400, detail="Transcript is locked") raise HTTPException(status_code=400, detail="Transcript is locked")

View File

@@ -1,11 +1,18 @@
"use client"; "use client";
import { FiefAuthProvider } from "@fief/fief/nextjs/react"; import { FiefAuthProvider } from "@fief/fief/nextjs/react";
import { createContext } from "react";
export default function FiefWrapper({ children }) { export const CookieContext = createContext<{ hasAuthCookie: boolean }>({
hasAuthCookie: false,
});
export default function FiefWrapper({ children, hasAuthCookie }) {
return ( return (
<CookieContext.Provider value={{ hasAuthCookie }}>
<FiefAuthProvider currentUserPath="/api/current-user"> <FiefAuthProvider currentUserPath="/api/current-user">
{children} {children}
</FiefAuthProvider> </FiefAuthProvider>
</CookieContext.Provider>
); );
} }

View File

@@ -11,6 +11,9 @@ import About from "../(aboutAndPrivacy)/about";
import Privacy from "../(aboutAndPrivacy)/privacy"; import Privacy from "../(aboutAndPrivacy)/privacy";
import { DomainContextProvider } from "./domainContext"; import { DomainContextProvider } from "./domainContext";
import { getConfig } from "../lib/edgeConfig"; import { getConfig } from "../lib/edgeConfig";
import { ErrorBoundary } from "@sentry/nextjs";
import { cookies } from "next/dist/client/components/headers";
import { SESSION_COOKIE_NAME } from "../lib/fief";
const poppins = Poppins({ subsets: ["latin"], weight: ["200", "400", "600"] }); const poppins = Poppins({ subsets: ["latin"], weight: ["200", "400", "600"] });
@@ -70,12 +73,14 @@ type LayoutProps = {
export default async function RootLayout({ children, params }: LayoutProps) { export default async function RootLayout({ children, params }: LayoutProps) {
const config = await getConfig(params.domain); const config = await getConfig(params.domain);
const { requireLogin, privacy, browse } = config.features; const { requireLogin, privacy, browse } = config.features;
const hasAuthCookie = !!cookies().get(SESSION_COOKIE_NAME);
return ( return (
<html lang="en"> <html lang="en">
<body className={poppins.className + " h-screen relative"}> <body className={poppins.className + " h-screen relative"}>
<FiefWrapper> <FiefWrapper hasAuthCookie={hasAuthCookie}>
<DomainContextProvider config={config}> <DomainContextProvider config={config}>
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
<ErrorProvider> <ErrorProvider>
<ErrorMessage /> <ErrorMessage />
<div <div
@@ -150,6 +155,7 @@ export default async function RootLayout({ children, params }: LayoutProps) {
{children} {children}
</div> </div>
</ErrorProvider> </ErrorProvider>
</ErrorBoundary>
</DomainContextProvider> </DomainContextProvider>
</FiefWrapper> </FiefWrapper>
</body> </body>

View File

@@ -14,6 +14,9 @@ import QRCode from "react-qr-code";
import TranscriptTitle from "../transcriptTitle"; import TranscriptTitle from "../transcriptTitle";
import Player from "../player"; import Player from "../player";
import WaveformLoading from "../waveformLoading"; import WaveformLoading from "../waveformLoading";
import { useRouter } from "next/navigation";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -21,26 +24,16 @@ type TranscriptDetails = {
}; };
}; };
const protectedPath = true;
export default function TranscriptDetails(details: TranscriptDetails) { export default function TranscriptDetails(details: TranscriptDetails) {
const transcriptId = details.params.transcriptId; const transcriptId = details.params.transcriptId;
const router = useRouter();
const transcript = useTranscript(protectedPath, transcriptId); const transcript = useTranscript(transcriptId);
const topics = useTopics(protectedPath, transcriptId); const topics = useTopics(transcriptId);
const waveform = useWaveform(protectedPath, transcriptId); const waveform = useWaveform(transcriptId);
const useActiveTopic = useState<Topic | null>(null); const useActiveTopic = useState<Topic | null>(null);
const mp3 = useMp3(transcriptId); const mp3 = useMp3(transcriptId);
if (transcript?.error || topics?.error) {
return (
<Modal
title="Transcription Not Found"
text="A trascription with this ID does not exist."
/>
);
}
useEffect(() => { useEffect(() => {
const statusToRedirect = ["idle", "recording", "processing"]; const statusToRedirect = ["idle", "recording", "processing"];
if (statusToRedirect.includes(transcript.response?.status)) { if (statusToRedirect.includes(transcript.response?.status)) {
@@ -48,8 +41,8 @@ export default function TranscriptDetails(details: TranscriptDetails) {
// 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
// router.push(newUrl, undefined, { shallow: true }); router.push(newUrl, undefined);
history.replaceState({}, "", newUrl); // history.replaceState({}, "", newUrl);
} }
}, [transcript.response?.status]); }, [transcript.response?.status]);
@@ -60,16 +53,24 @@ export default function TranscriptDetails(details: TranscriptDetails) {
.replace(/ +/g, " ") .replace(/ +/g, " ")
.trim() || ""; .trim() || "";
if (transcript.error || topics?.error) {
return (
<Modal
title="Transcription Not Found"
text="A trascription with this ID does not exist."
/>
);
}
if (transcript?.loading || topics?.loading) {
return <Modal title="Loading" text={"Loading transcript..."} />;
}
return ( return (
<>
{transcript?.loading || topics?.loading ? (
<Modal title="Loading" text={"Loading transcript..."} />
) : (
<> <>
<div className="flex flex-col"> <div className="flex flex-col">
{transcript?.response?.title && ( {transcript?.response?.title && (
<TranscriptTitle <TranscriptTitle
protectedPath={protectedPath}
title={transcript.response.title} title={transcript.response.title}
transcriptId={transcript.response.id} transcriptId={transcript.response.id}
/> />
@@ -99,7 +100,6 @@ export default function TranscriptDetails(details: TranscriptDetails) {
<section className=" bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4 h-full"> <section className=" bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4 h-full">
{transcript.response.longSummary ? ( {transcript.response.longSummary ? (
<FinalSummary <FinalSummary
protectedPath={protectedPath}
fullTranscript={fullTranscript} fullTranscript={fullTranscript}
summary={transcript.response.longSummary} summary={transcript.response.longSummary}
transcriptId={transcript.response.id} transcriptId={transcript.response.id}
@@ -110,8 +110,8 @@ export default function TranscriptDetails(details: TranscriptDetails) {
<p>Loading Transcript</p> <p>Loading Transcript</p>
) : ( ) : (
<p> <p>
There was an error generating the final summary, please There was an error generating the final summary, please come
come back later back later
</p> </p>
)} )}
</div> </div>
@@ -127,13 +127,15 @@ export default function TranscriptDetails(details: TranscriptDetails) {
/> />
</div> </div>
<div className="flex-grow max-w-full"> <div className="flex-grow max-w-full">
<ShareLink /> <ShareLink
transcriptId={transcript?.response?.id}
userId={transcript?.response?.userId}
shareMode={transcript?.response?.shareMode}
/>
</div> </div>
</section> </section>
</div> </div>
</div> </div>
</> </>
)}
</>
); );
} }

View File

@@ -15,7 +15,7 @@ import { faGear } from "@fortawesome/free-solid-svg-icons";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Player from "../../player"; import Player from "../../player";
import useMp3, { Mp3Response } from "../../useMp3"; import useMp3 from "../../useMp3";
import WaveformLoading from "../../waveformLoading"; import WaveformLoading from "../../waveformLoading";
type TranscriptDetails = { type TranscriptDetails = {
@@ -39,8 +39,8 @@ const TranscriptRecord = (details: TranscriptDetails) => {
} }
}, []); }, []);
const transcript = useTranscript(true, details.params.transcriptId); const transcript = useTranscript(details.params.transcriptId);
const webRTC = useWebRTC(stream, details.params.transcriptId, true); const webRTC = useWebRTC(stream, details.params.transcriptId);
const webSockets = useWebSockets(details.params.transcriptId); const webSockets = useWebSockets(details.params.transcriptId);
const { audioDevices, getAudioStream } = useAudioDevice(); const { audioDevices, getAudioStream } = useAudioDevice();

View File

@@ -19,7 +19,7 @@ const useCreateTranscript = (): CreateTranscript => {
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setErrorState] = useState<Error | null>(null); const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError(); const { setError } = useError();
const api = getApi(true); const api = getApi();
const create = (params: V1TranscriptsCreateRequest["createTranscript"]) => { const create = (params: V1TranscriptsCreateRequest["createTranscript"]) => {
if (loading || !api) return; if (loading || !api) return;

View File

@@ -5,7 +5,6 @@ import "../../styles/markdown.css";
import getApi from "../../lib/getApi"; import getApi from "../../lib/getApi";
type FinalSummaryProps = { type FinalSummaryProps = {
protectedPath: boolean;
summary: string; summary: string;
fullTranscript: string; fullTranscript: string;
transcriptId: string; transcriptId: string;
@@ -18,7 +17,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const [preEditSummary, setPreEditSummary] = useState(props.summary); const [preEditSummary, setPreEditSummary] = useState(props.summary);
const [editedSummary, setEditedSummary] = useState(props.summary); const [editedSummary, setEditedSummary] = useState(props.summary);
const api = getApi(props.protectedPath); const api = getApi();
const updateSummary = async (newSummary: string, transcriptId: string) => { const updateSummary = async (newSummary: string, transcriptId: string) => {
if (!api) return; if (!api) return;

View File

@@ -1,15 +1,39 @@
import React, { useState, useRef, useEffect, use } from "react"; import React, { useState, useRef, useEffect, use } from "react";
import { featureEnabled } from "../domainContext"; import { featureEnabled } from "../domainContext";
import getApi from "../../lib/getApi";
import { useFiefUserinfo } from "@fief/fief/nextjs/react";
import SelectSearch from "react-select-search";
import "react-select-search/style.css";
import "../../styles/button.css";
import "../../styles/form.scss";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
const ShareLink = () => { type ShareLinkProps = {
transcriptId: string;
userId: string | null;
shareMode: string;
};
const ShareLink = (props: ShareLinkProps) => {
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [currentUrl, setCurrentUrl] = useState<string>(""); const [currentUrl, setCurrentUrl] = useState<string>("");
const requireLogin = featureEnabled("requireLogin");
const [isOwner, setIsOwner] = useState(false);
const [shareMode, setShareMode] = useState(props.shareMode);
const [shareLoading, setShareLoading] = useState(false);
const userinfo = useFiefUserinfo();
const api = getApi();
useEffect(() => { useEffect(() => {
setCurrentUrl(window.location.href); setCurrentUrl(window.location.href);
}, []); }, []);
useEffect(() => {
setIsOwner(!!(requireLogin && userinfo?.sub === props.userId));
}, [userinfo, props.userId]);
const handleCopyClick = () => { const handleCopyClick = () => {
if (inputRef.current) { if (inputRef.current) {
let text_to_copy = inputRef.current.value; let text_to_copy = inputRef.current.value;
@@ -23,6 +47,18 @@ const ShareLink = () => {
} }
}; };
const updateShareMode = async (selectedShareMode: string) => {
if (!api) return;
setShareLoading(true);
const updatedTranscript = await api.v1TranscriptUpdate({
transcriptId: props.transcriptId,
updateTranscript: {
shareMode: selectedShareMode,
},
});
setShareMode(updatedTranscript.shareMode);
setShareLoading(false);
};
const privacyEnabled = featureEnabled("privacy"); const privacyEnabled = featureEnabled("privacy");
return ( return (
@@ -30,18 +66,61 @@ const ShareLink = () => {
className="p-2 md:p-4 rounded" className="p-2 md:p-4 rounded"
style={{ background: "rgba(96, 165, 250, 0.2)" }} style={{ background: "rgba(96, 165, 250, 0.2)" }}
> >
{requireLogin && (
<div className="text-sm mb-2">
{shareMode === "private" && (
<p>This transcript is private and can only be accessed by you.</p>
)}
{shareMode === "semi-private" && (
<p>
This transcript is secure. Only authenticated users can access it.
</p>
)}
{shareMode === "public" && (
<p>This transcript is public. Everyone can access it.</p>
)}
{isOwner && api && (
<div className="relative">
<SelectSearch
className="select-search--top select-search"
options={[
{ name: "Private", value: "private" },
{ name: "Secure", value: "semi-private" },
{ name: "Public", value: "public" },
]}
value={shareMode}
onChange={updateShareMode}
closeOnSelect={true}
/>
{shareLoading && (
<div className="h-4 w-4 absolute top-1/3 right-3 z-10">
<FontAwesomeIcon
icon={faSpinner}
className="animate-spin-slow text-gray-600 flex-grow rounded-lg md:rounded-xl h-4 w-4"
/>
</div>
)}
</div>
)}
</div>
)}
{!requireLogin && (
<>
{privacyEnabled ? ( {privacyEnabled ? (
<p className="text-sm mb-2"> <p className="text-sm mb-2">
You can share this link with others. Anyone with the link will have Share this link to grant others access to this page. The link
access to the page, including the full audio recording, for the next 7 includes the full audio recording and is valid for the next 7
days. days.
</p> </p>
) : ( ) : (
<p className="text-sm mb-2"> <p className="text-sm mb-2">
You can share this link with others. Anyone with the link will have Share this link to allow others to view this page and listen to
access to the page, including the full audio recording. the full audio recording.
</p> </p>
)} )}
</>
)}
<div className="flex items-center"> <div className="flex items-center">
<input <input
type="text" type="text"

View File

@@ -2,7 +2,6 @@ import { useState } from "react";
import getApi from "../../lib/getApi"; import getApi from "../../lib/getApi";
type TranscriptTitle = { type TranscriptTitle = {
protectedPath: boolean;
title: string; title: string;
transcriptId: string; transcriptId: string;
}; };
@@ -11,7 +10,7 @@ const TranscriptTitle = (props: TranscriptTitle) => {
const [displayedTitle, setDisplayedTitle] = useState(props.title); const [displayedTitle, setDisplayedTitle] = useState(props.title);
const [preEditTitle, setPreEditTitle] = useState(props.title); const [preEditTitle, setPreEditTitle] = useState(props.title);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const api = getApi(props.protectedPath); const api = getApi();
const updateTitle = async (newTitle: string, transcriptId: string) => { const updateTitle = async (newTitle: string, transcriptId: string) => {
if (!api) return; if (!api) return;

View File

@@ -13,29 +13,33 @@ const useMp3 = (id: string, waiting?: boolean): Mp3Response => {
const [media, setMedia] = useState<HTMLMediaElement | null>(null); const [media, setMedia] = useState<HTMLMediaElement | null>(null);
const [later, setLater] = useState(waiting); const [later, setLater] = useState(waiting);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const api = getApi(true); const api = getApi();
const { api_url } = useContext(DomainContext); const { api_url } = useContext(DomainContext);
const accessTokenInfo = useFiefAccessTokenInfo(); const accessTokenInfo = useFiefAccessTokenInfo();
const [serviceWorkerReady, setServiceWorkerReady] = useState(false); const [serviceWorker, setServiceWorker] =
useState<ServiceWorkerRegistration | null>(null);
useEffect(() => { useEffect(() => {
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js").then(() => { navigator.serviceWorker.register("/service-worker.js").then((worker) => {
setServiceWorkerReady(true); setServiceWorker(worker);
}); });
} }
return () => {
serviceWorker?.unregister();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!navigator.serviceWorker) return; if (!navigator.serviceWorker) return;
if (!navigator.serviceWorker.controller) return; if (!navigator.serviceWorker.controller) return;
if (!serviceWorkerReady) return; if (!serviceWorker) return;
// Send the token to the service worker // Send the token to the service worker
navigator.serviceWorker.controller.postMessage({ navigator.serviceWorker.controller.postMessage({
type: "SET_AUTH_TOKEN", type: "SET_AUTH_TOKEN",
token: accessTokenInfo?.access_token, token: accessTokenInfo?.access_token,
}); });
}, [navigator.serviceWorker, serviceWorkerReady, accessTokenInfo]); }, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
useEffect(() => { useEffect(() => {
if (!id || !api || later) return; if (!id || !api || later) return;

View File

@@ -14,12 +14,12 @@ type TranscriptTopics = {
error: Error | null; error: Error | null;
}; };
const useTopics = (protectedPath, id: string): TranscriptTopics => { const useTopics = (id: string): TranscriptTopics => {
const [topics, setTopics] = useState<Topic[] | null>(null); const [topics, setTopics] = useState<Topic[] | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setErrorState] = useState<Error | null>(null); const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError(); const { setError } = useError();
const api = getApi(protectedPath); const api = getApi();
useEffect(() => { useEffect(() => {
if (!id || !api) return; if (!id || !api) return;

View File

@@ -24,14 +24,13 @@ type SuccessTranscript = {
}; };
const useTranscript = ( const useTranscript = (
protectedPath: boolean,
id: string | null, id: string | null,
): ErrorTranscript | LoadingTranscript | SuccessTranscript => { ): ErrorTranscript | LoadingTranscript | SuccessTranscript => {
const [response, setResponse] = useState<GetTranscript | null>(null); const [response, setResponse] = useState<GetTranscript | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null); const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError(); const { setError } = useError();
const api = getApi(protectedPath); const api = getApi();
useEffect(() => { useEffect(() => {
if (!id || !api) return; if (!id || !api) return;

View File

@@ -15,7 +15,7 @@ const useTranscriptList = (page: number): TranscriptList => {
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null); const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError(); const { setError } = useError();
const api = getApi(true); const api = getApi();
useEffect(() => { useEffect(() => {
if (!api) return; if (!api) return;

View File

@@ -1,8 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { V1TranscriptGetAudioWaveformRequest } from "../../api/apis/DefaultApi";
DefaultApi,
V1TranscriptGetAudioWaveformRequest,
} from "../../api/apis/DefaultApi";
import { AudioWaveform } from "../../api"; import { AudioWaveform } from "../../api";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import getApi from "../../lib/getApi"; import getApi from "../../lib/getApi";
@@ -14,12 +11,12 @@ type AudioWaveFormResponse = {
error: Error | null; error: Error | null;
}; };
const useWaveform = (protectedPath, id: string): AudioWaveFormResponse => { const useWaveform = (id: string): AudioWaveFormResponse => {
const [waveform, setWaveform] = useState<AudioWaveform | null>(null); const [waveform, setWaveform] = useState<AudioWaveform | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null); const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError(); const { setError } = useError();
const api = getApi(protectedPath); const api = getApi();
useEffect(() => { useEffect(() => {
if (!id || !api) return; if (!id || !api) return;

View File

@@ -10,11 +10,10 @@ import getApi from "../../lib/getApi";
const useWebRTC = ( const useWebRTC = (
stream: MediaStream | null, stream: MediaStream | null,
transcriptId: string | null, transcriptId: string | null,
protectedPath,
): Peer => { ): Peer => {
const [peer, setPeer] = useState<Peer | null>(null); const [peer, setPeer] = useState<Peer | null>(null);
const { setError } = useError(); const { setError } = useError();
const api = getApi(protectedPath); const api = getApi();
useEffect(() => { useEffect(() => {
if (!stream || !transcriptId) { if (!stream || !transcriptId) {

View File

@@ -402,6 +402,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
console.debug("WebSocket connection closed"); console.debug("WebSocket connection closed");
switch (event.code) { switch (event.code) {
case 1000: // Normal Closure: case 1000: // Normal Closure:
case 1005: // Closure by client FF
default: default:
setError( setError(
new Error(`WebSocket closed unexpectedly with code: ${event.code}`), new Error(`WebSocket closed unexpectedly with code: ${event.code}`),

View File

@@ -5,7 +5,7 @@ export default () => (
<div className="flex flex-grow items-center justify-center h-20"> <div className="flex flex-grow items-center justify-center h-20">
<FontAwesomeIcon <FontAwesomeIcon
icon={faSpinner} icon={faSpinner}
className="animate-spin-slow text-gray-600 flex-grow rounded-lg md:rounded-xl h-10" className="animate-spin-slow text-gray-600 flex-grow rounded-lg md:rounded-xl h-10 w-10"
/> />
</div> </div>
); );

View File

@@ -25,6 +25,12 @@ export interface GetTranscript {
* @memberof GetTranscript * @memberof GetTranscript
*/ */
id: any | null; id: any | null;
/**
*
* @type {any}
* @memberof GetTranscript
*/
userId: any | null;
/** /**
* *
* @type {any} * @type {any}
@@ -73,6 +79,12 @@ export interface GetTranscript {
* @memberof GetTranscript * @memberof GetTranscript
*/ */
createdAt: any | null; createdAt: any | null;
/**
*
* @type {any}
* @memberof GetTranscript
*/
shareMode?: any | null;
/** /**
* *
* @type {any} * @type {any}
@@ -93,6 +105,7 @@ export interface GetTranscript {
export function instanceOfGetTranscript(value: object): boolean { export function instanceOfGetTranscript(value: object): boolean {
let isInstance = true; let isInstance = true;
isInstance = isInstance && "id" in value; isInstance = isInstance && "id" in value;
isInstance = isInstance && "userId" in value;
isInstance = isInstance && "name" in value; isInstance = isInstance && "name" in value;
isInstance = isInstance && "status" in value; isInstance = isInstance && "status" in value;
isInstance = isInstance && "locked" in value; isInstance = isInstance && "locked" in value;
@@ -120,6 +133,7 @@ export function GetTranscriptFromJSONTyped(
} }
return { return {
id: json["id"], id: json["id"],
userId: json["user_id"],
name: json["name"], name: json["name"],
status: json["status"], status: json["status"],
locked: json["locked"], locked: json["locked"],
@@ -128,6 +142,7 @@ export function GetTranscriptFromJSONTyped(
shortSummary: json["short_summary"], shortSummary: json["short_summary"],
longSummary: json["long_summary"], longSummary: json["long_summary"],
createdAt: json["created_at"], createdAt: json["created_at"],
shareMode: !exists(json, "share_mode") ? undefined : json["share_mode"],
sourceLanguage: json["source_language"], sourceLanguage: json["source_language"],
targetLanguage: json["target_language"], targetLanguage: json["target_language"],
}; };
@@ -142,6 +157,7 @@ export function GetTranscriptToJSON(value?: GetTranscript | null): any {
} }
return { return {
id: value.id, id: value.id,
user_id: value.userId,
name: value.name, name: value.name,
status: value.status, status: value.status,
locked: value.locked, locked: value.locked,
@@ -150,6 +166,7 @@ export function GetTranscriptToJSON(value?: GetTranscript | null): any {
short_summary: value.shortSummary, short_summary: value.shortSummary,
long_summary: value.longSummary, long_summary: value.longSummary,
created_at: value.createdAt, created_at: value.createdAt,
share_mode: value.shareMode,
source_language: value.sourceLanguage, source_language: value.sourceLanguage,
target_language: value.targetLanguage, target_language: value.targetLanguage,
}; };

View File

@@ -49,6 +49,12 @@ export interface UpdateTranscript {
* @memberof UpdateTranscript * @memberof UpdateTranscript
*/ */
longSummary?: any | null; longSummary?: any | null;
/**
*
* @type {any}
* @memberof UpdateTranscript
*/
shareMode?: any | null;
} }
/** /**
@@ -81,6 +87,7 @@ export function UpdateTranscriptFromJSONTyped(
longSummary: !exists(json, "long_summary") longSummary: !exists(json, "long_summary")
? undefined ? undefined
: json["long_summary"], : json["long_summary"],
shareMode: !exists(json, "share_mode") ? undefined : json["share_mode"],
}; };
} }
@@ -97,5 +104,6 @@ export function UpdateTranscriptToJSON(value?: UpdateTranscript | null): any {
title: value.title, title: value.title,
short_summary: value.shortSummary, short_summary: value.shortSummary,
long_summary: value.longSummary, long_summary: value.longSummary,
share_mode: value.shareMode,
}; };
} }

View File

@@ -3,9 +3,9 @@ import { isDevelopment } from "./utils";
const localConfig = { const localConfig = {
features: { features: {
requireLogin: false, requireLogin: true,
privacy: true, privacy: true,
browse: false, browse: true,
}, },
api_url: "http://127.0.0.1:1250", api_url: "http://127.0.0.1:1250",
websocket_url: "ws://127.0.0.1:1250", websocket_url: "ws://127.0.0.1:1250",

View File

@@ -1,5 +1,8 @@
function shouldShowError(error: Error | null | undefined) { function shouldShowError(error: Error | null | undefined) {
if (error?.name == "ResponseError" && error["response"].status == 404) if (
error?.name == "ResponseError" &&
(error["response"].status == 404 || error["response"].status == 403)
)
return false; return false;
if (error?.name == "FetchError") return false; if (error?.name == "FetchError") return false;
return true; return true;

View File

@@ -66,10 +66,6 @@ export const getFiefAuthMiddleware = async (url) => {
matcher: "/transcripts", matcher: "/transcripts",
parameters: {}, parameters: {},
}, },
{
matcher: "/transcripts/((?!new).*)",
parameters: {},
},
{ {
matcher: "/browse", matcher: "/browse",
parameters: {}, parameters: {},

View File

@@ -4,17 +4,19 @@ import { DefaultApi } from "../api/apis/DefaultApi";
import { useFiefAccessTokenInfo } from "@fief/fief/nextjs/react"; import { useFiefAccessTokenInfo } from "@fief/fief/nextjs/react";
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { DomainContext, featureEnabled } from "../[domain]/domainContext"; import { DomainContext, featureEnabled } from "../[domain]/domainContext";
import { CookieContext } from "../(auth)/fiefWrapper";
export default function getApi(protectedPath: boolean): DefaultApi | undefined { export default function getApi(): DefaultApi | undefined {
const accessTokenInfo = useFiefAccessTokenInfo(); const accessTokenInfo = useFiefAccessTokenInfo();
const api_url = useContext(DomainContext).api_url; const api_url = useContext(DomainContext).api_url;
const requireLogin = featureEnabled("requireLogin"); const requireLogin = featureEnabled("requireLogin");
const [api, setApi] = useState<DefaultApi>(); const [api, setApi] = useState<DefaultApi>();
const { hasAuthCookie } = useContext(CookieContext);
if (!api_url) throw new Error("no API URL"); if (!api_url) throw new Error("no API URL");
useEffect(() => { useEffect(() => {
if (protectedPath && requireLogin && !accessTokenInfo) { if (hasAuthCookie && requireLogin && !accessTokenInfo) {
return; return;
} }
@@ -25,7 +27,7 @@ export default function getApi(protectedPath: boolean): DefaultApi | undefined {
: undefined, : undefined,
}); });
setApi(new DefaultApi(apiConfiguration)); setApi(new DefaultApi(apiConfiguration));
}, [!accessTokenInfo, protectedPath]); }, [!accessTokenInfo, hasAuthCookie]);
return api; return api;
} }

View File

@@ -35,3 +35,8 @@ body.is-light-mode .input-container {
max-width: 100%; max-width: 100%;
width: auto; width: auto;
} }
body .select-search-container .select-search--top.select-search-select {
top: auto;
bottom: 46px;
}