mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-07 16:05:39 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a42b126357 | |||
| adc74f846c |
@@ -23,6 +23,8 @@ and this project adheres to
|
|||||||
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
|
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
|
||||||
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
||||||
- 🐛(frontend) restore automatic lower-hand on speaking
|
- 🐛(frontend) restore automatic lower-hand on speaking
|
||||||
|
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
|
||||||
|
- 🔒️(backend) reject inactive users in resource server backend
|
||||||
|
|
||||||
## [1.30.0] - 2026-09-01
|
## [1.30.0] - 2026-09-01
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--avatar-cap-height: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Header-beforeLogo {
|
.Header-beforeLogo {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ Let's say you want to change the font of our application to a custom font. You c
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
|
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--avatar-cap-height: 0.7;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ class ResourceServerBackend(LaSuiteBackend):
|
|||||||
if user is None and settings.OIDC_CREATE_USER:
|
if user is None and settings.OIDC_CREATE_USER:
|
||||||
user = self.create_user(sub)
|
user = self.create_user(sub)
|
||||||
|
|
||||||
|
if user is not None and not user.is_active:
|
||||||
|
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||||
|
raise SuspiciousOperation("User account is disabled.")
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def create_user(self, sub):
|
def create_user(self, sub):
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Tests for the external API ResourceServerBackend."""
|
||||||
|
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import responses
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.external_api.authentication import ResourceServerBackend
|
||||||
|
from core.factories import UserFactory
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(sub):
|
||||||
|
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_active():
|
||||||
|
"""An existing active user matching the sub should be returned."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
result = ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == user
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_inactive():
|
||||||
|
"""An inactive user should be rejected even with a valid token."""
|
||||||
|
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
|
||||||
|
ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_creates(settings):
|
||||||
|
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = True
|
||||||
|
|
||||||
|
result = ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.sub == "new-sub"
|
||||||
|
assert result.is_active is True
|
||||||
|
assert User.objects.filter(sub="new-sub").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_no_creation(settings):
|
||||||
|
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = False
|
||||||
|
|
||||||
|
result = ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert not User.objects.filter(sub="new-sub").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_rooms_list_resource_server_inactive_user(settings):
|
||||||
|
"""End to end: a valid introspected token for an inactive user should get 401."""
|
||||||
|
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"active": True,
|
||||||
|
"sub": user.sub,
|
||||||
|
"scope": "lasuite_meet",
|
||||||
|
"client_id": "app",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
|
||||||
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "login failed" in str(response.data).lower()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
||||||
import React, { useLayoutEffect, useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
|
|
||||||
const avatar = cva({
|
const avatar = cva({
|
||||||
base: {
|
base: {
|
||||||
@@ -28,24 +28,17 @@ const avatar = cva({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Instantiating a segmenter is expensive; create it once and reuse it.
|
|
||||||
const graphemeSegmenter =
|
const graphemeSegmenter =
|
||||||
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the first user-perceived character. Some Unicode characters span
|
|
||||||
* multiple UTF-16 code units, so a naive index into the string can split them
|
|
||||||
* and yield a broken glyph.
|
|
||||||
*/
|
|
||||||
const getFirstGrapheme = (value: string): string => {
|
const getFirstGrapheme = (value: string): string => {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
if (graphemeSegmenter) {
|
if (graphemeSegmenter) {
|
||||||
const [first] = graphemeSegmenter.segment(value)
|
const [first] = graphemeSegmenter.segment(value)
|
||||||
return first?.segment ?? ''
|
return first?.segment ?? ''
|
||||||
}
|
}
|
||||||
// Fallback: keeps single code points intact (including surrogate pairs).
|
|
||||||
return Array.from(value)[0] ?? ''
|
return Array.from(value)[0] ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,36 +59,6 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
export const Avatar = React.memo(
|
export const Avatar = React.memo(
|
||||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||||
const initials = useMemo(() => getInitials(name), [name])
|
const initials = useMemo(() => getInitials(name), [name])
|
||||||
const textRef = React.useRef<SVGTextElement>(null)
|
|
||||||
const [offsetY, setOffsetY] = React.useState(0)
|
|
||||||
|
|
||||||
// Optically center the initials: measure the ink bounding box of the
|
|
||||||
// rendered glyphs and shift them so the box's center sits at the middle
|
|
||||||
// of the viewBox. Works for any font, weight or glyph shape, unlike a
|
|
||||||
// hand-tuned dy offset. getBBox() is in local (pre-transform)
|
|
||||||
// coordinates, so applying the translation never changes the measure.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const text = textRef.current
|
|
||||||
if (!text) return
|
|
||||||
|
|
||||||
const center = () => {
|
|
||||||
const box = text.getBBox()
|
|
||||||
// A hidden element measures as an empty box; keep the default then.
|
|
||||||
if (box.height === 0) return
|
|
||||||
setOffsetY(50 - (box.y + box.height / 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
center()
|
|
||||||
// Glyph metrics can change once webfonts finish loading.
|
|
||||||
let cancelled = false
|
|
||||||
document.fonts?.ready.then(() => {
|
|
||||||
if (!cancelled) center()
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [initials])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ backgroundColor: bgColor, ...style }}
|
style={{ backgroundColor: bgColor, ...style }}
|
||||||
@@ -108,15 +71,16 @@ export const Avatar = React.memo(
|
|||||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
ref={textRef}
|
|
||||||
x="50"
|
x="50"
|
||||||
y="50"
|
y={50}
|
||||||
transform={`translate(0 ${offsetY})`}
|
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
|
||||||
fontSize="52"
|
fontSize="52"
|
||||||
fontWeight="500"
|
fontWeight="500"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
|
className={css({
|
||||||
|
transform:
|
||||||
|
'translateY(calc(var(--avatar-cap-height, 0.7) * 0.5em))',
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ body,
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--avatar-cap-height: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
html.font-lexend {
|
html.font-lexend {
|
||||||
--fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif;
|
--fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user