Compare commits

..

21 Commits

Author SHA1 Message Date
lebaudantoine f20842b481 (frontend) allow user register to beta feature
I've created a beta form, allowing interested visitors to
register to be a beta user.

This form is yet created with Grist, could be enhance. I'll
investigate using PostHog.
2024-11-17 00:59:40 +01:00
lebaudantoine 3f3ef34053 💫(frontend) add fadein animation on home
Elements' apparition felt quite harsh.
Add an animation to fade in content.
Used a pre-defined animation in panda configs.
2024-11-17 00:53:21 +01:00
lebaudantoine cf647b872f 💫(frontend) add animation on interaction with slider
Add a minimal animation to enhance micro-interaction with our app.
I've tried being minimal, please feel free to enhance or tune this
animation.
2024-11-17 00:52:16 +01:00
lebaudantoine bc9f8c2239 (frontend) add slide feature in the IntroSlider
Avoided installing a dependency for such small piece of code.
I've implemented a naive component, which allow users explore
the slide presenting key feature of our app.

User experience should be ok. However, I might need to optimize
image format and loading strategy. First 'raw' iteration, gonna
optimize it in the future.
2024-11-17 00:50:26 +01:00
lebaudantoine d6372bfd86 💄(frontend) enhance home page
I've totally rework the homepage, heavily inspired by GMeet.
Goal: make it more pro, and polished.
2024-11-17 00:49:19 +01:00
lebaudantoine 4bbdb6abe6 (frontend) introduce learn more link component
Component providing a 'learn more' feature. Inspired by GMeet.
Naive component.
2024-11-17 00:48:33 +01:00
lebaudantoine 8b7708f3a3 🚸(frontend) update A component hover animation
I found original to hide text decoration on hover. It felt wrong
to me, I proposed a new animation. Please feel free to rework it
or enhance it in the future.
2024-11-17 00:47:38 +01:00
lebaudantoine 73a5511dcd 🚧(frontend) add intro slider component with feature slides
Created a component for an intro slider to showcase app features.
Each slide will include a minimal illustration and concise descriptions.

Work In Progress, Slide logic will be added in upcomming commits.
2024-11-17 00:45:04 +01:00
lebaudantoine 998a62041f 💄(frontend) remove bottom border on header
The bottom border on the header was unnecessary
and removed for a cleaner design.
2024-11-17 00:43:53 +01:00
lebaudantoine 6fcb69bd3c 🩹(frontend) fix horizontal scroll issue
Oooopsie while merging chat, I forgot to test horizontal scrolling.
It ended up creating a scroll issue. Fixed!

This fix might create a new issue for mobile users, I'll refactor
their layout ASAP.
2024-11-16 00:29:58 +01:00
lebaudantoine 6aed4cb751 🚸(frontend) auto-populate prejoin screen with user's default full name
Streamline user joining a room by pre-fillig name field from
their ProConnect data. Reduces friction and typos in the join flow.

This feature will be mostly used by new users. Recurrent users have
their previous choices persisted in local storage.
2024-11-16 00:29:58 +01:00
lebaudantoine f433f59d3f ♻️(frontend) prioritize full name display before email
Why? user friendly, and more human-readable identifier. Should
be enhance later on, to display both info together.
2024-11-16 00:29:58 +01:00
lebaudantoine fba9910705 (frontend) declare full and short name in APIUser type
API now serialize these two data. Save them when requestion /me.
2024-11-16 00:29:58 +01:00
lebaudantoine dcba3330f7 🛂(backend) request given and usual name scopes from ProConnect
Request the necessary scopes from ProConnect service.
Update configurations in every environments.

Note: ask given_name and usual_name scopes to get users' info.

(these scopes should be granted by default by ProConnect when
requesting a client id client secret)
2024-11-16 00:29:58 +01:00
lebaudantoine 82bb5f0f8b (backend) persist OIDC first name and last name while authenticating
Inspired by @sampaccoud's eee2003 commit on impress, adapt the code to be more
Pythonic. Add basic test coverage for user name synchronization on login. User
name fields now update automatically at each login when new data is available.

