Compare commits

...

103 Commits

Author SHA1 Message Date
lebaudantoine e7dc54d6c5 🔖(minor) bump release to 0.1.3 2024-08-05 23:01:26 +02:00
lebaudantoine 9a07fba991 💄(frontend) center the feedback screen text
Align the screen with the mockup, and the recent added error screen.
2024-08-05 22:28:45 +02:00
lebaudantoine ca3b1f0297 (frontend) center error screen body
Added a new props on Text primitive to allow centering
the text if needed. It's align the design with the mockup.
2024-08-05 22:28:45 +02:00
lebaudantoine 01390b12fb (frontend) allow customization of error title and description
Enhanced the error screen to support customizable titles and descriptions.

Allows for more detailed and informative error messages for users.
By default, the error screen displays a standard heading. You can
now pass custom title and body content to provide additional context
and clarity.
2024-08-05 22:28:45 +02:00
lebaudantoine df1eca7c34 (frontend) support get or create room while accessing a room
Attempt to create a room using a mutation if the fetch query fails.
If the mutation succeeds, update the fetch query data to ensure
it is up-to-date.

I tried to reproduce kind of a ‘get or create’ mechanism.

Due to the removal of the 'onError' prop in React Query, it is recommended
to use useEffect for handling such event propagation.

Regarding status handling, the fetch status takes priority over the mutation.
The mutation should only be called if the fetch has failed.
2024-08-05 22:28:45 +02:00
lebaudantoine b529e9c848 ♻️(frontend) extract query key in a variable
I'll need this key to sync data when the mutation responds successfully.
This is a preliminary refactoring; the mutation will be added
in upcoming commits.
2024-08-05 22:28:45 +02:00
lebaudantoine 23a2d3bcac ♻️(frontend) support mutation status in QueryAware component
useMutation has one more possible status, 'idle'. I was forced
to add it to avoid any Typescript error.
2024-08-05 22:28:45 +02:00
lebaudantoine 55749a9565 ♻️(frontend) extract queryClient definition
It will be necessary to access the client in other components for manually
discarding or setting data in the central data store.
2024-08-05 22:28:45 +02:00
lebaudantoine 8115a39538 🐛(frontend) make link in the chat clickable
This is based on LiveKit demo app.
Used the LiveKit default chat messages formatter to make the link
clickable by any user.
2024-08-05 22:28:45 +02:00
lebaudantoine 2416ca1127 🔥(frontend) remove default username
Backend defaults username to the user's email, if the frontend
haven't pass an username value.

Remove this useless check.
2024-08-05 22:28:45 +02:00
lebaudantoine 1971f594cf 🔇(frontend) remove LiveKit logs in production
Enabling logs by default in clients during production feels insecure.
Therefore, I have silenced all LiveKit logs.

This might pose a challenge if we need to debug a user in the future,
but currently, we don’t require these logs.

Additionally, my utils file and function work as intended,
though there is room for improvement.
2024-08-05 22:28:45 +02:00
lebaudantoine fb0ee9e8f6 (frontend) create room in db when creating a new room
With unregistered rooms being now forbidden, we need to call create a room
in our Django db to get an access token.

Clicking on the 'Create room' will create a new entry using a Post request.
The output serialized already contains an access token to the LiveKit server,
thus we won't need to run the useQuery hook when navigating to the room.

/!\ this modification now prevent authenticated users to create rooms
by simply navigating to it. It'll be fixed in the upcoming commits.
2024-08-05 22:28:45 +02:00
lebaudantoine b261f2ee5b 🛂(backend) disallow unregistered rooms
Require users to create a room in the database
before requesting a LiveKit token.

If user request an access token for a room that doesn't
exist in our db, its request would end in a 404 error.

Ensure that rooms must be registered by a user before they can be accessed.
By default, all created rooms remain public, allowing anonymous users to join
any room created by a logged-in user.

However, anonymous users cannot create rooms themselves.
2024-08-05 22:28:45 +02:00
lebaudantoine aa54075e6b 📈(helm) add separate namespaces for each environment
I have set up distinct namespaces for each environment. You can now push
events to the development namespace without affecting production data.

Please note that these keys are not 'secret'. They will also be configured
in the browser SDK, which is inherently insecure. The documentation does not
specify a secure storage method for these keys.
2024-08-05 17:30:12 +02:00
lebaudantoine 271b598cee 📈(backend) introduce analytics
In this commit, we'll integrate a third-party service to track user events.
We start by using the `identify` method to track sign-ins and sign-ups.

Additionally, we use the `track` method to monitor custom events such as room
creation, access token generation, and logouts. This will provide us with
valuable data on current usage patterns.

The analytics library operates by opening a queue in a separate thread for
posting events, ensuring it remains non-blocking for the API. Let's test
this in a real-world scenario.
2024-08-05 17:30:12 +02:00
lebaudantoine fc232759fb (backend) support email anonymization on user
Add a new property 'email_anonymized' to the User model,
to allow tracking a user's email without any personal data.

In fact, we're dealing with professional data, thus it shouldn't
be subject to the GDPR, however I prefer taking extra care
when working with potentially first and last names.
2024-08-05 17:30:12 +02:00
lebaudantoine a992aa8898 (backend) add analytics dependency
I've chosen June, a closed project, for our product analysis. Please note that
this is temporary until we find our product-market fit and achieve
significant traction.

I selected June for several reasons, particularly their focus on pre-product-
market fit (PMF) analysis, which is crucial for us. Their approach will help us
track user engagement and identify the most important features.

Remember, the purpose of this data is not to provide definitive answers about
our product, but to prompt us to ask the right questions and engage with users
to find the answers.
2024-08-05 17:30:12 +02:00
renovate[bot] 1b8b91a44d ⬆️(dependencies) update python dependencies 2024-08-05 10:01:15 +02:00
lebaudantoine 63c6f5a8a1 🐛(frontend) add unique key to 'popover' items
Close issue #91. Each <li> element in a list should have a unique key
to avoid React warnings.

Added unique keys to 'popover' items. Although the 'lang' keys are generated
by i18n dependencies and are expected to be unique, this implementation
ensures keys are unique and not based on indexes.
2024-08-04 23:59:41 +02:00
lebaudantoine 1970a4d6b1 ✏️(frontend) fix minor typo in i18n hook
Oupsi, found a typo and fixed it.
2024-08-04 23:59:41 +02:00
lebaudantoine d406f31bd8 🔧(backend) fix Pylint configurations
Removing the __init__.py makes it impossible for Pylint to get the sources
to lint from the root folder. We manually set all the paths pylint will lint.

That's not a big deal, as we'll remove Pylint any soon to rely only on ruff.
I took inspiration from marsha or magnify project.

I removed the now useless bash script to run Pylint command. It saves us
wrapper! Plus, having a lint command running with different option locally
and in the CI was quite a pain.

Locally linter was running on diff files; Fixed! CI and make command has now
the same behavior.
2024-07-31 13:12:30 +02:00
lebaudantoine 4a011024dd 🚨(backend) fix Ruff warnings
Ruff is configured in the pyproject.toml file.
Its configuration was deprecated. It triggered warning in the console.
Updated the pyproject.toml file.
2024-07-31 13:12:30 +02:00
lebaudantoine 59b23ad1b9 ⚰️(backend) remove unused setup file
We are now using pyproject.toml, cleaned legacy files that weren't removed
when bootstraping the project.
2024-07-31 13:12:30 +02:00
lebaudantoine 93be2881d2 (backend) fix tests broken by dependencies updates
Recent updates in dependencies broke the tests.
I am in a rush, I found a stack overflow discussion mentionning we should
NOT consider the root folder of a Django project as a Python package.

My issue was:
Model class app.core.models.User doesn't declare an explicit app_label
and isn't in an application in INSTALLED_APPS.

Removing the __ini__.py file at the root folder fixed the regression.
2024-07-31 13:12:30 +02:00
lebaudantoine daa125edf3 🚨(backend) fix linter warnings
Recent updates of dev/ruff and dev/pylint dependencies led
to new linting warnings.

Pylint 3.2.0 introduced a new check `possibly-used-before-assignment`,
which ensures variables are defined regardless of conditional statements.

Some if/else branches were missing defaults. These have been fixed.
2024-07-31 13:12:30 +02:00
renovate[bot] c93e770704 ⬆️(dependencies) update python dependencies 2024-07-31 13:12:30 +02:00
Emmanuel Pelletier 1f57adc4da ♻️(frontend) rework how we handle screens and layout code
Previously each route rendered its whole layout from a to z.
Now each route updates a single common wrapper when the layout changes
between pages.

Also the Loading, Error, UserFetched, QueryAware code is more explicit
and understandable.

