Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd943068c2 | |||
| 6605eae6d2 | |||
| ae0e8ade7b | |||
| eeb8bd6142 | |||
| 558a331b71 |
@@ -4,9 +4,11 @@ from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.user_token import viewsets as user_token_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -16,6 +18,11 @@ router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
|
||||
router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
)
|
||||
router.register(
|
||||
"user-tokens",
|
||||
user_token_viewsets.UserTokenViewset,
|
||||
basename="user_tokens",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
@@ -24,6 +31,7 @@ urlpatterns = [
|
||||
[
|
||||
*router.urls,
|
||||
*oidc_urls,
|
||||
*resource_server_urls,
|
||||
path("config/", get_frontend_configuration, name="config"),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Wip."""
|
||||
|
||||
from knox.models import get_token_model
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class TokenReadSerializer(serializers.ModelSerializer):
|
||||
"""Serialize token for list purpose."""
|
||||
|
||||
class Meta:
|
||||
model = get_token_model()
|
||||
fields = ["digest", "created", "expiry"]
|
||||
read_only_fields = ["digest", "created", "expiry"]
|
||||
|
||||
|
||||
class TokenCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serialize token for creation purpose."""
|
||||
|
||||
class Meta:
|
||||
model = get_token_model()
|
||||
fields = ["user", "digest", "token_key", "created", "expiry"]
|
||||
read_only_fields = ["digest", "token_key", "created", "expiry"]
|
||||
extra_kwargs = {"user": {"write_only": True}}
|
||||
|
||||
def create(self, validated_data):
|
||||
"""The default knox token create manager returns a tuple."""
|
||||
instance, token = super().create(validated_data)
|
||||
instance.token_key = token # warning do not save this
|
||||
return instance
|
||||
@@ -0,0 +1,50 @@
|
||||
"""API endpoints for user token management"""
|
||||
|
||||
from knox.models import get_token_model
|
||||
from rest_framework import mixins, permissions, viewsets
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
|
||||
from . import serializers
|
||||
|
||||
|
||||
class UserTokenViewset(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""API ViewSet for user invitations to document.
|
||||
|
||||
This view access is restricted to the session ie from frontend.
|
||||
|
||||
GET /api/v1.0/user-token/
|
||||
Return list of existing tokens.
|
||||
|
||||
POST /api/v1.0/user-token/
|
||||
Return newly created token.
|
||||
|
||||
DELETE /api/v1.0/user-token/<token_id>/
|
||||
Delete targeted token.
|
||||
"""
|
||||
|
||||
authentication_classes = [SessionAuthentication]
|
||||
pagination_class = None
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
queryset = get_token_model().objects.all()
|
||||
serializer_class = serializers.TokenReadSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset restricted to the logged-in user."""
|
||||
queryset = super().get_queryset()
|
||||
queryset = queryset.filter(user_id=self.request.user.pk)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return serializers.TokenCreateSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Enforce request data to use current user."""
|
||||
request.data["user"] = self.request.user.pk
|
||||
return super().create(request, *args, **kwargs)
|
||||
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from socket import gethostbyname, gethostname
|
||||
@@ -224,6 +225,7 @@ class Base(Configuration):
|
||||
# Third party apps
|
||||
"corsheaders",
|
||||
"dockerflow.django",
|
||||
"knox",
|
||||
"rest_framework",
|
||||
"parler",
|
||||
"easy_thumbnails",
|
||||
@@ -257,8 +259,9 @@ class Base(Configuration):
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
|
||||
"knox.auth.TokenAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"lasuite.oidc_resource_server.authentication.ResourceServerAuthentication",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
@@ -455,6 +458,69 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# OIDC - Meet as a resource server
|
||||
OIDC_OP_URL = values.Value(
|
||||
default=None, environ_name="OIDC_OP_URL", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_TIMEOUT = values.IntegerValue(
|
||||
default=3, environ_name="OIDC_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
|
||||
|
||||
OIDC_RS_BACKEND_CLASS = "lasuite.oidc_resource_server.backend.ResourceServerBackend"
|
||||
OIDC_RS_AUDIENCE_CLAIM = values.Value( # The claim used to identify the audience
|
||||
default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_PRIVATE_KEY_STR = values.Value(
|
||||
default=None,
|
||||
environ_name="OIDC_RS_PRIVATE_KEY_STR",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
|
||||
default="RSA",
|
||||
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_ALGO = values.Value(
|
||||
default="RSA-OAEP",
|
||||
environ_name="OIDC_RS_ENCRYPTION_ALGO",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
|
||||
default="A256GCM",
|
||||
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_CLIENT_ID = values.Value(
|
||||
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_CLIENT_SECRET = values.Value(
|
||||
None,
|
||||
environ_name="OIDC_RS_CLIENT_SECRET",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_SIGNING_ALGO = values.Value(
|
||||
default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_SCOPES = values.ListValue(
|
||||
[], environ_name="OIDC_RS_SCOPES", environ_prefix=None
|
||||
)
|
||||
|
||||
# User token (knox)
|
||||
REST_KNOX = {
|
||||
"SECURE_HASH_ALGORITHM": "hashlib.sha512",
|
||||
"AUTH_TOKEN_CHARACTER_LENGTH": 64,
|
||||
"TOKEN_TTL": datetime.timedelta(hours=24 * 7),
|
||||
"TOKEN_LIMIT_PER_USER": None,
|
||||
"AUTO_REFRESH": False,
|
||||
"AUTO_REFRESH_MAX_TTL": None,
|
||||
"MIN_REFRESH_INTERVAL": 60,
|
||||
"AUTH_HEADER_PREFIX": "Token",
|
||||
}
|
||||
|
||||
# Video conference configuration
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
"api_key": values.Value(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
|
||||
|
||||
@@ -36,6 +36,7 @@ dependencies = [
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-rest-knox==5.0.2",
|
||||
"django-storages[s3]==1.14.5",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.9",
|
||||
|
||||
@@ -15,6 +15,15 @@ export const fetchApi = async <T = Record<string, unknown>>(
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
// Handle empty responses (like for DELETE requests)
|
||||
if (
|
||||
response.status === 204 ||
|
||||
response.headers.get('content-length') === '0'
|
||||
) {
|
||||
return {} as T
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, result)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
import { Button } from '@/primitives'
|
||||
|
||||
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
||||
|
||||
@@ -12,6 +13,12 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||
return (
|
||||
<Dialog title={t('dialog.heading')} {...props}>
|
||||
<Button
|
||||
onClick={() => (window.location.href = '/user-tokens')}
|
||||
aria-label={t('API Tokens', 'API Tokens')}
|
||||
>
|
||||
{t('API Tokens', 'API Tokens')}
|
||||
</Button>
|
||||
<H lvl={2}>{t('account.heading')}</H>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useCreateUserToken, createUserToken } from './useCreateUserToken'
|
||||
export { useDeleteUserToken, deleteUserToken } from './useDeleteUserToken'
|
||||
export { useListUserTokens, listUserTokens } from './useListUserTokens'
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
|
||||
import { NewUserToken } from '../types'
|
||||
|
||||
export const createUserToken = async (): Promise<NewUserToken> => {
|
||||
return fetchApi('user-tokens/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateUserToken(
|
||||
options?: UseMutationOptions<NewUserToken, ApiError>
|
||||
) {
|
||||
return useMutation<NewUserToken, ApiError>({
|
||||
mutationFn: createUserToken,
|
||||
onSuccess: options?.onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
|
||||
export type DeleteUserTokenParams = {
|
||||
digest: string
|
||||
}
|
||||
|
||||
export const deleteUserToken = async ({
|
||||
digest,
|
||||
}: DeleteUserTokenParams): Promise<void> => {
|
||||
return fetchApi(`user-tokens/${digest}/`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteUserToken(
|
||||
options?: UseMutationOptions<void, ApiError, DeleteUserTokenParams>
|
||||
) {
|
||||
return useMutation<void, ApiError, DeleteUserTokenParams>({
|
||||
mutationFn: deleteUserToken,
|
||||
onSuccess: options?.onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { UserToken } from '../types'
|
||||
|
||||
export const listUserTokens = async (): Promise<UserToken[]> => {
|
||||
return fetchApi('user-tokens/')
|
||||
}
|
||||
|
||||
export function useListUserTokens(
|
||||
options?: UseQueryOptions<UserToken[], ApiError>
|
||||
) {
|
||||
return useQuery<UserToken[], ApiError>({
|
||||
queryKey: ['userTokens'],
|
||||
queryFn: listUserTokens,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UserTokens } from './routes/UserTokens'
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createUserToken, deleteUserToken, listUserTokens } from '../api/index'
|
||||
import { NewUserToken, UserToken } from '../types'
|
||||
import { H, Button } from '@/primitives'
|
||||
|
||||
// Add id to UserToken type for table compatibility
|
||||
interface UserTokenWithId extends UserToken {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const UserTokens = () => {
|
||||
const [tokens, setTokens] = useState<UserTokenWithId[]>([])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [newToken, setNewToken] = useState<NewUserToken | null>(null)
|
||||
const [modal, setModal] = useState<{ open: boolean; message: string }>({
|
||||
open: false,
|
||||
message: '',
|
||||
})
|
||||
|
||||
const fetchTokens = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const fetchedTokens = await listUserTokens()
|
||||
setTokens(fetchedTokens.map((token) => ({ ...token, id: token.digest })))
|
||||
} catch (err) {
|
||||
setError(
|
||||
'Failed to fetch tokens. Please ensure you are logged in and have permissions.'
|
||||
)
|
||||
setModal({ open: true, message: 'Failed to fetch tokens' })
|
||||
console.error(err)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchTokens()
|
||||
}, [fetchTokens])
|
||||
|
||||
const handleCreateToken = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setNewToken(null)
|
||||
try {
|
||||
const generatedToken = await createUserToken()
|
||||
setNewToken(generatedToken)
|
||||
setModal({
|
||||
open: true,
|
||||
message:
|
||||
'Token created successfully! Store the token key safely, it will not be shown again.',
|
||||
})
|
||||
void fetchTokens()
|
||||
} catch (err) {
|
||||
setError('Failed to create token.')
|
||||
setModal({ open: true, message: 'Failed to create token' })
|
||||
console.error(err)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleDeleteToken = async (digest: string) => {
|
||||
if (!window.confirm('Are you sure you want to delete this token?')) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await deleteUserToken({ digest })
|
||||
setModal({ open: true, message: 'Token deleted successfully!' })
|
||||
setNewToken(null)
|
||||
await fetchTokens()
|
||||
} catch (err) {
|
||||
setError('Failed to delete token.')
|
||||
setModal({ open: true, message: 'Failed to delete token' })
|
||||
console.error(err)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
padding: '16px 24px 24px 24px',
|
||||
boxSizing: 'border-box',
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<H lvl={2} style={{ marginBottom: 16 }}>
|
||||
User token management
|
||||
</H>
|
||||
<Button
|
||||
onPress={handleCreateToken}
|
||||
isDisabled={isLoading}
|
||||
variant="primary"
|
||||
>
|
||||
{isLoading ? 'Generating...' : 'Generate New Token'}
|
||||
</Button>
|
||||
</div>
|
||||
{newToken && (
|
||||
<div
|
||||
style={{
|
||||
background: '#e6f4ea',
|
||||
padding: 16,
|
||||
borderRadius: 10,
|
||||
marginBottom: 16,
|
||||
marginTop: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<span style={{ marginLeft: 16 }}>
|
||||
<strong>New Token:</strong> <code>{newToken.token_key}</code>
|
||||
</span>
|
||||
<span style={{ marginLeft: 16 }}>
|
||||
<strong>Digest:</strong> <code>{newToken.digest}</code>
|
||||
</span>
|
||||
<span style={{ marginLeft: 16 }}>
|
||||
<strong>Expires:</strong>{' '}
|
||||
<code>{new Date(newToken.expiry).toLocaleString()}</code>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && !tokens.length && (
|
||||
<div style={{ marginBottom: 8 }}>Loading...</div>
|
||||
)}
|
||||
{error && <div style={{ color: 'red', marginBottom: 8 }}>{error}</div>}
|
||||
<table
|
||||
style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16 }}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>Name</th>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>Updated at</th>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>Expires at</th>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.length === 0 && !isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ textAlign: 'center', padding: 16 }}>
|
||||
No tokens found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tokens.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ padding: 8 }}>{row.digest}</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
{new Date(row.created).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
{new Date(row.expiry).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
<Button
|
||||
onPress={() => handleDeleteToken(row.digest)}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{modal.open && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: '#eee',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<span>{modal.message}</span>
|
||||
<Button
|
||||
onPress={() => setModal({ open: false, message: '' })}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
style={{ marginLeft: 16 }}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface UserToken {
|
||||
digest: string
|
||||
created: string // Assuming ISO date string
|
||||
expiry: string // Assuming ISO date string
|
||||
}
|
||||
|
||||
export interface NewUserToken extends UserToken {
|
||||
token_key: string
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { TermsOfServiceRoute } from '@/features/legalsTerms/TermsOfService'
|
||||
import { CreatePopup } from '@/features/sdk/routes/CreatePopup'
|
||||
import { CreateMeetingButton } from '@/features/sdk/routes/CreateMeetingButton'
|
||||
import { RecordingDownloadRoute } from '@/features/recording'
|
||||
import { UserTokens } from '@/features/user-tokens/routes/UserTokens'
|
||||
|
||||
export const routes: Record<
|
||||
| 'home'
|
||||
@@ -20,7 +21,8 @@ export const routes: Record<
|
||||
| 'termsOfService'
|
||||
| 'sdkCreatePopup'
|
||||
| 'sdkCreateButton'
|
||||
| 'recordingDownload',
|
||||
| 'recordingDownload'
|
||||
| 'userTokens',
|
||||
{
|
||||
name: RouteName
|
||||
path: RegExp | string
|
||||
@@ -78,6 +80,11 @@ export const routes: Record<
|
||||
to: (recordingId: string) => `/recording/${recordingId.trim()}`,
|
||||
Component: RecordingDownloadRoute,
|
||||
},
|
||||
userTokens: {
|
||||
name: 'userTokens',
|
||||
path: '/user-tokens',
|
||||
Component: UserTokens,
|
||||
},
|
||||
}
|
||||
|
||||
export type RouteName = keyof typeof routes
|
||||
|
||||
Reference in New Issue
Block a user