Note: current logic doesn't handle the case where a user with existing names
logs in with missing first/last names - should we clear the names then?

Removing a field that was present in the initial form is not a valid update
operation.
2024-11-15 23:38:31 +01:00
lebaudantoine 0fd06ef6c0 ♻️(backend) isolate authentication tests when dealing only with email
Refactor a test to narrow down its scope to email-related updates.
2024-11-15 23:38:31 +01:00
lebaudantoine bd4dec6f27 (backend) serialize user name-related fields
Needed in the frontend. Updated existing tests accordingly.
Names are not yet saved while logging the user, it will be
added in the upcomming commits.
2024-11-15 23:38:31 +01:00
lebaudantoine a987830fb3 🗑️(backend) remove useless methods on User
While removing analytics code in 15e922f, I forgot to remove
related code in the User model, used nowwhere else. Oopsie.
Fixed it!
2024-11-15 23:38:31 +01:00
lebaudantoine 7f09636791 (backend) add full_name short_name on User model
Following @sampaccoud's work on impress, add new fields to handle
user names in our application.

@sampaccoud preferred having a full and short names instead of
a basic first and last ones, to follow common good practices, and
avoid having frontend formating names (from my understanding).

Please see commit eee20033 on Impress.
2024-11-15 23:38:31 +01:00
lebaudantoine 3460ec8808 ✏️(backend) fix minor typo
login is a noun, the verb needs a whitespace.
2024-11-15 23:38:31 +01:00
lebaudantoine 86cda75b39 🐛(frontend) tune connection parameters for low bandwidth clients
DINUM users face connection issues in bandwidth-constrained environments.
They cannot reach their room, the peer connection timeouts while negotiating
TURN/TLS handshake (from our current understanding).