This introduces valtio as dependency, allowing us to deal with global
state easily in a svelte/vue way (reactive mutable state). This limits
the boilerplaty-ness of immutable state lib approaches, while keeping
rendering optimization better than homemade react contexts.
2024-07-30 16:26:24 +02:00
Emmanuel Pelletier 952e6970f0 🐛(room) do not show feedback info when reloading room page
the livekit prefab component doesn't expose what we want, rely on the
dom for now until we redo the component from scratch
2024-07-30 01:47:03 +02:00
Emmanuel Pelletier af0746eac1 ️(frontend) dont endlessly query user info
we query user at load and keep info for a while, way less than user
session anyway. will need to check if we could get de-sync (like loading
the frontend with backend user session ending in 30 minutes while we
don't check user state until an hour)
2024-07-30 01:37:20 +02:00
Emmanuel Pelletier 62492d1411 🔖(minor) bump release to 0.1.2 2024-07-29 10:19:58 +02:00
Emmanuel Pelletier 34e21b77b5 🩹(loading) fix header doubled when loading room
the whole screen wrapper thing is not that great… needs improvements to
prevent that kind of stuff
2024-07-29 10:11:31 +02:00
Emmanuel Pelletier 9edb9c2f57 💄(button) center button texts
this is definitely an oversight, button texts should be centered
2024-07-29 08:48:44 +02:00
Emmanuel Pelletier ee1abbed04 🚸(feedback) remember the user about feedback form on call end
add a new route that just tells the user about the feedback form
2024-07-29 08:48:44 +02:00
Emmanuel Pelletier ed52123733 💄(header) better feedback integration
- small screens are now usable (not that great but better than before)
- spacing is more consistent between left and right
2024-07-29 08:48:44 +02:00
Emmanuel Pelletier a7739efb70 🚸(feedback) better wording
"on Meet" was misleading, it was like, "give us feedback by going on
'meet'. Let's be straightforward
2024-07-29 08:48:44 +02:00
Emmanuel Pelletier 72ef3c8bb0 🎨(locales) fix locale strings order
we should definitely have something that crashes on CI if running
i18n:extract makes a diff… oh well
2024-07-29 08:48:44 +02:00
Emmanuel Pelletier e9ef2bc4ae 🐛(home) fix invite form that didn't work with actual meeting urls
it worked with codes but not full urls!
2024-07-29 08:16:50 +02:00
Emmanuel Pelletier 8587574fcd (rooms) show an invite dialog when creating a room
for now the dialog appears as a regular dialog with an overlay and all,
would be better as less intrusive. but good as is as a first step
2024-07-29 08:16:50 +02:00
Emmanuel Pelletier c3617fc005 🚸(home) skip the join screen when creating a room
at the cost of a not-changeable username, logged in users who create
rooms now skip the join page. I think it's good to have this to test
this way, even if the username choice is not there yet.
2024-07-29 08:16:50 +02:00
Emmanuel Pelletier 8f81318ecf ♻️(frontend) trying out cleaner way to handle routes
it was a bit cumbersome to navigate by paths accross the app as if one
path changes a tiny bit we have to make sure we updated everything
everywhere and it's kind of error-prone and cumbersome to build paths by
hand.

now we have a routes file that describes everything: what is the path to
each route, and how to navigate to them.

We use a new navigateTo helper that helps us. It could be better typed
but i'm a newbie.
2024-07-29 08:16:50 +02:00
Emmanuel Pelletier afd2f9a299 (frontend) new feedback link in header
this is just a link to an external url that is, for now, hardcoded as
our deploys dont have frontend env vars right now
2024-07-28 02:49:14 +02:00
lebaudantoine fff5740b14 (backend) fix test broken by dependencies update
Django Rest Framework (DRF) recent updates broke one unit test,
that was checking for a default error message returned by DRF.
Updated the message.
2024-07-25 23:56:59 +02:00
renovate[bot] 7545f94bbd ⬆️(dependencies) update django to v5.0.7 [SECURITY] 2024-07-25 23:06:41 +02:00
renovate[bot] edb90327e5 ⬆️(dependencies) update requests to v2.32.2 [SECURITY] 2024-07-25 22:34:45 +02:00
Emmanuel Pelletier fdff5092b0 🐛(sso) prevent flash when trying to autologin
the issue was we return the fetchUser promise to react query too early
(before the browser reloaded the page). now we make sure react query
doesn't get the info
2024-07-25 22:34:18 +02:00
lebaudantoine 234116163a (frontend) try silent login every one hour
This commit is totally work in progress.
@manuhabitela the floor is yours.

If the user is logged-out, try silently log-in her every hour,
to avoid any manual login on her side.

The one hour value has been discussed with @manuhabitela, but there
is no real logic behind it. Could definitely be shorter or longer.

It needs at least to be longer than the average silent login flow
to avoid locking the user-agent in an infinite redirection loop.
2024-07-25 22:34:18 +02:00
lebaudantoine 2a8027deb0 (frontend) support 'silent' and 'returnTo' params in authUrl
Enhanced the authentication URL generation to support 'silent'
and 'returnTo' query parameters.

This allows initiating a silent login and specifying a custom
return URL after auth.
2024-07-25 22:34:18 +02:00
lebaudantoine d167490c09 (backend) support silent login
Silent login attempts to re-authenticate the user without interaction,
provided they have an active session, improving UX by reducing manual auth.

It's an essential feature to really feel the SSO in La Suite.

A new query parameter, 'silent', allows the client to initiate a silent login.
In this flow, an extra parameter, 'prompt=none', is passed to the OIDC provider.

The requested flow is persisted in session data to adapt the authentication
callback behavior.

In a silent login flow, an authentication failure should not be considered as a
real failure. Instead, users should be redirected back to the originating view.
A silent login fails when user has no active session.

Why return the 'success_url'? The 'success_url' will redirect the user agent to
the 'returnTo' parameter provided when requesting authentication.
It's necessary to enable a silent login on any URL.

Minimal test coverage has been added for these two custom views to ensure
correct behavior.
2024-07-25 22:34:18 +02:00
lebaudantoine e7ea700c3d (backend) handle custom redirect URL while login
mozilla-django-oidc now supports a 'returnTo' parameter for redirecting
to a specificURL upon successful login. if not provided,
it defaults to the settings-defined URL.

This allows initiating the login flow from any views,
enhancing UX by returning users to their previous page.

The 'returnTo' naming can be discussed.
2024-07-25 22:34:18 +02:00
lebaudantoine edf19c8f1e 🩹(backend) update pyproject description
Woopsi. I forgot to update the package description while bootstrapping
the project. Fixed!
2024-07-25 22:34:18 +02:00
lebaudantoine e6feed2086 📝(tilt) document running the application with Tilt
Based on @rouja instructions, try to document the Tilt stack,
to enhance the DX of any newcomers, discovering Meet and trying
to run it on K8s.

Having a shared/common onboarding documentation on Tilt with
Impress, Regie, and Meet would be amazing.

Especially, to document how to install Tilt and its dependencies.

Important: The frontend is not deployed locally using the production
target, and I feel important to document it.
2024-07-25 18:56:40 +02:00
lebaudantoine dbd9ac6eea 🩹(frontend) fix ignored pre-join options
Closes #50.

Adopted LiveKit demo app approach for passing
configurations to LiveKitRoom.

Introduced roomOptions for future customizations (e.g.,
video quality, e2e, codec).

DeviceId is persisted in local storage despite boolean flag.
2024-07-25 18:54:19 +02:00
lebaudantoine 86b03a3d47 ⚰️(backend) remove unused cold storage configurations
Minio was removed from our stack, because it wasn't used.
Cleaned up some environment variables.
2024-07-25 18:24:37 +02:00
renovate[bot] 2c2b4edc84 ⬆️(dependencies) update djangorestframework to v3.15.2 [SECURITY] 2024-07-25 18:23:56 +02:00
renovate[bot] 4be2d16483 ⬆️(dependencies) update sentry-sdk to v2 [SECURITY] 2024-07-25 18:23:01 +02:00
lebaudantoine ccd0cb4641 ⬆️(ci) update setup-python actions
setup-python@v3 uses a soon-deprecated Node version.
Updated them to the most recent version.
2024-07-25 18:06:50 +02:00
lebaudantoine 561ea346db ⬆️(ci) update checkout actions
checkout@v2 uses node12 which will be deprecated soon.
I've aligned CI configurations to use a more recent action,
already in-use in the 'meet.yml' flow.
2024-07-25 18:06:50 +02:00
lebaudantoine 13bd195b22 🔥(tsclient) remove tsclient sources
It won't be any useful in the short term, and was broken.
If needed, we would add it back to the stack.
(Opinionated choice, feel free to discuss it)
2024-07-25 16:46:27 +02:00
Emmanuel Pelletier a35a4ffbec 🚸(room) prevent user from misclicking out of a meeting
now clicking on the header homepage link asks for confirmation when on
the route page.

this is quick and dirty, using browser confirm ui, and not making a
difference between join page and conference page, but it'll do for now
2024-07-25 15:05:06 +02:00
Emmanuel Pelletier 668523aa8b 🚸(frontend) follow antoine's remarks on homepage and header
- show the header on homepage. Not sure we want any header on this app
actually but I guess he's right since we have one it feels more
consistent to have it everywhere
- show logged in email in header. ditched it because i didn't quite get
the value of showing it all the time in this app but i guess it's better
than nothing
- remove user info from settings. Since they are back in the header, no
need
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier d15fb0a19b 💄(frontend) fix loading screen not visually centered
the loading word was sticked to top left without any padding
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier d6502f0739 🌐(home) fix obsolete locale string
examples are handled in the code instead of needing translations
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 504b851731 💄(header) just show the settings button in the header
for now! i wanna ditch the header anyway when we rework the conference
view
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier cb6a81715a 💄(boxes) screen boxes are not at all boxes now
this is meant to be updated soon, it's not ideal at all. This Screen/Box
stuff is really meh anyway, needs some rework.
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 47c133cc64 ♻️(dialogs) make it easier to have dialogs unrelated to their trigger
- use the default react-aria DialogTrigger when we want to build buttons
triggering dialogs
- use custom Dialog component as a wrapper to Dialog content
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier c0d490f549 (frontend) new Tooltip component
buttons can now easily have tooltip via a new `tooltip` attribute that
generates a Tooltip linked to the button
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 41ad15e20b (frontend) new settings dialog to handle user account/language
add a settings button directly in the homepage to change language or see
user account settings
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 0707ac2cd4 ♻️(dialogs) new DialogContent component to ease up code splitting
this better permits us to have a Dialog content component somewhere else
than its trigger button. Mainly did this so that the dialog title is
localized with its content.
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 5ce63d937d ♻️(css) remove '_ra-*' helpers from panda
they are not really helpful, i'd rather stick to the react-aria wording,
easier to understand when looking at react aria examples, converting
code, etc. Not a great value adding this api in our tiny heads
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 50791c945d ♻️(home) move the join meeting dialog in its own component
feels less cluttered that way
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 1fb37f2f8e 💄(keyboard) tinker (again) with the global focus styles
- setting an invisible outline by default for better transitions
- excluding the select containers from keyboard focus rings
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier eab64b7197 💄(animations) add animations to smooth out general feeling
the app is your friend now, it's all smooth
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 786cd3e4c7 (forms) add a select field
via the <Field> component you can now describe a select input that
matches other field apis
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier c8f96b1f22 (frontend) new Form components
- improve the Form component to abstract the few things we'll certainly
do all the time (data parsing, action buttons rendering)
- add a Field component, the main way to render form fields. It mainly
wraps react aria components with our styling. The Checkbox component is
a bit tricky to go around some current limitations with react aria
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier df5f0dbf9f 💄(keyboard) improve the global focus ring selector
this triggered on a few things we didn't want (labels particularly).
plus, handle the focus ring color via panda, so that it's available in
the JS (will be useful in soon to be commited stuff)
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier dc67d7c190 🌐(frontend) replace all "conférence" by "réunion"
feels like we better understand with this term
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 82208c220a ♻️(frontend) tiny cleanup of the room regex related stuff
since this is used for the routing *and* for common validation, split a
few things
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 2e081e5e1e 🚸(frontend) prevent flash of "logged out" user content when loading
until now, we concluded that is `isLoggedIn` !== true meant the user
wasn't logged in. While it also meant that we are currently loading user
info.

wrap the whole in something that doesn't render anything until we made
the first user request to prevent this behavior.
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 57b8a15642 💄(home) have a cleaner homepage layout
- try a new layout for the homepage, making it easier to join a meeting
- add a new Dialog component to easily build dialogs
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier b03bfe94a4 💚(gitlint) ignore missing body on release commits
release commits dont need any body info as the changelog ~~is~~ will be
updated accordingly to track changes of the release
2024-07-22 16:33:45 +02:00
Emmanuel Pelletier a2774eb888 📝(release) improve release doc after first release
explained a few things for the newbies like me, added a deployment
example, simplified the process while we are in "mvp" mode
2024-07-22 16:33:45 +02:00
Emmanuel Pelletier 195e701fc4 🔖(minor) bump release to 0.1.1 2024-07-22 15:57:57 +02:00
lebaudantoine 88a5717022 ♻️(backend) simplify queryset while listing rooms
Recent refactoring simplified the DB models.
Thus, filtering rooms is now way simpler,
I updated the subsequent queryset.
2024-07-22 14:15:49 +02:00
lebaudantoine e17d42ebe3 🔥(backend) remove todo items
The Pylint job was failing due to those TODO items. In our make lint
command sequence, Pylint runs first. If it fails, Ruff won't run,
which is quite inconvenient.

I've extracted those TODOs into an issue for further review.
2024-07-22 14:15:49 +02:00
lebaudantoine ae95a00301 (backend) add 'username' query param when retrieving a room
Quick and dirty approach. It works, that's essential.
Frontend can pass a desired username for the user. This would
be the name displayed in the room to other participants.
Usernames don't need to be unique, but user identities do

If no username is passed, API will fall back to a default username.
Why? This serves as a security mechanism. If the API is called
incorrectly by a client, it maintains the previous behavior.
2024-07-22 14:15:49 +02:00
Emmanuel Pelletier faff1c1228 ♻️(frontend) make the Conference component not know about routing
Makes more sense that way: only the Room _route_ knows about route
params, not the internal component used.
2024-07-21 17:42:53 +02:00
Emmanuel Pelletier e04b8d081f 🐛(frontend) have a suspense fallback
the app crashed on screen changes, I don't quite get why right now… but
at least this goes around the issue…
2024-07-21 17:39:08 +02:00
Emmanuel Pelletier efb5ac5834 🌐(frontend) sort extrated locales to help prevent conflicts
when adding keys by hand, we didn't really know where to add them so
that the i18n:extract command would not move them afterwards. Feels like
this will help.
I guess a CI thing checking if the locales file dont change after a push
would be helpful
2024-07-21 17:26:26 +02:00
Emmanuel Pelletier 0cf4960969 ♻️(rooms) room id gen: write more es6-like code
- this feels a bit less boilerplaty to read
- puting the characters whitelist outside the function to prevent
creating the var each time (yes, this of super great importance)
2024-07-21 17:18:29 +02:00
Emmanuel Pelletier f11bcea3a2 🔒️(frontend) valide ':roomId' path using a regex
Enhanced security by ensuring users are redirected to a 404 error page
if they
pass an incorrect roomId path, either intentionally or unintentionally.
This is
a critical security mechanism that should be included in our MVP.

Let's discuss extracting hardcoded elements, such as lengths or
the separator, into proper constants to improve code maintainability.
I was concerned that this might make the code harder to read, it could
enhance
clarity and reusability in the long term.

I prefer exposing the roomIdRegex from the same location where we
generate IDs.
However, this increases the responsibility of that file. Lmk if you have
any
suggestion for a better organization.

Additionally, the current 404 error page displays a 'Page not found'
message for
invalid room IDs. Should we update this message to 'Invalid room name'
to
provide more context to the user?
2024-07-21 17:18:29 +02:00
lebaudantoine d8c8ac0811 🚸(frontend) generate shorter room IDs making URLs easier to share
UUID-v4 room IDs are long and uninviting. Shorter, custom room IDs
can enhance UX by making URLs easier to share and remember.

While UUID-v4s are typically used in database systems for their low
collision probability, for ephemeral room IDs, the collision risk of e+14
combinations is acceptable.

This aligns room IDs with Google Meet format.

Even if the 'slugify' function is not used anymore, I kept it.
Lmk if you prefer removing it @manuhabitela
2024-07-21 17:18:29 +02:00
Emmanuel Pelletier 9fd1af2302 💄(keyboard) better handling of focus ring
some focus rings were shown even when we only used the mouse. this
globally fixes that
2024-07-21 16:48:27 +02:00
Emmanuel Pelletier e6c292ecd7 🌐(frontend) add a language selector in the header
this will be better in an options page later i think, as we don't pass
our life changing language and we already have a language detector at
load.

this adds a PopoverList primitive to easily create buttons triggering
popovers containing list of actionable items.
2024-07-21 16:48:27 +02:00
Emmanuel Pelletier d6b5e9a50c (frontend) add a Popover primitive
easily use buttons toggling styled RAC popovers
2024-07-21 16:48:27 +02:00
Emmanuel Pelletier 789bce5092 ♻️(frontend) put homepage in its own feature
makes more sense i guess, maybe
2024-07-20 20:23:57 +02:00
Emmanuel Pelletier f888fc1717 🌐(crowdin) make crowdin work with frontend translations
- upload local translation files on push
- make crowdin create a pull request when new translations are made
through the crowdin website (webhook configured on crowdin-end)
2024-07-20 20:23:57 +02:00
Emmanuel Pelletier 545877febb 💚(crowdin) update to latest secrets to fix CROWDIN_BASE_PATH issue
the base path is actually not a secret so we'd rather have it outside
secrets and see it easily
2024-07-20 20:23:57 +02:00
Emmanuel Pelletier d2dba511e2 🌐(frontend) init i18next
- dynamically load locale files for smaller footprint
- have a namespace for each feature. At first I'd figured I'd put each
namespace in its correct feature folder but it's kinda cumbersome to
manage if we want to link that to i18n management services like crowdin…
2024-07-20 20:23:57 +02:00
antoine lebaud 84c2986c01 ✏️(makefile) fix "crowin" typo
Fixed a typo and ensured all instances of "crowdin"
are capitalized for consistent naming.
2024-07-20 20:23:57 +02:00
antoine lebaud 44e5cd6ef3 💚(CI) fix crowdin steps
Updated CI to use "npm" instead of yarn for the frontend project based
on @manuhabitela's recommendations. Also updated the dependencies-related CI
steps that were previously missed.
2024-07-20 20:23:57 +02:00
Jacques ROUSSEL 7510d0fc2b 🔧(helm) configuration
Change configuration to use livekit-preprod.beta.numerique.gouv.fr
instead of the docker test vm
2024-07-19 15:35:55 +02:00
Jacques ROUSSEL f50426b11a 🔧(helm) fix helm chart
Fix helm secret to be abble to use titl on dev
2024-07-18 16:11:56 +02:00
lebaudantoine b604235c35 📝(release) document releasing new version
Heavily inspired from openfun handbook instructions,
https://handbook.openfun.fr/git

Document how release a new version. Might be a common
documentation shared with Impress, Regie and Meet projects.
Let's discuss it.

It's quite important that anyone should know how to release,
as we plan to release every week an enhanced version.
2024-07-18 16:03:19 +02:00
159 changed files with 5060 additions and 797 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Download Crowdin translations
on:
workflow_dispatch:
types: [file-fully-translated]
permissions:
contents: write
pull-requests: write
jobs:
crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download Crowdin files
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
localization_branch_name: l10n_crowdin_translations
create_pull_request: true
pull_request_title: "New Crowdin translations"
pull_request_body: "New Crowdin pull request with translations"
pull_request_base_branch_name: "main"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: ${{ github.workspace }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
@@ -72,7 +72,7 @@ jobs:
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
@@ -122,7 +122,7 @@ jobs:
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
+21 -22
View File
@@ -14,7 +14,7 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
@@ -77,9 +77,9 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install development dependencies
@@ -89,7 +89,7 @@ jobs:
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
run: ~/.local/bin/pylint meet demo core
test-back:
runs-on: ubuntu-latest
@@ -122,9 +122,6 @@ jobs:
DB_PASSWORD: pass
DB_PORT: 5432
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: impress
AWS_S3_SECRET_ACCESS_KEY: password
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
@@ -145,7 +142,7 @@ jobs:
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.10"
@@ -176,7 +173,7 @@ jobs:
repositories: "infrastructure,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
@@ -193,7 +190,7 @@ jobs:
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.10"
@@ -208,22 +205,24 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "18.x"
cache: "yarn"
cache-dependency-path: src/frontend/yarn.lock
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
run: cd src/frontend/ && npm ci
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: true
upload_translations: true
download_translations: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_BASE_PATH: ${{ github.workspace }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
-3
View File
@@ -50,9 +50,6 @@ node_modules
# Mails
src/backend/core/templates/mail/
# Typescript client
src/frontend/tsclient
# Swagger
**/swagger.json
+6 -1
View File
@@ -62,12 +62,17 @@ words=wip
# For example, use the following regex if you only want to allow email addresses from foo.com
# regex=[^@]+@foo.com
[ignore-by-title]
[ignore-by-title:bots]
# Allow empty body & wrong title pattern only when bots (pyup/greenkeeper)
# upgrade dependencies
regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$
ignore=B6,UC1
[ignore-by-title:releases]
# Allow empty body for release commits
regex=^🔖.*$
ignore=B6
# [ignore-by-body]
# Ignore certain rules for commits of which the body has a line that matches a regex
# E.g. Match bodies that have a line that that contain "release"
+11 -27
View File
@@ -49,7 +49,6 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn # FIXME : use npm
TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn # FIXME : use npm
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -142,7 +141,7 @@ lint-ruff-check: ## lint back-end python sources with ruff
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
@echo 'lint:pylint started…'
bin/pylint --diff-only=origin/main
@$(COMPOSE_RUN_APP) pylint meet demo core
.PHONY: lint-pylint
test: ## run project tests
@@ -217,7 +216,7 @@ env.d/development/kc_postgresql:
env.d/development/crowdin:
cp -n env.d/development/crowdin.dist env.d/development/crowdin
crowdin-download: ## Download translated message from crowdin
crowdin-download: ## Download translated message from Crowdin
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
.PHONY: crowdin-download
@@ -225,14 +224,17 @@ crowdin-download-sources: ## Download sources from Crowdin
@$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml
.PHONY: crowdin-download-sources
crowdin-upload: ## Upload source translations to crowdin
crowdin-upload: ## Upload source translations to Crowdin
@$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml
.PHONY: crowdin-upload
crowdin-upload-translations: ## Upload translations to Crowdin
@$(COMPOSE_RUN_CROWDIN) upload translations -c crowdin/config.yml
.PHONY: crowdin-upload-translations
i18n-compile: ## compile all translations
i18n-compile: \
back-i18n-compile \
frontend-i18n-compile
back-i18n-compile
.PHONY: i18n-compile
i18n-generate: ## create the .pot files and extract frontend messages
@@ -272,17 +274,6 @@ mails-install: ## install the mail generator
@$(MAIL_YARN) install
.PHONY: mails-install
# -- TS client generator
# FIXME : adapt this command
tsclient-install: ## Install the Typescript API client generator
@$(TSCLIENT_YARN) install
.PHONY: tsclient-install
# FIXME : adapt this command
tsclient: tsclient-install ## Generate a Typescript API client
@$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient
.PHONY: tsclient-install
# -- Misc
clean: ## restore repository state as it was freshly cloned
@@ -295,23 +286,16 @@ help:
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
# FIXME : adapt this command
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
frontend-i18n-extract: ## Check the frontend code and generate missing translations keys in translation files
cd $(PATH_FRONT) && npm run i18n:extract
.PHONY: frontend-i18n-extract
# FIXME : adapt this command
frontend-i18n-generate: ## Generate the frontend json files used for crowdin
frontend-i18n-generate: ## Generate the frontend json files used for Crowdin
frontend-i18n-generate: \
crowdin-download-sources \
frontend-i18n-extract
.PHONY: frontend-i18n-generate
# FIXME : adapt this command
frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
cd $(PATH_FRONT) && yarn i18n:deploy
.PHONY: frontend-i18n-compile
# -- K8S
build-k8s-cluster: ## build the kubernetes cluster using kind
./bin/start-kind.sh
+50
View File
@@ -94,6 +94,56 @@ You first need to create a superuser account:
$ make superuser
```
### Run application on local Kubernetes
The application is deployed across staging, preprod, and production environments using Kubernetes (K8s).
Reproducing environment conditions locally is crucial for developing new features or debugging issues.
This is facilitated by [Tilt](https://tilt.dev/) ("Kubernetes for Prod, Tilt for Dev"). Tilt enables smart rebuilds and live updates for services running locally in Kubernetes. We defined our services in a Tiltfile located at `bin/Tiltfile`.
#### Getting Started
Make sure you have installed:
- kubectl
- helm
- helmfile
- tilt
To build and start the Kubernetes cluster using Kind:
```shell
$ make build-k8s-cluster
```
Once the Kubernetes cluster is ready, start the application stack locally:
```shell
$ make start-tilt
```
These commands set up and run your application environment using Tilt for local Kubernetes development.
You can monitor Tilt's at `http://localhost:10350/`. After Tilt actions finish, you can access the app at `https://meet.127.0.0.1.nip.io/`.
#### Debugging frontend
Tilt deploys the `meet-dev` for the frontend by default, to benefit from Vite.js hot reloading while developing.
To troubleshoot production issues, please modify the Tiltfile, switch frontend's target to `frontend-production`:
```yaml
...
docker_build(
'localhost:5001/meet-frontend:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target='frontend-production', # Update this line when needed
live_update=[
sync('../src/frontend', '/home/frontend'),
]
)
...
```
## Contributing
This project is intended to be community-driven, so please, do not hesitate to
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
declare diff_from
declare -a paths
declare -a args
# Parse options
for arg in "$@"
do
case $arg in
--diff-only=*)
diff_from="${arg#*=}"
shift
;;
-*)
args+=("$arg")
shift
;;
*)
paths+=("$arg")
shift
;;
esac
done
if [[ -n "${diff_from}" ]]; then
# Run pylint only on modified files located in src/backend
# (excluding deleted files and migration files)
# shellcheck disable=SC2207
paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend ':!**/migrations/*.py' | grep -E '^src/backend/.*\.py$'))
fi
# Fix docker vs local path when project sources are mounted as a volume
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
+15 -14
View File
@@ -1,7 +1,7 @@
#
# Your crowdin's credentials
#
api_token_env: CROWDIN_API_TOKEN
api_token_env: CROWDIN_PERSONAL_TOKEN
project_id_env: CROWDIN_PROJECT_ID
base_path_env: CROWDIN_BASE_PATH
@@ -14,16 +14,17 @@ preserve_hierarchy: true
#
# Files configuration
#
files: [
{
source : "/backend/locale/django.pot",
dest: "/backend-meet.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
dest: "/frontend-impress.json",
translation: "/frontend/packages/i18n/locales/impress/%two_letters_code%/translations.json",
skip_untranslated_strings: true,
},
]
files:
[
{
source: "src/backend/locale/django.pot",
dest: "/backend-meet.pot",
translation: "src/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po",
},
{
source: "src/frontend/src/locales/fr/**/*",
translation: "src/frontend/src/locales/%two_letters_code%/**/%original_file_name%",
dest: "/%original_file_name%",
skip_untranslated_strings: true,
},
]
+1 -1
View File
@@ -100,7 +100,7 @@ services:
image: jwilder/dockerize
crowdin:
image: crowdin/cli:3.16.0
image: crowdin/cli:4.0.0
volumes:
- ".:/app"
env_file:
+57
View File
@@ -0,0 +1,57 @@
# Releasing a new version
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
The new images don't exist _yet_: they will be created automatically later in the process.
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) bump release to 4.18.0
```
5. Open a pull request, wait for an approval from your peers and merge it.
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
7. Tag and push your commit:
```bash
git tag v4.18.1 && git push origin --tags
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/meet-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/meet-frontend/tags) image tags are on Docker Hub.
9. The release is now done!
# Deploying
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
Making a new release doesn't publish it automatically in production.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
-25
View File
@@ -1,25 +0,0 @@
# Api client TypeScript
The backend application can automatically create a TypeScript client to be used in frontend
applications. It is used in the Meet front application itself.
This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
and Meet's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running).
## Requirements
We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to
install the backend application.
## Install openApiClientJs
```sh
$ cd src/tsclient
$ yarn install
```
## Generate the client
```sh
yarn generate:api:client:local <output_path_for_generated_client>
```
+1 -3
View File
@@ -18,9 +18,6 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
@@ -44,3 +41,4 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://localhost:7880
ALLOW_UNREGISTERED_ROOMS=False
+2 -2
View File
@@ -1,3 +1,3 @@
CROWDIN_API_TOKEN=Your-Api-Token
CROWDIN_PERSONAL_TOKEN=Your-Api-Token
CROWDIN_PROJECT_ID=Your-Project-Id
CROWDIN_BASE_PATH=/app/src
CROWDIN_BASE_PATH=/app
+1 -1
Submodule secrets updated: 9da3dfb982...9da011f5c2
View File
+1
View File
@@ -1,4 +1,5 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
+58
View File
@@ -0,0 +1,58 @@
"""
Meet analytics class.
"""
import uuid
from django.conf import settings
from june import analytics as jAnalytics
class Analytics:
"""Analytics integration
This class wraps the June analytics code to avoid coupling our code directly
with this third-party library. By doing so, we create a generic interface
for analytics that can be easily modified or replaced in the future.
"""
def __init__(self):
key = getattr(settings, "ANALYTICS_KEY", None)
if key is not None:
jAnalytics.write_key = key
self._enabled = key is not None
def _is_anonymous_user(self, user):
"""Check if the user is anonymous."""
return user is None or user.is_anonymous
def identify(self, user, **kwargs):
"""Identify a user"""
if self._is_anonymous_user(user) or not self._enabled:
return
traits = kwargs.pop("traits", {})
traits.update({"email": user.email_anonymized})
jAnalytics.identify(user_id=user.sub, traits=traits, **kwargs)
def track(self, user, **kwargs):
"""Track an event"""
if not self._enabled:
return
event_data = {}
if self._is_anonymous_user(user):
event_data["anonymous_id"] = str(uuid.uuid4())
else:
event_data["user_id"] = user.sub
jAnalytics.track(**event_data, **kwargs)
analytics = Analytics()
+3
View File
@@ -1,4 +1,5 @@
"""Meet core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -22,6 +23,8 @@ def exception_handler(exc, context):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages
else:
detail = ""
exc = drf_exceptions.ValidationError(detail=detail)
+1
View File
@@ -1,4 +1,5 @@
"""Permission handlers for the Meet core app."""
from rest_framework import permissions
from ..models import RoleChoices
+6 -3
View File
@@ -1,4 +1,5 @@
"""Client serializers for the Meet core app."""
from django.conf import settings
from django.utils.translation import gettext_lazy as _
@@ -122,14 +123,16 @@ class RoomSerializer(serializers.ModelSerializer):
if role is not None or instance.is_public:
slug = f"{instance.id!s}".replace("-", "")
username = request.GET.get("username", None)
output["livekit"] = {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
"token": utils.generate_token(room=slug, user=request.user),
"token": utils.generate_token(
room=slug, user=request.user, username=username
),
}
output["is_administrable"] = is_admin
# todo - pass properly livekit configuration
return output
+22 -5
View File
@@ -1,4 +1,5 @@
"""API endpoints"""
import uuid
from django.conf import settings
@@ -19,6 +20,7 @@ from rest_framework import (
from core import models, utils
from ..analytics import analytics
from . import permissions, serializers
# pylint: disable=too-many-ancestors
@@ -184,16 +186,26 @@ class RoomViewSet(
"""
try:
instance = self.get_object()
analytics.track(
user=self.request.user,
event="Get Room",
properties={"slug": instance.slug},
)
except Http404:
if not settings.ALLOW_UNREGISTERED_ROOMS:
raise
slug = slugify(self.kwargs["pk"])
username = request.query_params.get("username", None)
data = {
"id": None,
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
"token": utils.generate_token(room=slug, user=request.user),
"token": utils.generate_token(
room=slug, user=request.user, username=username
),
},
}
else:
@@ -206,11 +218,8 @@ class RoomViewSet(
user = self.request.user
if user.is_authenticated:
# todo - simplify this queryset
queryset = (
self.filter_queryset(self.get_queryset())
.filter(Q(users=user))
.distinct()
self.filter_queryset(self.get_queryset()).filter(users=user).distinct()
)
else:
queryset = self.get_queryset().none()
@@ -232,6 +241,14 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
analytics.track(
user=self.request.user,
event="Create Room",
properties={
"slug": room.slug,
},
)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
@@ -10,6 +10,8 @@ from mozilla_django_oidc.auth import (
from core.models import User
from ..analytics import analytics
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -79,6 +81,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
else:
user = None
analytics.identify(user=user)
return user
def create_user(self, claims):
+50
View File
@@ -1,5 +1,6 @@
"""Authentication Views for the People core app."""
import copy
from urllib.parse import urlencode
from django.contrib import auth
@@ -11,10 +12,18 @@ from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
from ..analytics import analytics
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
@@ -91,6 +100,10 @@ class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
logout_url = self.redirect_url
analytics.track(
user=request.user,
event="Signed Out",
)
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
@@ -135,3 +148,40 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
"""Custom callback view for handling the silent loging flow."""
@property
def failure_url(self):
"""Override the failure URL property to handle silent login flow
A silent login failure (e.g., no active user session) should not be
considered as an authentication failure.
"""
if self.request.session.get("silent", None):
del self.request.session["silent"]
self.request.session.save()
return self.success_url
return super().failure_url
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
"""Custom authentication view for handling the silent loging flow."""
def get_extra_params(self, request):
"""Handle 'prompt' extra parameter for the silent login flow
This extra parameter is necessary to distinguish between a standard
authentication flow and the silent login flow.
"""
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
if extra_params is None:
extra_params = {}
if request.GET.get("silent") == "true":
extra_params = copy.deepcopy(extra_params)
extra_params.update({"prompt": "none"})
request.session["silent"] = True
request.session.save()
return extra_params
+1
View File
@@ -1,6 +1,7 @@
"""
Core application enums declaration
"""
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
+1
View File
@@ -2,6 +2,7 @@
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.utils.text import slugify
+8
View File
@@ -1,6 +1,7 @@
"""
Declare and configure the models for the Meet core application
"""
import uuid
from logging import getLogger
@@ -162,6 +163,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""
return []
@property
def email_anonymized(self):
"""Anonymize the email address by replacing the local part with asterisks."""
if not self.email:
return ""
return f"***@{self.email.split('@')[1]}"
class Resource(BaseModel):
"""Model to define access control"""
@@ -92,9 +92,12 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(0), pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
@@ -15,7 +15,13 @@ import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
from core.authentication.views import (
MozillaOIDCAuthenticationCallbackView,
OIDCAuthenticationCallbackView,
OIDCAuthenticationRequestView,
OIDCLogoutCallbackView,
OIDCLogoutView,
)
pytestmark = pytest.mark.django_db
@@ -229,3 +235,125 @@ def test_view_logout_callback():
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_default(settings, mocked_extra_params_setting):
"""By default, authentication request should not trigger silent login."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {}
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "foo"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
assert not request.session.get("silent")
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
"""If 'silent' parameter is set to True, the silent login should be triggered."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "true"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
expected_params = {"prompt": "none"}
assert (
extra_params == {**mocked_extra_params_setting, **expected_params}
if mocked_extra_params_setting
else expected_params
)
assert request.session.get("silent") is True
@mock.patch.object(
MozillaOIDCAuthenticationCallbackView,
"failure_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url(mocked_failure_url):
"""Test default behavior of the 'failure_url' property"""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_failure_url.assert_called_once()
assert returned_url == "foo"
@mock.patch.object(
OIDCAuthenticationCallbackView,
"success_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url_silent_login(mocked_success_url):
"""If a silent login was initiated and failed, it should not be treated as a failure."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["silent"] = True
request.session.save()
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_success_url.assert_called_once()
assert returned_url == "foo"
assert not request.session.get("silent")
+1
View File
@@ -1,4 +1,5 @@
"""Fixtures for tests in the Meet core application"""
from unittest import mock
import pytest
@@ -1,6 +1,7 @@
"""
Test rooms API endpoints in the Meet core app: create.
"""
import pytest
from rest_framework.test import APIClient
@@ -1,6 +1,7 @@
"""
Test rooms API endpoints in the Meet core app: delete.
"""
import pytest
from rest_framework.test import APIClient
@@ -1,6 +1,7 @@
"""
Test rooms API endpoints in the Meet core app: list.
"""
from unittest import mock
import pytest
@@ -1,6 +1,7 @@
"""
Test rooms API endpoints in the Meet core app: retrieve.
"""
import random
from unittest import mock
@@ -111,7 +112,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
},
}
mock_token.assert_called_once_with(room="unregistered-room", user=AnonymousUser())
mock_token.assert_called_once_with(
room="unregistered-room", user=AnonymousUser(), username=None
)
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
@@ -141,7 +144,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
},
}
mock_token.assert_called_once_with(room="reunion", user=AnonymousUser())
mock_token.assert_called_once_with(
room="reunion", user=AnonymousUser(), username=None
)
@override_settings(ALLOW_UNREGISTERED_ROOMS=False)
@@ -153,7 +158,7 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
response = client.get("/api/v1.0/rooms/unregistered-room/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Room matches the given query."}
@mock.patch("core.utils.generate_token", return_value="foo")
@@ -229,7 +234,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"slug": room.slug,
}
mock_token.assert_called_once_with(room=expected_name, user=user)
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
def test_api_rooms_retrieve_authenticated():
@@ -327,7 +332,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"slug": room.slug,
}
mock_token.assert_called_once_with(room=expected_name, user=user)
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
@mock.patch("core.utils.generate_token", return_value="foo")
@@ -400,4 +405,4 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"slug": room.slug,
}
mock_token.assert_called_once_with(room=expected_name, user=user)
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
@@ -1,6 +1,7 @@
"""
Test rooms API endpoints in the Meet core app: update.
"""
import random
import pytest
@@ -1,6 +1,7 @@
"""
Test suite for generated openapi schema.
"""
import json
from io import StringIO
+132
View File
@@ -0,0 +1,132 @@
"""
Test for the Analytics class.
"""
# pylint: disable=W0212
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings
import pytest
from core.analytics import Analytics
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(name="mock_june_analytics")
def _mock_june_analytics():
with patch("core.analytics.jAnalytics") as mock:
yield mock
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_init_enabled(mock_june_analytics):
"""Should enable analytics and set the write key correctly when ANALYTICS_KEY is set."""
analytics = Analytics()
assert analytics._enabled is True
assert mock_june_analytics.write_key == "test_key"
@override_settings(ANALYTICS_KEY=None)
def test_analytics_init_disabled():
"""Should disable analytics when ANALYTICS_KEY is not set."""
analytics = Analytics()
assert analytics._enabled is False
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_user(mock_june_analytics):
"""Should identify a user with the correct traits when analytics is enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_called_once_with(
user_id="12345", traits={"email": "***@example.com"}
)
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_user_with_traits(mock_june_analytics):
"""Should identify a user with additional traits when analytics is enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user, traits={"email": "user@example.com", "foo": "foo"})
mock_june_analytics.identify.assert_called_once_with(
user_id="12345", traits={"email": "***@example.com", "foo": "foo"}
)
@override_settings(ANALYTICS_KEY=None)
def test_analytics_identify_not_enabled(mock_june_analytics):
"""Should not call identify when analytics is not enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_no_user(mock_june_analytics):
"""Should not call identify when the user is None."""
analytics = Analytics()
analytics.identify(None)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_anonymous_user(mock_june_analytics):
"""Should not call identify when the user is anonymous."""
user = AnonymousUser()
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_track_event(mock_june_analytics):
"""Should track an event with the correct user and event details when analytics is enabled."""
user = UserFactory(sub="12345")
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
user_id="12345", event="test_event", foo="foo"
)
@override_settings(ANALYTICS_KEY=None)
def test_analytics_track_event_not_enabled(mock_june_analytics):
"""Should not call track when analytics is not enabled."""
user = UserFactory(sub="12345")
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
@patch("uuid.uuid4", return_value="test_uuid4")
def test_analytics_track_event_no_user(mock_uuid4, mock_june_analytics):
"""Should track an event with a random anonymous user ID when the user is None."""
analytics = Analytics()
analytics.track(None, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
anonymous_id="test_uuid4", event="test_event", foo="foo"
)
mock_uuid4.assert_called_once()
@override_settings(ANALYTICS_KEY="test_key")
@patch("uuid.uuid4", return_value="test_uuid4")
def test_analytics_track_event_anonymous_user(mock_uuid4, mock_june_analytics):
"""Should track an event with a random anonymous user ID when the user is anonymous."""
user = AnonymousUser()
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
anonymous_id="test_uuid4", event="test_event", foo="foo"
)
mock_uuid4.assert_called_once()
@@ -1,6 +1,7 @@
"""
Test resource accesses API endpoints in the Meet core app.
"""
import random
from unittest import mock
from uuid import uuid4
+1
View File
@@ -1,6 +1,7 @@
"""
Test users API endpoints in the Meet core app.
"""
import pytest
from rest_framework.test import APIClient
@@ -1,6 +1,7 @@
"""
Unit tests for the ResourceAccess model with user
"""
from django.core.exceptions import ValidationError
import pytest
@@ -1,6 +1,7 @@
"""
Unit tests for the Room model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -1,6 +1,7 @@
"""
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError
@@ -43,3 +44,12 @@ def test_models_users_send_mail_main_missing():
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
def test_models_users_email_anonymized():
"""The user's email should be anonymized if it exists."""
user = factories.UserFactory(email="john.doe@world.com")
assert user.email_anonymized == "***@world.com"
user = factories.UserFactory(email=None)
assert user.email_anonymized == ""
+1
View File
@@ -1,4 +1,5 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path
+10 -6
View File
@@ -1,7 +1,8 @@
"""
Utils functions used in the core app
"""
import string
from typing import Optional
from uuid import uuid4
from django.conf import settings
@@ -9,18 +10,19 @@ from django.conf import settings
from livekit.api import AccessToken, VideoGrants
def generate_token(room: string, user) -> str:
def generate_token(room: str, user, username: Optional[str] = None) -> str:
"""Generate a Livekit access token for a user in a specific room.
Args:
room (str): The name of the room.
user (User): The user which request the access token.
username (Optional[str]): The username to be displayed in the room.
If none, a default value will be used.
Returns:
str: The LiveKit JWT access token.
"""
# todo - define the video grants properly based on user and room.
video_grants = VideoGrants(
room=room,
room_join=True,
@@ -38,10 +40,12 @@ def generate_token(room: string, user) -> str:
).with_grants(video_grants)
if user.is_anonymous:
# todo - allow passing a proper name for not logged-in user
token.with_identity(str(uuid4()))
default_username = "Anonymous"
else:
# todo - use user's fullname instead of its email for the displayed name
token.with_identity(user.sub).with_name(f"{user!s}")
token.with_identity(user.sub)
default_username = str(user)
token.with_name(username or default_username)
return token.to_jwt()
@@ -1,4 +1,5 @@
"""Management user to create a superuser."""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
+1
View File
@@ -2,6 +2,7 @@
"""
meet's sandbox management script.
"""
import os
import sys
+1
View File
@@ -1,4 +1,5 @@
"""Meet celery configuration file."""
import os
from celery import Celery
+11
View File
@@ -9,6 +9,7 @@ https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
@@ -284,6 +285,8 @@ class Base(Configuration):
SESSION_COOKIE_AGE = 60 * 60 * 12
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
OIDC_CREATE_USER = values.BooleanValue(
default=True,
environ_name="OIDC_CREATE_USER",
@@ -344,6 +347,9 @@ class Base(Configuration):
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
OIDC_REDIRECT_FIELD_NAME = values.Value(
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
@@ -359,6 +365,9 @@ class Base(Configuration):
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
)
ANALYTICS_KEY = values.Value(
None, environ_name="ANALYTICS_KEY", environ_prefix=None
)
# pylint: disable=invalid-name
@property
@@ -479,6 +488,8 @@ class Test(Base):
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
ANALYTICS_KEY = None
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
+40 -39
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.0"
version = "0.1.3"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -19,44 +19,45 @@ classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
]
description = "An application to print markdown to pdf from a set of managed templates."
description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.33.6",
"boto3==1.34.153",
"Brotli==1.1.0",
"celery[redis]==5.3.6",
"django-configurations==2.5",
"django-cors-headers==4.3.1",
"django-countries==7.5.1",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.3",
"redis==5.0.8",
"django-redis==5.4.0",
"django-storages[s3]==1.14.2",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.0.3",
"djangorestframework==3.14.0",
"drf_spectacular==0.26.5",
"dockerflow==2022.8.0",
"easy_thumbnails==2.8.5",
"django==5.0.7",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"factory_boy==3.3.0",
"freezegun==1.5.0",
"freezegun==1.5.1",
"gunicorn==22.0.0",
"jsonschema==4.20.0",
"markdown==3.5.1",
"jsonschema==4.23.0",
"june-analytics-python==2.3.0",
"markdown==3.6",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.1.14",
"PyJWT==2.8.0",
"python-frontmatter==1.0.1",
"requests==2.31.0",
"sentry-sdk==1.38.0",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.12.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
"livekit-api==0.5.1",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.6.0",
]
[project.urls]
@@ -68,20 +69,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2023.12.1",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.18.1",
"pyfakefs==5.3.2",
"ipython==8.26.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.0.3",
"pytest-cov==4.1.0",
"pytest-django==4.7.0",
"pytest==7.4.3",
"pytest-icdiff==0.8",
"pytest-xdist==3.5.0",
"responses==0.24.1",
"ruff==0.1.6",
"types-requests==2.31.0.10",
"pylint==3.2.6",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.5.6",
"types-requests==2.32.0.20240712",
]
[tool.setuptools]
@@ -100,7 +101,6 @@ exclude = [
"__pycache__",
"*/migrations/*",
]
ignore= ["DJ001", "PLR2004"]
line-length = 88
@@ -121,12 +121,13 @@ select = [
"SLF", # flake8-self
"T20", # flake8-print
]
ignore= ["DJ001", "PLR2004"]
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","meet","first-party","local-folder"]
sections = { meet=["core"], django=["django"] }
[tool.ruff.per-file-ignores]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
[tool.pytest.ini_options]
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env python
"""Setup file for the meet module. All configuration stands in the setup.cfg file."""
# coding: utf-8
from setuptools import setup
setup()
+8
View File
@@ -0,0 +1,8 @@
{
"defaultNamespace": "global",
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
"output": "src/locales/$LOCALE/$NAMESPACE.json",
"createOldCatalogs": false,
"locales": ["en", "fr", "de"],
"sort": true
}
+1565 -44
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -1,23 +1,32 @@
{
"name": "meet",
"private": true,
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
"build": "panda codegen && tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"preview": "vite preview",
"i18n:extract": "npx i18next -c i18next-parser.config.json"
},
"dependencies": {
"@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.0.12",
"@pandacss/preset-panda": "0.41.0",
"@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.49.2",
"hoofd": "1.7.1",
"i18next": "23.12.1",
"i18next-browser-languagedetector": "8.0.0",
"i18next-parser": "9.0.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.3.1",
"react": "18.2.0",
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"valtio": "1.13.2",
"wouter": "3.3.0"
},
"devDependencies": {
+35 -23
View File
@@ -35,18 +35,6 @@ const config: Config = {
exclude: [],
jsxFramework: 'react',
outdir: 'src/styled-system',
conditions: {
extend: {
// React Aria builds upon data attributes instead of css pseudo-classes, in case we style a React Aria component
// we dont want to trigger pseudo class related styles
'ra-hover': '&:is([data-hovered])',
'ra-focus': '&:is([data-focused])',
'ra-focusVisible': '&:is([data-focus-visible])',
'ra-disabled': '&:is([data-disabled])',
pressed: '&:is([data-pressed])',
'ra-pressed': '&:is([data-pressed])',
},
},
theme: {
...pandaPreset.theme,
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
@@ -58,6 +46,19 @@ const config: Config = {
xl: '80em', // 1280px
'2xl': '96em', // 1536px
},
keyframes: {
slide: {
from: {
transform: 'var(--origin)',
opacity: 0,
},
to: {
transform: 'translateY(0)',
opacity: 1,
},
},
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
},
tokens: defineTokens({
/* we take a few things from the panda preset but for now we clear out some stuff.
* This way we'll only add the things we need step by step and prevent using lots of differents things.
@@ -146,6 +147,7 @@ const config: Config = {
2: { value: '2' },
},
radii: {
4: { value: '0.25rem' },
6: { value: '0.375rem' },
8: { value: '0.5rem' },
16: { value: '1rem' },
@@ -164,7 +166,7 @@ const config: Config = {
colors: {
default: {
text: { value: '{colors.gray.900}' },
bg: { value: '{colors.slate.50}' },
bg: { value: 'white' },
subtle: { value: '{colors.gray.100}' },
'subtle-text': { value: '{colors.gray.600}' },
},
@@ -178,7 +180,8 @@ const config: Config = {
hover: { value: '{colors.gray.200}' },
active: { value: '{colors.gray.300}' },
text: { value: '{colors.default.text}' },
border: { value: '{colors.gray.300}' },
border: { value: '{colors.gray.500}' },
subtle: { value: '{colors.gray.400}' },
},
primary: {
DEFAULT: { value: '{colors.blue.700}' },
@@ -187,7 +190,7 @@ const config: Config = {
text: { value: '{colors.white}' },
warm: { value: '{colors.blue.300}' },
subtle: { value: '{colors.blue.100}' },
'subtle-text': { value: '{colors.sky.700}' },
'subtle-text': { value: '{colors.blue.700}' },
},
danger: {
DEFAULT: { value: '{colors.red.600}' },
@@ -198,12 +201,13 @@ const config: Config = {
'subtle-text': { value: '{colors.red.700}' },
},
success: {
DEFAULT: { value: '{colors.emerald.700}' },
hover: { value: '{colors.emerald.800}' },
active: { value: '{colors.emerald.900}' },
DEFAULT: { value: '{colors.green.700}' },
hover: { value: '{colors.green.800}' },
active: { value: '{colors.green.900}' },
text: { value: '{colors.white}' },
subtle: { value: '{colors.emerald.100}' },
'subtle-text': { value: '{colors.emerald.700}' },
subtle: { value: '{colors.green.100}' },
'subtle-text': { value: '{colors.green.800}' },
...pandaPreset.theme.tokens.colors.green,
},
warning: {
DEFAULT: { value: '{colors.amber.700}' },
@@ -213,6 +217,7 @@ const config: Config = {
subtle: { value: '{colors.amber.100}' },
'subtle-text': { value: '{colors.amber.700}' },
},
focusRing: { value: 'rgb(74, 121, 199)' },
},
shadows: {
box: { value: '{shadows.sm}' },
@@ -228,12 +233,20 @@ const config: Config = {
DEFAULT: { value: '{spacing.1}' },
lg: { value: '{spacing.2}' },
},
paragraph: { value: '{spacing.1}' },
paragraph: { value: '{spacing.0.5}' },
heading: { value: '{spacing.1}' },
gutter: { value: '{spacing.1}' },
textfield: { value: '{spacing.1}' },
},
}),
textStyles: defineTextStyles({
display: {
value: {
fontSize: '3rem',
lineHeight: '2rem',
fontWeight: 700,
},
},
h1: {
value: {
fontSize: '1.5rem',
@@ -252,7 +265,6 @@ const config: Config = {
value: {
fontSize: '1.125rem',
lineHeight: '1.75rem',
fontWeight: 700,
},
},
body: {
@@ -261,7 +273,7 @@ const config: Config = {
lineHeight: '1.5',
},
},
small: {
sm: {
value: {
fontSize: '0.875rem',
lineHeight: '1.25rem',
+28 -12
View File
@@ -1,23 +1,39 @@
import '@livekit/components-styles'
import '@/styles/index.css'
import { Suspense } from 'react'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Route, Switch } from 'wouter'
import { Home } from './routes/Home'
import { NotFound } from './routes/NotFound'
import { RoomRoute } from '@/features/rooms'
import { QueryClientProvider } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useLang } from 'hoofd'
import { Switch, Route } from 'wouter'
import { Layout } from './layout/Layout'
import { NotFoundScreen } from './components/NotFoundScreen'
import { routes } from './routes'
import './i18n/init'
import { silenceLiveKitLogs } from "@/utils/livekit.ts";
import { queryClient } from "@/api/queryClient";
const queryClient = new QueryClient()
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
const isProduction = import.meta.env.PROD
silenceLiveKitLogs(isProduction)
return (
<QueryClientProvider client={queryClient}>
<Switch>
<Route path="/" component={Home} />
<Route path="/:roomId" component={RoomRoute} />
<Route component={NotFound} />
</Switch>
<ReactQueryDevtools initialIsOpen={false} />
<Suspense fallback={null}>
<Layout>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</Layout>
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</QueryClientProvider>
)
}
+3
View File
@@ -0,0 +1,3 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient()
@@ -0,0 +1,14 @@
import { Link } from '@/primitives'
import { AProps } from '@/primitives/A'
import { useTranslation } from 'react-i18next'
export const BackToHome = ({ size }: { size?: AProps['size'] }) => {
const { t } = useTranslation()
return (
<p>
<Link to="/" size={size}>
{t('backToHome')}
</Link>
</p>
)
}
@@ -0,0 +1,24 @@
import { useState, useEffect, type ReactNode } from 'react'
export const DelayedRender = ({
children,
delay = 500,
}: {
delay?: number
children: ReactNode
}) => {
const [show, setShow] = useState(false)
useEffect(() => {
if (delay === 0) {
setShow(true)
return
}
const timeout = setTimeout(() => setShow(true), delay)
return () => clearTimeout(timeout)
}, [delay])
if (delay !== 0 && !show) {
return null
}
return children
}
@@ -0,0 +1,25 @@
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { useTranslation } from 'react-i18next'
import { Center } from '@/styled-system/jsx'
import { Text } from '@/primitives'
export const ErrorScreen = ({ title, body }: {
title?: string
body?: string
}) => {
const { t } = useTranslation()
return (
<Screen layout="centered">
<CenteredContent title={title || t('error.heading')} withBackButton>
{!!body && (
<Center>
<Text as="p" variant="h3" centered>
{body}
</Text>
</Center>
)}
</CenteredContent>
</Screen>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
export const Feedback = () => {
const { t } = useTranslation()
return (
<Button
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
variant="success"
target="_blank"
>
<span className={css({ marginRight: 0.5 })} aria-hidden="true">
💡
</span>
{t('feedbackAlert')}
<RiExternalLinkLine
size={16}
className={css({ marginLeft: 0.5 })}
aria-hidden="true"
/>
</Button>
)
}
@@ -0,0 +1,27 @@
import { Screen, type ScreenProps } from '@/layout/Screen'
import { DelayedRender } from './DelayedRender'
import { CenteredContent } from '@/layout/CenteredContent'
import { useTranslation } from 'react-i18next'
import { Center } from '@/styled-system/jsx'
export const LoadingScreen = ({
delay = 500,
header = undefined,
layout = 'centered',
}: {
delay?: number
} & Omit<ScreenProps, 'children'>) => {
const { t } = useTranslation()
return (
<DelayedRender delay={delay}>
<Screen layout={layout} header={header}>
<CenteredContent>
<Center>
<p>{t('loading')}</p>
</Center>
</CenteredContent>
</Screen>
</DelayedRender>
)
}
@@ -0,0 +1,12 @@
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { useTranslation } from 'react-i18next'
export const NotFoundScreen = () => {
const { t } = useTranslation()
return (
<Screen layout="centered">
<CenteredContent title={t('notFound.heading')} withBackButton />
</Screen>
)
}
@@ -0,0 +1,28 @@
import { ErrorScreen } from '@/components/ErrorScreen'
import { LoadingScreen } from '@/components/LoadingScreen'
/**
* Render an error or loading Screen while a given `status` is not a success,
* otherwise directly render children.
*
* `status` matches react query statuses.
*
* Children usually contain a Screen at some point in the render tree.
*/
export const QueryAware = ({
status,
children,
}: {
status: 'error' | 'idle' | 'pending' | 'success'
children: React.ReactNode
}) => {
if (status === 'error') {
return <ErrorScreen />
}
if (status === 'pending') {
return <LoadingScreen header={undefined} />
}
return children
}
@@ -1,6 +1,7 @@
import { ApiError } from '@/api/ApiError'
import { fetchApi } from '@/api/fetchApi'
import { type ApiUser } from './ApiUser'
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
/**
* fetch the logged-in user from the api.
@@ -16,7 +17,13 @@ export const fetchUser = (): Promise<ApiUser | false> => {
.catch((error) => {
// we assume that a 401 means the user is not logged in
if (error instanceof ApiError && error.statusCode === 401) {
resolve(false)
// make sure to not resolve the promise while trying to silent login
// so that consumers of fetchUser don't think the work already ended
if (canAttemptSilentLogin()) {
attemptSilentLogin(3600)
} else {
resolve(false)
}
} else {
reject(error)
}
+13 -3
View File
@@ -1,17 +1,27 @@
import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { fetchUser } from './fetchUser'
import { type ApiUser } from './ApiUser'
/**
* returns info about currently logged in user
*
* `isLoggedIn` is undefined while query is loading and true/false when it's done
*/
export const useUser = () => {
const query = useQuery({
queryKey: [keys.user],
queryFn: fetchUser,
staleTime: 1000 * 60 * 60, // 1 hour
})
const isLoggedIn =
query.status === 'success' ? query.data !== false : undefined
const isLoggedOut = isLoggedIn === false
return {
...query,
// if fetchUser returns false, it means the user is not logged in: expose that
user: query.data === false ? undefined : query.data,
isLoggedIn: query.data !== undefined && query.data !== false,
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
isLoggedIn,
}
}
@@ -0,0 +1,20 @@
import { useUser } from '@/features/auth'
import { LoadingScreen } from '@/components/LoadingScreen'
/**
* Renders a loading Screen while user info has not been fetched yet,
* otherwise directly render children.
*
* Children usually contain a Screen at some point in the render tree.
*
* This is helpful to prevent flash of logged-out content for a few milliseconds when user is actually logged in
*/
export const UserAware = ({ children }: { children: React.ReactNode }) => {
const { isLoggedIn } = useUser()
return isLoggedIn !== undefined ? (
children
) : (
<LoadingScreen header={false} delay={1000} />
)
}
+1
View File
@@ -1,3 +1,4 @@
export { useUser } from './api/useUser'
export { authUrl } from './utils/authUrl'
export { logoutUrl } from './utils/logoutUrl'
export { UserAware } from './components/UserAware'
@@ -1,5 +1,8 @@
import { apiUrl } from '@/api/apiUrl'
export const authUrl = () => {
return apiUrl('/authenticate')
export const authUrl = ({
silent = false,
returnTo = window.location.href,
} = {}) => {
return apiUrl(`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`)
}
@@ -0,0 +1,34 @@
import { authUrl } from "@/features/auth";
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
const isRetryAllowed = () => {
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
if (!lastRetryDate) {
return true;
}
const now = new Date();
return now.getTime() > Number(lastRetryDate)
}
const setNextRetryTime = (retryIntervalInSeconds: number) => {
const now = new Date()
const nextRetryTime = now.getTime() + (retryIntervalInSeconds * 1000);
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime));
}
const initiateSilentLogin = () => {
window.location.href = authUrl({ silent: true })
}
export const canAttemptSilentLogin = () => {
return isRetryAllowed()
}
export const attemptSilentLogin = (retryIntervalInSeconds: number) => {
if (!isRetryAllowed()) {
return
}
setNextRetryTime(retryIntervalInSeconds)
initiateSilentLogin()
}
@@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next'
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
import { navigateTo } from '@/navigation/navigateTo'
import { isRoomValid } from '@/features/rooms'
export const JoinMeetingDialog = () => {
const { t } = useTranslation('home')
return (
<Dialog title={t('joinMeeting')}>
<Form
onSubmit={(data) => {
navigateTo(
'room',
(data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
)
}}
submitLabel={t('joinInputSubmit')}
>
<Field
type="text"
name="roomId"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
})}
validate={(value) => {
return !isRoomValid(value.trim()) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}}
/>
</Form>
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
<P last>{t('joinMeetingTipContent')}</P>
</Dialog>
)
}
+1
View File
@@ -0,0 +1 @@
export { Home as HomeRoute } from './routes/Home'
@@ -0,0 +1,67 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger } from 'react-aria-components'
import { Button, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen'
import { Centered } from '@/layout/Centered'
import { generateRoomId } from '@/features/rooms'
import { authUrl, useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { useCreateRoom } from '@/features/rooms'
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn } = useUser()
const { mutateAsync: createRoom } = useCreateRoom({
onSuccess: (data) => {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
}
});
return (
<UserAware>
<Screen>
<Centered width="fit-content">
<Text as="h1" variant="display">
{t('heading')}
</Text>
<Text as="p" variant="h3">
{t('intro')}
</Text>
{!isLoggedIn && (
<Text margin="sm" variant="note">
{t('loginToCreateMeeting')}
</Text>
)}
<HStack gap="gutter">
<Button
variant="primary"
onPress={
isLoggedIn
? async () => {
const slug = generateRoomId()
await createRoom({slug})
}
: undefined
}
href={isLoggedIn ? undefined : authUrl()}
>
{isLoggedIn ? t('createMeeting') : t('login', { ns: 'global' })}
</Button>
<DialogTrigger>
<Button variant="primary" outline>
{t('joinMeeting')}
</Button>
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
</Centered>
</Screen>
</UserAware>
)
}
@@ -8,4 +8,7 @@ export type ApiRoom = {
room: string
token: string
}
configuration?: {
[key: string]: string | number | boolean;
}
}
@@ -0,0 +1,25 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { fetchApi } from '@/api/fetchApi';
import { ApiError } from "@/api/ApiError";
import { ApiRoom } from "./ApiRoom";
export interface CreateRoomParams {
slug: string;
}
const createRoom = ({slug}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/`, {
method: 'POST',
body: JSON.stringify({
name: slug,
}),
})
};
export function useCreateRoom(options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>) {
return useMutation<ApiRoom, ApiError, CreateRoomParams>({
mutationFn: createRoom,
onSuccess: options?.onSuccess,
});
}
@@ -1,48 +1,124 @@
import { useParams } from 'wouter'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
formatChatMessageLinks,
LiveKitRoom,
VideoConference,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { QueryAware } from '@/layout/QueryAware'
import { navigateToHome } from '@/navigation/navigateToHome'
import { queryClient } from '@/api/queryClient'
import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen'
import { QueryAware } from '@/components/QueryAware'
import { ErrorScreen } from '@/components/ErrorScreen'
import { fetchRoom } from '../api/fetchRoom'
import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
export const Conference = ({
roomId,
userConfig,
initialRoomData,
mode = 'join',
}: {
roomId: string
userConfig: LocalUserChoices
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const { roomId } = useParams()
const { status, data } = useQuery({
queryKey: [keys.room, roomId, userConfig.username],
const fetchKey = [keys.room, roomId, userConfig.username]
const { mutateAsync: createRoom, status: createStatus, isError: isCreateError} = useCreateRoom({
onSuccess: (data) => {
queryClient.setQueryData(fetchKey, data)
},
});
const { status: fetchStatus, isError: isFetchError, data } = useQuery({
queryKey: fetchKey,
enabled: !initialRoomData,
initialData: initialRoomData,
queryFn: () =>
fetchRoom({
roomId: roomId as string,
username: userConfig.username,
}).catch((error) => {
if (error.statusCode == '404') {
createRoom({slug: roomId})
}
}),
retry: false,
})
const roomOptions = useMemo((): RoomOptions => {
return {
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
},
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
/**
* checks for actual click on the leave button instead of
* relying on LiveKitRoom onDisconnected because onDisconnected
* triggers even on page reload, it's not a user "onLeave" event really.
* Here we want to react to the user actually deciding to leave.
*/
useEffect(() => {
const checkOnLeaveClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.classList.contains('lk-disconnect-button')) {
navigateTo('feedback')
}
}
document.body.addEventListener('click', checkOnLeaveClick)
return () => {
document.body.removeEventListener('click', checkOnLeaveClick)
}
}, [])
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return <ErrorScreen title={t('error.createRoom.heading')} body={t('error.createRoom.body')} />
}
return (
<QueryAware status={status}>
<LiveKitRoom
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={{
deviceId: userConfig.audioDeviceId,
}}
video={{
deviceId: userConfig.videoDeviceId,
}}
onDisconnected={() => {
navigateToHome()
}}
>
<VideoConference />
</LiveKitRoom>
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
<Screen>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
/>
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
roomId={roomId}
onClose={() => setShowInviteDialog(false)}
/>
)}
</LiveKitRoom>
</Screen>
</QueryAware>
)
}
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Div, Button, Dialog, Input, type DialogProps } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
export const InviteDialog = ({
roomId,
...dialogProps
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms')
const roomUrl = getRouteUrl('room', roomId)
const copyLabel = t('shareDialog.copy')
const copiedLabel = t('shareDialog.copied')
const [copyLinkLabel, setCopyLinkLabel] = useState(copyLabel)
useEffect(() => {
if (copyLinkLabel == copiedLabel) {
const timeout = setTimeout(() => {
setCopyLinkLabel(copyLabel)
}, 5000)
return () => {
clearTimeout(timeout)
}
}
}, [copyLinkLabel, copyLabel, copiedLabel])
return (
<Dialog {...dialogProps} title={t('shareDialog.heading')}>
<HStack alignItems="stretch" gap="gutter">
<Div flex="1">
<Input
type="text"
aria-label={t('shareDialog.inputLabel')}
value={roomUrl}
readOnly
onClick={(e) => {
e.currentTarget.select()
}}
/>
</Div>
<Div minWidth="8rem">
<Button
variant="primary"
size="sm"
fullWidth
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setCopyLinkLabel(copiedLabel)
}}
>
{copyLinkLabel}
</Button>
</Div>
</HStack>
</Dialog>
)
}
@@ -1,17 +1,27 @@
import { Box } from '@/layout/Box'
import { useTranslation } from 'react-i18next'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
export const Join = ({
onSubmit,
}: {
onSubmit: (choices: LocalUserChoices) => void
}) => {
const { t } = useTranslation('rooms')
return (
<Box title="Verify your settings before joining" withBackButton>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
/>
</Box>
<Screen layout="centered">
<CenteredContent title={t('join.heading')}>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.micLabel')}
camLabel={t('join.camlabel')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')}
/>
</CenteredContent>
</Screen>
)
}
+4 -1
View File
@@ -1,2 +1,5 @@
export { navigateToNewRoom } from './navigation/navigateToNewRoom'
export { Room as RoomRoute } from './routes/Room'
export { FeedbackRoute } from './routes/Feedback'
export { roomIdPattern, isRoomValid } from './utils/isRoomValid'
export { generateRoomId } from './utils/generateRoomId'
export { useCreateRoom } from './api/createRoom'
@@ -1,6 +0,0 @@
import { navigate } from 'wouter/use-browser-location'
import { generateRoomId } from '../utils/generateRoomId'
export const navigateToNewRoom = () => {
navigate(`/${generateRoomId()}`)
}
@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next'
import { Text } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
return (
<Screen layout="centered">
<CenteredContent title={t('feedback.heading')} withBackButton>
<Text as="p" variant="h3" centered>{t('feedback.body')}</Text>
</CenteredContent>
</Screen>
)
}
@@ -1,18 +1,47 @@
import { type LocalUserChoices } from '@livekit/components-react'
import { useState } from 'react'
import {
usePersistentUserChoices,
type LocalUserChoices,
} from '@livekit/components-react'
import { useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { Screen } from '@/layout/Screen'
export const Room = () => {
const { isLoggedIn } = useUser()
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
return (
<Screen>
{userConfig ? (
<Conference userConfig={userConfig} />
) : (
const { roomId } = useParams()
const initialRoomData = history.state?.initialRoomData
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
if (!roomId) {
return <ErrorScreen />
}
if (!userConfig && !skipJoinScreen) {
return (
<UserAware>
<Join onSubmit={setUserConfig} />
)}
</Screen>
</UserAware>
)
}
return (
<UserAware>
<Conference
initialRoomData={initialRoomData}
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...userConfig,
}}
/>
</UserAware>
)
}
@@ -1,5 +1,14 @@
import { slugify } from '@/utils/slugify'
// Google Meet uses only letters in a room identifier
const ROOM_ID_ALLOWED_CHARACTERS = 'abcdefghijklmnopqrstuvwxyz'
export const generateRoomId = () => {
return slugify(crypto.randomUUID())
}
const getRandomChar = () =>
ROOM_ID_ALLOWED_CHARACTERS[
Math.floor(Math.random() * ROOM_ID_ALLOWED_CHARACTERS.length)
]
const generateSegment = (length: number): string =>
Array.from(Array(length), getRandomChar).join('')
// Generates a unique room identifier following the Google Meet format
export const generateRoomId = () =>
[generateSegment(3), generateSegment(4), generateSegment(3)].join('-')
@@ -0,0 +1,5 @@
export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
export const isRoomValid = (roomIdOrUrl: string) =>
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger } from 'react-aria-components'
import { RiSettings3Line } from '@remixicon/react'
import { Button } from '@/primitives'
import { SettingsDialog } from './SettingsDialog'
export const SettingsButton = () => {
const { t } = useTranslation('settings')
return (
<DialogTrigger>
<Button
square
invisible
aria-label={t('settingsButtonLabel')}
tooltip={t('settingsButtonLabel')}
>
<RiSettings3Line />
</Button>
<SettingsDialog />
</DialogTrigger>
)
}
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { Dialog, Field, H } from '@/primitives'
export const SettingsDialog = () => {
const { t, i18n } = useTranslation('settings')
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<Dialog title={t('dialog.heading')}>
<H lvl={2}>{t('language.heading')}</H>
<Field
type="select"
label={t('language.label')}
items={languagesList}
defaultSelectedKey={currentLanguage.key}
onSelectionChange={(lang) => {
i18n.changeLanguage(lang as string)
}}
/>
</Dialog>
)
}
@@ -0,0 +1,2 @@
export { SettingsButton } from './components/SettingsButton'
export { SettingsDialog } from './components/SettingsDialog'
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import { Button, Popover, PopoverList } from '@/primitives'
import { useLanguageLabels } from './useLanguageLabels'
export const LanguageSelector = () => {
const { t, i18n } = useTranslation()
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<Popover aria-label={t('languageSelector.popoverLabel')}>
<Button
aria-label={t('languageSelector.buttonLabel', {
currentLanguage: currentLanguage.label,
})}
size="sm"
variant="primary"
outline
>
{i18n.language}
</Button>
<PopoverList
items={languagesList}
onAction={(lang) => {
i18n.changeLanguage(lang)
}}
/>
</Popover>
)
}
+23
View File
@@ -0,0 +1,23 @@
import i18n from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
const i18nDefaultNamespace = 'global'
i18n.setDefaultNamespace(i18nDefaultNamespace)
i18n
.use(
resourcesToBackend((language: string, namespace: string) => {
return import(`../locales/${language}/${namespace}.json`)
})
)
.use(initReactI18next)
.use(LanguageDetector)
i18n.init({
supportedLngs: ['en', 'fr'],
fallbackLng: 'en',
ns: i18nDefaultNamespace,
interpolation: {
escapeValue: false,
},
})
@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
const languageLabels: Record<string, string> = {
en: 'English',
fr: 'Français',
de: 'Deutsch',
}
export const useLanguageLabels = () => {
const { i18n } = useTranslation()
// cimode is a testing value from i18next, don't include it
const supportedLanguages = (i18n.options.supportedLngs || []).filter(
(lang) => lang !== 'cimode'
)
const languagesList = supportedLanguages.map((lang) => ({
key: lang,
value: lang,
label: languageLabels[lang],
}))
return {
languagesList,
currentLanguage: {
key: i18n.language,
label: languageLabels[i18n.language],
},
}
}
-28
View File
@@ -1,28 +0,0 @@
import type { ReactNode } from 'react'
import { Box as BoxDiv, H, Link } from '@/primitives'
export type BoxProps = {
children?: ReactNode
title?: ReactNode
withBackButton?: boolean
}
export const Box = ({
children,
title = '',
withBackButton = false,
}: BoxProps) => {
return (
<BoxDiv asScreen>
{!!title && <H lvl={1}>{title}</H>}
{children}
{!!withBackButton && (
<p>
<Link to="/" size="small">
Back to homescreen
</Link>
</p>
)}
</BoxDiv>
)
}
-10
View File
@@ -1,10 +0,0 @@
import { Screen } from './Screen'
import { Box, type BoxProps } from './Box'
export const BoxScreen = (props: BoxProps) => {
return (
<Screen>
<Box {...props} />
</Screen>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import { Div, VerticallyOffCenter } from '@/primitives'
import type { SystemStyleObject } from '../styled-system/types'
export const Centered = ({
width = '38rem',
children,
}: {
width?: SystemStyleObject['width']
children?: ReactNode
}) => {
return (
<VerticallyOffCenter>
<Div margin="auto" width={width} maxWidth="100%">
{children}
</Div>
</VerticallyOffCenter>
)
}
@@ -0,0 +1,29 @@
import { BackToHome } from '@/components/BackToHome'
import { H } from '@/primitives'
import { Center } from '@/styled-system/jsx'
export const CenteredContent = ({
title,
children,
withBackButton,
}: {
title?: string
children?: React.ReactNode
withBackButton?: boolean
}) => {
return (
<>
{!!title && (
<Center>
<H lvl={1}>{title}</H>
</Center>
)}
{children}
{!!withBackButton && (
<Center marginTop={2}>
<BackToHome size="sm" />
</Center>
)}
</>
)
}
-7
View File
@@ -1,7 +0,0 @@
import { BoxScreen } from './BoxScreen'
export const ErrorScreen = () => {
return (
<BoxScreen title="An error occured while loading the page" withBackButton />
)
}
@@ -1,10 +0,0 @@
import { BoxScreen } from './BoxScreen'
export const ForbiddenScreen = () => {
return (
<BoxScreen
title="You don't have the permission to view this page"
withBackButton
/>
)
}
+73 -27
View File
@@ -1,46 +1,92 @@
import { Link } from 'wouter'
import { css } from '@/styled-system/css'
import { flex } from '@/styled-system/patterns'
import { A, Badge, Text } from '@/primitives'
import { Stack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { A, Button, Popover, PopoverList, Text } from '@/primitives'
import { SettingsButton } from '@/features/settings'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
import { Feedback } from '@/components/Feedback'
export const Header = () => {
const { t } = useTranslation()
const isHome = useMatchesRoute('home')
const isRoom = useMatchesRoute('room')
const { user, isLoggedIn } = useUser()
return (
<div
className={css({
backgroundColor: 'primary.text',
color: 'primary',
borderBottomColor: 'box.border',
borderBottomWidth: 1,
borderBottomStyle: 'solid',
padding: 1,
paddingY: 1,
paddingX: 1,
flexShrink: 0,
boxShadow: 'box',
})}
>
<header
className={flex({
justify: 'space-between',
align: 'center',
<div
className={css({
display: 'flex',
flexDirection: 'column',
rowGap: 1,
md: {
rowGap: 0,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
})}
>
<div>
<Text bold variant="h1" margin={false}>
Meet
</Text>
</div>
<div>
{isLoggedIn === false && <A href={authUrl()}>Login</A>}
{!!user && (
<p className={flex({ gap: 1, align: 'center' })}>
<Badge>{user.email}</Badge>
<A href={logoutUrl()} size="small">
Logout
</A>
</p>
)}
</div>
</header>
<header>
<Stack gap={2.25} direction="row" align="center">
<Text bold variant="h1" margin={false}>
<Link
onClick={(event) => {
if (
isRoom &&
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
) {
event.preventDefault()
}
}}
to="/"
>
{t('app')}
</Link>
</Text>
<Feedback />
</Stack>
</header>
<nav>
<Stack gap={1} direction="row" align="center">
{isLoggedIn === false && !isHome && (
<A href={authUrl()}>{t('login')}</A>
)}
{!!user && (
<Popover aria-label={t('logout')}>
<Button
size="sm"
invisible
tooltip={t('loggedInUserTooltip')}
tooltipType="delayed"
>
{user.email}
</Button>
<PopoverList
items={[{ key: 'logout', value: 'logout', label: t('logout') }]}
onAction={(value) => {
if (value === 'logout') {
window.location.href = logoutUrl()
}
}}
/>
</Popover>
)}
<SettingsButton />
</Stack>
</nav>
</div>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { type ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { Header } from './Header'
import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
export type Layout = 'fullpage' | 'centered'
/**
* Layout component for the app.
*
* This component is meant to be used as a wrapper around the whole app.
* In a specific page, use the `Screen` component and change its props to change global page layout.
*/
export const Layout = ({ children }: { children: ReactNode }) => {
const layoutSnap = useSnapshot(layoutStore)
const showHeader = layoutSnap.showHeader
return (
<div
className={css({
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'white',
color: 'default.text',
})}
>
{showHeader && <Header />}
<main
className={css({
flexGrow: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
})}
>
{children}
</main>
</div>
)
}
-21
View File
@@ -1,21 +0,0 @@
import { useState, useEffect } from 'react'
import { BoxScreen } from './BoxScreen'
import { Screen } from './Screen'
export const LoadingScreen = ({ asBox = false }: { asBox?: boolean }) => {
// show the loading screen only after a little while to prevent flash of texts
const [show, setShow] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setShow(true), 500)
return () => clearTimeout(timeout)
}, [])
if (!show) {
return null
}
const Container = asBox ? BoxScreen : Screen
return (
<Container>
<p>Loading</p>
</Container>
)
}
@@ -1,5 +0,0 @@
import { BoxScreen } from './BoxScreen'
export const NotFoundScreen = () => {
return <BoxScreen title="Page not found" withBackButton />
}

Some files were not shown because too many files have changed in this diff Show More