I am not sure this issue is linked to those parameters, at least try something.
2024-11-15 23:38:31 +01:00
31 changed files with 835 additions and 113 deletions
+33 -4
View File
@@ -22,7 +22,19 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
(
_("Personal info"),
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(
_("Permissions"),
{
@@ -52,6 +64,8 @@ class UserAdmin(auth_admin.UserAdmin):
"sub",
"admin_email",
"email",
"full_name",
"short_name",
"is_active",
"is_staff",
"is_superuser",
@@ -60,9 +74,24 @@ class UserAdmin(auth_admin.UserAdmin):
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
search_fields = ("id", "sub", "admin_email", "email")
ordering = (
"is_active",
"-is_superuser",
"-is_staff",
"-is_device",
"-updated_at",
"full_name",
)
readonly_fields = (
"id",
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
class ResourceAccessInline(admin.TabularInline):
+2 -2
View File
@@ -14,8 +14,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email"]
read_only_fields = ["id", "email"]
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class ResourceAccessSerializerMixin:
+37 -1
View File
@@ -75,11 +75,16 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
email = user_info.get("email")
user = self.get_existing_user(sub, email)
claims = {
"email": email,
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
}
if not user and self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(
sub=sub,
email=email,
password="!", # noqa: S106
**claims,
)
elif not user:
return None
@@ -87,6 +92,8 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
return user
def get_existing_user(self, sub, email):
@@ -104,3 +111,32 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
_("Multiple user accounts share a common email.")
) from e
return None
@staticmethod
def compute_full_name(user_info):
"""Compute user's full name based on OIDC fields in settings."""
full_name = " ".join(
filter(
None,
(
user_info.get(field)
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
),
)
)
return full_name or None
@staticmethod
def update_user_if_needed(user, claims):
"""Update user claims if they have changed."""
user_fields = vars(user.__class__) # Get available model fields
updated_claims = {
key: value
for key, value in claims.items()
if value and key in user_fields and value != getattr(user, key)
}
if not updated_claims:
return
User.objects.filter(sub=user.sub).update(**updated_claims)
+2
View File
@@ -23,6 +23,8 @@ class UserFactory(factory.django.DjangoModelFactory):
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
@@ -0,0 +1,23 @@
# Generated by Django 5.1.1 on 2024-11-13 09:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_recording_mode'),
]
operations = [
migrations.AddField(
model_name='user',
name='full_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
),
migrations.AddField(
model_name='user',
name='short_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='short name'),
)
]
+5 -9
View File
@@ -141,11 +141,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to login to the admin site
# stores the email used by staff users to log in to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(
_("short name"), max_length=100, null=True, blank=True
)
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
@@ -204,13 +207,6 @@ 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]}"
def get_resource_roles(resource: models.Model, user: User) -> List[str]:
"""
@@ -62,15 +62,14 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
def test_authentication_getter_new_user_with_email(monkeypatch):
"""
If no user matches, a user should be created.
User's email and name should be set on the identity.
The "email" field on the User model should not be set as it is reserved for staff users.
User's info contains an email, created user's email should be filled.
"""
klass = OIDCAuthenticationBackend()
email = "meet@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
@@ -80,6 +79,34 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert models.User.objects.count() == 1
@pytest.mark.parametrize("email", [None, "johndoe@foo.com"])
def test_authentication_getter_new_user_with_names(monkeypatch, email):
"""
If no user matches, a user should be created.
User's info contains name-related field, created user's full and short names should be filled,
whether the email is filled
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "123", "given_name": "John", "usual_name": "Doe", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -285,3 +312,103 @@ def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, se
)
assert user != db_user
@pytest.mark.parametrize(
"given_name, usual_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
given_name, usual_name, email, django_assert_num_queries, monkeypatch
):
"""It should update the email or name fields on the user when they change."""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": email,
"given_name": given_name,
"usual_name": usual_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{given_name:s} {usual_name:s}"
assert user.short_name == given_name
@pytest.mark.parametrize(
"user_info, expected_name",
[
({"given_name": "John", "family_name": "Doe"}, "John Doe"),
(
{"given_name": "John", "middle_name": "M", "family_name": "Doe"},
"John M Doe",
),
({"family_name": "Doe"}, "Doe"),
({"given_name": "", "family_name": ""}, None),
({}, None),
],
)
def test_compute_full_name(user_info, expected_name, settings):
"""Test full name computation from OIDC user info fields."""
settings.OIDC_USERINFO_FULLNAME_FIELDS = [
"given_name",
"middle_name",
"family_name",
]
klass = OIDCAuthenticationBackend()
assert klass.compute_full_name(user_info) == expected_name
def test_compute_full_name_no_fields(settings):
"""Test full name computation with empty field configuration."""
settings.OIDC_USERINFO_FULLNAME_FIELDS = []
klass = OIDCAuthenticationBackend()
assert klass.compute_full_name({"given_name": "John"}) is None
@pytest.mark.parametrize(
"claims",
[
{"email": "john.doe@example.com"}, # Same data - no change needed
{"email": ""}, # Empty strings should not override
{"non_related_field": "foo"}, # Unrelated fields should be ignored
{}, # Empty claims should not affect user
{"email": None}, # None values should be ignored
],
)
def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
"""Test that user attributes remain unchanged when claims don't require updates."""
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
klass = OIDCAuthenticationBackend()
with django_assert_num_queries(0):
klass.update_user_if_needed(user, claims)
user.refresh_from_db()
assert user.email == "john.doe@example.com"
@@ -301,6 +301,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"user": {
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
},
"resource": str(room.id),
"role": user_access.role,
@@ -310,6 +312,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"user": {
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -374,6 +378,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"user": {
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -383,6 +389,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"user": {
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
},
"resource": str(room.id),
"role": user_access.role,
+2
View File
@@ -96,6 +96,8 @@ def test_api_users_retrieve_me_authenticated():
assert response.json() == {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
}
@@ -44,12 +44,3 @@ 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 == ""
+10
View File
@@ -390,6 +390,16 @@ class Base(Configuration):
OIDC_REDIRECT_FIELD_NAME = values.Value(
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
)
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
default=["given_name", "usual_name"],
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None,
)
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
default="given_name",
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

@@ -1,4 +1,6 @@
export type ApiUser = {
id: string
email: string
full_name: string
last_name: string
}
@@ -0,0 +1,241 @@
import firstSlide from '@/assets/intro-slider/1_solo.png'
import secondSlide from '@/assets/intro-slider/2_multiple.png'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { styled } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Button, LinkButton } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
// todo - extract in a proper env variable
const BETA_USERS_FORM_URL =
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
const Heading = styled('h2', {
base: {
width: 'fit-content',
marginBottom: 0,
fontSize: '1.5rem',
marginTop: '0.75rem',
lineHeight: '2rem',
maxWidth: '23rem',
textAlign: 'center',
textWrap: 'pretty',
},
})
const Body = styled('p', {
base: {
maxWidth: '23rem',
textAlign: 'center',
textWrap: 'pretty',
lineHeight: '1.2rem',
fontSize: '1rem',
},
})
const Image = styled('img', {
base: {
maxHeight: '330px',
height: '100%',
width: 'fit-content',
},
})
const Dot = styled('div', {
base: {
borderRadius: '50%',
display: 'inline-block',
height: '.375rem',
margin: '0 .25rem',
width: '.375rem',
},
variants: {
selected: {
true: {
backgroundColor: '#000091',
},
false: {
backgroundColor: '#CACAFB',
},
},
},
})
const Container = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
width: '100%',
justifyContent: 'space-between',
textAlign: 'center',
},
})
const ButtonVerticalCenter = styled('div', {
base: {
marginTop: '10.3125rem',
transform: 'translateY(-50%)',
},
})
const SlideContainer = styled('div', {
base: {
alignItems: 'stretch',
display: 'flex',
position: 'relative',
},
})
const Slide = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5rem',
justifyContent: 'start',
minHeight: '550px',
minWidth: '200px',
width: '22.625rem',
},
variants: {
visible: {
true: {
visibility: 'visible',
position: 'static',
},
false: {
visibility: 'hidden',
position: 'absolute',
},
},
},
defaultVariants: {
visible: false,
},
})
const TextAnimation = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
gap: '0.5rem',
},
variants: {
visible: {
true: {
opacity: 1,
transform: 'none',
transition: 'opacity ease-in .3s, transform ease-in .3s',
},
false: {
opacity: 0,
transform: 'translateX(-30%)',
},
},
},
})
type Slide = {
key: string
img: string
isAvailableInBeta?: boolean
}
// todo - optimize how images are imported
const SLIDES: Slide[] = [
{
key: 'slide1',
img: firstSlide,
},
{
key: 'slide2',
img: secondSlide,
},
{
key: 'slide3',
img: thirdSlide,
isAvailableInBeta: true,
},
]
export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const NUMBER_SLIDES = SLIDES.length
return (
<Container>
<div
className={css({
display: 'flex',
flexGrow: 1,
justifyContent: 'center',
})}
>
<div>
<ButtonVerticalCenter>
<Button
square
invisible
aria-label={t('previous.label')}
tooltip={t('previous.tooltip')}
onPress={() => setSlideIndex(slideIndex - 1)}
isDisabled={slideIndex == 0}
>
<RiArrowLeftSLine />
</Button>
</ButtonVerticalCenter>
</div>
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex}>
<Image src={slide.img} alt={t(`${slide.key}.imgAlt`)} />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body>
{slide.isAvailableInBeta && (
<LinkButton
href={BETA_USERS_FORM_URL}
tooltip={t('beta.tooltip')}
variant={'primary'}
size={'sm'}
style={{ marginTop: '1rem', width: 'fit-content' }}
>
{t('beta.text')}
</LinkButton>
)}
</TextAnimation>
</Slide>
))}
</SlideContainer>
<div>
<ButtonVerticalCenter>
<Button
square
invisible
aria-label={t('next.label')}
tooltip={t('next.tooltip')}
onPress={() => setSlideIndex(slideIndex + 1)}
isDisabled={slideIndex == NUMBER_SLIDES - 1}
>
<RiArrowRightSLine />
</Button>
</ButtonVerticalCenter>
</div>
</div>
<div
className={css({
marginTop: '0.5rem',
})}
>
{SLIDES.map((_, index) => (
<Dot key={index} selected={index == slideIndex} />
))}
</div>
</Container>
)
}
@@ -0,0 +1,23 @@
import { A, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
const MANIFEST_LINK =
'https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/'
export const MoreLink = () => {
const { t } = useTranslation('home')
return (
<Text as={'p'} variant={'sm'} style={{ padding: '1rem 0' }}>
<A
href={MANIFEST_LINK}
target="_blank"
rel="noopener noreferrer"
aria-label={t('moreLinkLabel')}
>
{t('moreLink')}
</A>{' '}
{t('moreAbout')}
</Text>
)
}
+199 -69
View File
@@ -1,10 +1,9 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
import { Button, Menu, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { Button, Menu } from '@/primitives'
import { HStack, styled } 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 { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
@@ -14,7 +13,127 @@ import { usePersistentUserChoices } from '@livekit/components-react'
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { useState } from 'react'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useState } from 'react'
import { css } from '@/styled-system/css'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
<div
className={css({
alignItems: 'center',
display: 'inline-flex',
flexDirection: 'column',
height: '100%',
minHeight: '100%',
justifyContent: 'normal',
padding: '0 1rem',
width: 'calc(100%-2rem)',
opacity: 0,
animation: '.5s ease-in fade 0s forwards',
lg: {
flexDirection: 'row',
justifyContent: 'space-evenly',
width: '100%',
padding: 0,
},
})}
>
{children}
</div>
)
}
const LeftColumn = ({ children }: { children?: ReactNode }) => {
return (
<div
className={css({
alignItems: 'center',
textAlign: 'center',
display: 'inline-flex',
flexDirection: 'column',
flexBasis: 'auto',
flexShrink: 0,
maxWidth: '39rem',
width: '100%',
padding: '1rem 3%',
marginTop: 'auto',
lg: {
margin: 0,
textAlign: 'left',
alignItems: 'flex-start',
flexShrink: '1',
flexBasis: '45rem',
maxWidth: '45rem',
padding: '1em 3em',
},
})}
>
{children}
</div>
)
}
const RightColumn = ({ children }: { children?: ReactNode }) => {
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
overflow: 'hidden',
padding: '1rem 3%',
marginBottom: 'auto',
flexBasis: 'auto',
flexShrink: 0,
maxWidth: '39rem',
lg: {
margin: 0,
flexBasis: '45%',
padding: '1em 3em',
},
})}
>
{children}
</div>
)
}
const Separator = styled('div', {
base: {
borderBottom: '1px solid',
borderColor: '#747775',
marginTop: '2.5rem',
maxWidth: '30rem',
width: '100%',
},
})
const Heading = styled('h1', {
base: {
fontSize: '3rem',
lineHeight: '3.2rem',
letterSpacing: '0',
fontWeight: '500',
fontStyle: 'normal',
fontStretch: 'normal',
fontOpticalSizing: 'auto',
marginBottom: 0,
paddingBottom: '0.75rem',
},
})
const IntroText = styled('div', {
base: {
marginBottom: '3rem',
fontSize: '1.375rem',
lineHeight: '1.8rem',
textWrap: 'pretty',
maxWidth: '28rem',
},
})
export const Home = () => {
const { t } = useTranslation('home')
@@ -30,72 +149,83 @@ export const Home = () => {
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" alignItems="start">
{isLoggedIn ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
<Columns>
<LeftColumn>
<Heading>{t('heading')}</Heading>
<IntroText>{t('intro')}</IntroText>
<HStack gap="gutter" alignItems="start">
{isLoggedIn ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
</Button>
<RACMenu>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoomId(data.slug)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<ProConnectButton hint={false} />
)}
<DialogTrigger>
<Button
variant="primary"
outline
style={{
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
}}
>
{t('joinMeeting')}
</Button>
<RACMenu>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoomId(data.slug)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<ProConnectButton />
)}
<DialogTrigger>
<Button
variant="primary"
outline
style={{
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
}}
>
{t('joinMeeting')}
</Button>
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
</Centered>
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
<Separator />
<div
className={css({
display: { base: 'none', lg: 'inline' },
})}
>
<MoreLink />
</div>
</LeftColumn>
<RightColumn>
<IntroSlider />
<div
className={css({
display: { base: 'inline', lg: 'none' },
})}
>
<MoreLink />
</div>
</RightColumn>
</Columns>
<LaterMeetingDialog
roomId={laterRoomId || ''}
onOpenChange={() => setLaterRoomId(null)}
@@ -93,7 +93,7 @@ export const Conference = ({
// These settings help ensure successful connections in poor network conditions
const connectOptions = {
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
peerConnectionTimeout: 60, // Default: 15s. Extended for slow TURN/TLS negotiation
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
}
return (
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { useUser } from '@/features/auth'
export const Join = ({
onSubmit,
@@ -9,6 +10,7 @@ export const Join = ({
onSubmit: (choices: LocalUserChoices) => void
}) => {
const { t } = useTranslation('rooms')
const { user } = useUser()
return (
<Screen layout="centered">
@@ -20,6 +22,7 @@ export const Join = ({
camLabel={t('join.videoinput.label')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.usernameLabel')}
defaults={{ username: user?.full_name }}
/>
</CenteredContent>
</Screen>
@@ -149,7 +149,13 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { isSidePanelOpen } = useSidePanel()
return (
<div className="lk-video-conference" {...props}>
<div
className="lk-video-conference"
{...props}
style={{
overflowX: 'hidden',
}}
>
{isWeb() && (
<LayoutContextProvider
value={layoutContext}
@@ -18,7 +18,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
<P>
<Trans
i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.email }}
values={{ user: user?.full_name || user?.email }}
components={[<Badge />]}
/>
</P>
@@ -48,7 +48,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
<P>
<Trans
i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.email }}
values={{ user: user?.full_name || user?.email }}
components={[<Badge />]}
/>
</P>
+1 -4
View File
@@ -22,9 +22,6 @@ export const Header = () => {
return (
<div
className={css({
borderBottomColor: 'box.border',
borderBottomWidth: 1,
borderBottomStyle: 'solid',
paddingY: 1,
paddingX: 1,
flexShrink: 0,
@@ -76,7 +73,7 @@ export const Header = () => {
tooltip={t('loggedInUserTooltip')}
tooltipType="delayed"
>
{user.email}
{user?.full_name || user?.email}
</Button>
<MenuList
items={[{ value: 'logout', label: t('logout') }]}
+32
View File
@@ -10,6 +10,9 @@
"joinMeetingTipContent": "",
"joinMeetingTipHeading": "",
"loginToCreateMeeting": "",
"moreLinkLabel": "",
"moreLink": "",
"moreAbout": "",
"createMenu": {
"laterOption": "",
"instantOption": ""
@@ -20,5 +23,34 @@
"copy": "",
"copied": "",
"permissions": ""
},
"introSlider": {
"previous": {
"label": "",
"tooltip": ""
},
"beta": {
"text": "",
"tooltip": ""
},
"next": {
"label": "",
"tooltip": ""
},
"slide1": {
"title": "",
"body": "",
"imgAlt": ""
},
"slide2": {
"title": "",
"body": "",
"imgAlt": ""
},
"slide3": {
"title": "",
"body": "",
"imgAlt": ""
}
}
}
+34 -2
View File
@@ -1,7 +1,7 @@
{
"createMeeting": "Create a meeting",
"heading": "Welcome in Visio",
"intro": "Work easily, from anywhere.",
"heading": "Simple and Secure Video Conferencing",
"intro": "Communicate and work with ease, without compromising your sovereignty",
"joinInputError": "Use a meeting link or code. Examples:",
"joinInputExample": "URL or 10-letter code",
"joinInputLabel": "Meeting link",
@@ -10,6 +10,9 @@
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
"joinMeetingTipHeading": "Did you know?",
"loginToCreateMeeting": "Login to create a meeting",
"moreLinkLabel": "Learn more - new tab",
"moreLink": "Learn more",
"moreAbout": "about Visio",
"createMenu": {
"laterOption": "Create a meeting for a later date",
"instantOption": "Start an instant meeting"
@@ -20,5 +23,34 @@
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"permissions": "People with this link do not need your permission to join this meeting."
},
"introSlider": {
"previous": {
"label": "previous",
"tooltip": "previous"
},
"next": {
"label": "next",
"tooltip": "next"
},
"beta": {
"text": "Join the beta",
"tooltip": "Fill out the form"
},
"slide1": {
"title": "Try Visio to simplify your daily tasks",
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
},
"slide2": {
"title": "Host group calls without limits",
"body": "Unlimited time meetings, up to 15 participants, with smooth and high-quality communication, no matter the group size.",
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
},
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
}
}
}
+34 -2
View File
@@ -1,7 +1,7 @@
{
"createMeeting": "Créer une réunion",
"heading": "Visio",
"intro": "Collaborez en toute simplicité, où que vous soyez.",
"heading": "Visioconférences simples et sécurisées",
"intro": "Communiquez et travaillez en toute simplicité, sans compromis sur votre souveraineté",
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
"joinInputLabel": "Lien complet ou code de la réunion",
@@ -10,6 +10,9 @@
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
"joinMeetingTipHeading": "Astuce",
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
"moreLink": "En savoir plus",
"moreAbout": "sur Visio",
"createMenu": {
"laterOption": "Créer une réunion pour une date ultérieure",
"instantOption": "Démarrer une réunion instantanée"
@@ -20,5 +23,34 @@
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
},
"introSlider": {
"previous": {
"label": "précédent",
"tooltip": "précédent"
},
"next": {
"label": "suivant",
"tooltip": "suivant"
},
"beta": {
"text": "Essayer la beta",
"tooltip": "Accéder au formulaire"
},
"slide1": {
"title": "Essayez Visio pour simplifier votre quotidien",
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
},
"slide2": {
"title": "Organisez des appels de groupe sans limite",
"body": "Réunions sans limite de temps, jusqu'à 15 participants, avec une communication fluide et de haute qualité, quel que soit le nombre.",
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
},
"slide3": {
"title": "Transformez vos réunions avec l'IA",
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ const link = cva({
borderRadius: 2,
transition: 'all 0.2s',
'&[data-hovered]': {
textDecoration: 'none',
textDecorationThickness: '2px',
},
'&[data-pressed]': {
textDecoration: 'underline',
+1 -1
View File
@@ -24,7 +24,7 @@ backend:
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_RP_SCOPES: "openid email given_name usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
@@ -42,7 +42,7 @@ backend:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_RP_SCOPES: "openid email given_name usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://visio.numerique.gouv.fr
@@ -41,7 +41,7 @@ backend:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_RP_SCOPES: "openid email given_name usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio-staging.beta.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://visio-staging.beta.numerique.gouv.fr