💩(user-tokens) add back & front for Token auth
This provides: - a frontend to allow user to create/delete User Token - the authentication process to allow any API to be called when authenticating with a User Token.
This commit is contained in:
@@ -25,6 +25,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
import requests
|
||||
import rest_framework as drf
|
||||
from botocore.exceptions import ClientError
|
||||
from knox.auth import TokenAuthentication
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework import filters, status, viewsets
|
||||
@@ -671,6 +672,7 @@ class DocumentViewSet(
|
||||
authentication_classes=[
|
||||
authentication.ServerToServerAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
TokenAuthentication,
|
||||
],
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Test user_token API endpoints in the impress core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from knox.models import get_token_model
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
AuthToken = get_token_model()
|
||||
|
||||
def test_api_user_token_list_anonymous(client):
|
||||
"""Anonymous users should not be allowed to list user tokens."""
|
||||
response = client.get("/api/v1.0/user-tokens/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_user_token_list_authenticated(client):
|
||||
"""
|
||||
Authenticated users should be able to list their own tokens.
|
||||
Tokens are identified by digest, and include created/expiry.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
# Knox creates a token instance and a character string token key.
|
||||
# The create method returns a tuple: (instance, token_key_string)
|
||||
token_instance_1, _ = AuthToken.objects.create(user=user)
|
||||
AuthToken.objects.create(user=user) # Another token for the same user
|
||||
AuthToken.objects.create(user=factories.UserFactory()) # Token for a different user
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/user-tokens/")
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content) == 2
|
||||
|
||||
# Check that the response contains the digests of the tokens created for the user
|
||||
response_token_digests = {item["digest"] for item in content}
|
||||
assert token_instance_1.digest in response_token_digests
|
||||
|
||||
# Ensure the token_key is not listed
|
||||
for item in content:
|
||||
assert "token_key" not in item
|
||||
assert "digest" in item
|
||||
assert "created" in item
|
||||
assert "expiry" in item
|
||||
|
||||
|
||||
def test_api_user_token_create_anonymous(client):
|
||||
"""Anonymous users should not be allowed to create user tokens."""
|
||||
# The create endpoint does not take any parameters as per TokenCreateSerializer
|
||||
# (user is implicit, other fields are read_only)
|
||||
response = client.post("/api/v1.0/user-tokens/", data={})
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_user_token_create_authenticated(client):
|
||||
"""
|
||||
Authenticated users should be able to create a new token.
|
||||
The token key should be returned in the response upon creation.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
# The create endpoint does not take any parameters as per TokenCreateSerializer
|
||||
response = client.post("/api/v1.0/user-tokens/", data={})
|
||||
assert response.status_code == 201
|
||||
content = response.json()
|
||||
|
||||
# Based on TokenCreateSerializer, these fields should be in the response
|
||||
assert "token_key" in content
|
||||
assert "digest" in content
|
||||
assert "created" in content
|
||||
assert "expiry" in content
|
||||
assert len(content["token_key"]) > 0 # Knox token key should be non-empty
|
||||
|
||||
# Verify the token was actually created in the database for the user
|
||||
assert AuthToken.objects.filter(user=user, digest=content["digest"]).exists()
|
||||
|
||||
def test_api_user_token_destroy_anonymous(client):
|
||||
"""Anonymous users should not be allowed to delete user tokens."""
|
||||
user = factories.UserFactory()
|
||||
token_instance, _ = AuthToken.objects.create(user=user)
|
||||
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
|
||||
assert response.status_code == 403
|
||||
assert AuthToken.objects.filter(digest=token_instance.digest).exists()
|
||||
|
||||
|
||||
def test_api_user_token_destroy_authenticated_own_token(client):
|
||||
"""Authenticated users should be able to delete their own tokens."""
|
||||
user = factories.UserFactory()
|
||||
token_instance, _ = AuthToken.objects.create(user=user)
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
|
||||
assert response.status_code == 204
|
||||
assert not AuthToken.objects.filter(digest=token_instance.digest).exists()
|
||||
|
||||
|
||||
def test_api_user_token_destroy_authenticated_other_user_token(client):
|
||||
"""Authenticated users should not be able to delete other users' tokens."""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
other_user_token_instance, _ = AuthToken.objects.create(user=other_user)
|
||||
|
||||
client.force_login(user) # Log in as 'user'
|
||||
|
||||
response = client.delete(f"/api/v1.0/user-tokens/{other_user_token_instance.digest}/")
|
||||
# The default behavior for a non-found or non-permissioned item in DestroyModelMixin
|
||||
# when the queryset is filtered (as in get_queryset) is often a 404.
|
||||
assert response.status_code == 404
|
||||
assert AuthToken.objects.filter(digest=other_user_token_instance.digest).exists()
|
||||
|
||||
|
||||
def test_api_user_token_destroy_non_existent_token(client):
|
||||
"""Attempting to delete a non-existent token should result in a 404."""
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete("/api/v1.0/user-tokens/nonexistentdigest/")
|
||||
assert response.status_code == 404
|
||||
@@ -8,12 +8,18 @@ from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_url
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import viewsets
|
||||
from core.user_token import viewsets as user_token_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
router.register("templates", viewsets.TemplateViewSet, basename="templates")
|
||||
router.register("documents", viewsets.DocumentViewSet, basename="documents")
|
||||
router.register("users", viewsets.UserViewSet, basename="users")
|
||||
router.register(
|
||||
"user-tokens",
|
||||
user_token_viewsets.UserTokenViewset,
|
||||
basename="user_tokens",
|
||||
)
|
||||
|
||||
# - Routes nested under a document
|
||||
document_related_router = DefaultRouter()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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 permissions, viewsets, mixins
|
||||
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,9 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import tomllib
|
||||
from socket import gethostbyname, gethostname
|
||||
@@ -303,6 +306,7 @@ class Base(Configuration):
|
||||
"django_filters",
|
||||
"dockerflow.django",
|
||||
"rest_framework",
|
||||
"knox",
|
||||
"parler",
|
||||
"treebeard",
|
||||
"easy_thumbnails",
|
||||
@@ -328,6 +332,7 @@ class Base(Configuration):
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"knox.auth.TokenAuthentication",
|
||||
"lasuite.oidc_resource_server.authentication.ResourceServerAuthentication",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
@@ -647,6 +652,18 @@ class Base(Configuration):
|
||||
[], 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",
|
||||
}
|
||||
|
||||
# AI service
|
||||
AI_FEATURE_ENABLED = values.BooleanValue(
|
||||
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
|
||||
|
||||
@@ -36,6 +36,7 @@ dependencies = [
|
||||
"django-lasuite[all]==0.0.9",
|
||||
"django-parler==2.3",
|
||||
"django-redis==5.4.0",
|
||||
"django-rest-knox==5.0.2",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.9",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -16,6 +18,7 @@ import { Title } from './Title';
|
||||
|
||||
export const Header = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
@@ -63,6 +66,13 @@ export const Header = () => {
|
||||
) : (
|
||||
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
|
||||
<ButtonLogin />
|
||||
<Button
|
||||
onClick={() => router.push(`/user-tokens`)}
|
||||
aria-label={t('API Tokens', 'API Tokens')}
|
||||
color="primary-text"
|
||||
>
|
||||
{t('API Tokens', 'API Tokens')}
|
||||
</Button>
|
||||
<LanguagePicker />
|
||||
<LaGaufre />
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './useListUserTokens';
|
||||
export * from './useCreateUserToken';
|
||||
export * from './useDeleteUserToken';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { NewUserToken } from '../types';
|
||||
|
||||
export const createUserToken = async (): Promise<NewUserToken> => {
|
||||
const response = await fetchAPI(`user-tokens/`, {
|
||||
method: 'POST',
|
||||
// The backend test indicates no data is sent for creation, so body is an empty object
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to create user token',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<NewUserToken>;
|
||||
};
|
||||
|
||||
export function useCreateUserToken() {
|
||||
return useMutation<NewUserToken, APIError>({
|
||||
mutationFn: createUserToken,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
export const deleteUserToken = async (digest: string): Promise<void> => {
|
||||
const response = await fetchAPI(`user-tokens/${digest}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
// 204 is a valid response for delete
|
||||
throw new APIError(
|
||||
'Failed to delete user token',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
// For 204, there's no content, and for other successful deletions, we don't expect content.
|
||||
// So, we don't try to parse JSON.
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
export type DeleteUserTokenParams = {
|
||||
digest: string;
|
||||
};
|
||||
|
||||
export function useDeleteUserToken() {
|
||||
return useMutation<void, APIError, DeleteUserTokenParams>({
|
||||
mutationFn: ({ digest }) => deleteUserToken(digest),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { UserToken } from '../types';
|
||||
|
||||
export const listUserTokens = async (): Promise<UserToken[]> => {
|
||||
const response = await fetchAPI(`user-tokens/`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to list user tokens',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<UserToken[]>;
|
||||
};
|
||||
|
||||
export function useListUserTokens() {
|
||||
return useQuery<UserToken[], APIError>({
|
||||
queryKey: ['userTokens'],
|
||||
queryFn: listUserTokens,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
Button as CunninghamButton,
|
||||
DataGrid,
|
||||
Modal,
|
||||
ModalSize,
|
||||
} from '@openfun/cunningham-react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Box, Card } from '@/components';
|
||||
|
||||
import { createUserToken, deleteUserToken, listUserTokens } from '../api/index';
|
||||
import { NewUserToken, UserToken } from '../types';
|
||||
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
if (seconds < 60) {
|
||||
return `${seconds} seconds ago`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${minutes} minutes ago`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours} hours ago`;
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} days ago`;
|
||||
};
|
||||
|
||||
// Add id to UserToken type for DataGrid compatibility
|
||||
interface UserTokenWithId extends UserToken {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Define proper type for DataGrid columns
|
||||
interface ColumnDef {
|
||||
field: string;
|
||||
headerName: string;
|
||||
width?: number;
|
||||
renderCell: (params: { row: UserTokenWithId }) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const UserTokenManager: React.FC = () => {
|
||||
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 [modalState, setModalState] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: React.ReactNode;
|
||||
onConfirm?: () => void;
|
||||
confirmText?: string;
|
||||
isConfirmation: boolean;
|
||||
type?: 'success' | 'error' | 'warning' | 'info';
|
||||
size: ModalSize;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
isConfirmation: false,
|
||||
size: ModalSize.MEDIUM, // Default size using ModalSize enum
|
||||
});
|
||||
|
||||
const showNotification = (
|
||||
message: string,
|
||||
type: 'success' | 'error' = 'success',
|
||||
size: ModalSize = ModalSize.SMALL,
|
||||
) => {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: type === 'success' ? 'Success' : 'Error',
|
||||
message,
|
||||
isConfirmation: false,
|
||||
type: type,
|
||||
size,
|
||||
});
|
||||
};
|
||||
|
||||
const showConfirmation = (
|
||||
title: string,
|
||||
message: React.ReactNode,
|
||||
onConfirm: () => void,
|
||||
confirmText: string = 'Confirm',
|
||||
size: ModalSize = ModalSize.MEDIUM,
|
||||
) => {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
confirmText,
|
||||
isConfirmation: true,
|
||||
size,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchTokens = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fetchedTokens = await listUserTokens();
|
||||
// Add id to each token
|
||||
setTokens(fetchedTokens.map((token) => ({ ...token, id: token.digest })));
|
||||
} catch (err) {
|
||||
setError(
|
||||
'Failed to fetch tokens. Please ensure you are logged in and have permissions.',
|
||||
);
|
||||
showNotification('Failed to fetch tokens', 'error');
|
||||
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);
|
||||
showNotification(
|
||||
'Token created successfully! Store the token key safely, it will not be shown again.',
|
||||
'success',
|
||||
ModalSize.LARGE,
|
||||
);
|
||||
void fetchTokens();
|
||||
} catch (err) {
|
||||
setError('Failed to create token.');
|
||||
showNotification('Failed to create token', 'error');
|
||||
console.error(err);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteToken = (digest: string) => {
|
||||
showConfirmation(
|
||||
'Confirm Deletion',
|
||||
'Are you sure you want to delete this token?',
|
||||
() => {
|
||||
void (async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteUserToken(digest);
|
||||
showNotification('Token deleted successfully!');
|
||||
setNewToken(null);
|
||||
await fetchTokens();
|
||||
} catch (err) {
|
||||
setError('Failed to delete token.');
|
||||
showNotification('Failed to delete token', 'error');
|
||||
console.error(err);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnDef[] = [
|
||||
{
|
||||
field: 'digest',
|
||||
headerName: 'Name',
|
||||
renderCell: ({ row }: { row: UserTokenWithId }) => <>{row.digest}</>,
|
||||
},
|
||||
{
|
||||
field: 'created',
|
||||
headerName: 'Updated at',
|
||||
renderCell: ({ row }: { row: UserTokenWithId }) => (
|
||||
<>{formatTimeAgo(row.created)}</>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'expires',
|
||||
headerName: 'Expires at',
|
||||
renderCell: ({ row }: { row: UserTokenWithId }) => <>{row.expiry}</>,
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: '',
|
||||
width: 50,
|
||||
renderCell: ({ row }: { row: UserTokenWithId }) => (
|
||||
<CunninghamButton
|
||||
onClick={() => {
|
||||
handleDeleteToken(row.digest);
|
||||
}}
|
||||
color="danger"
|
||||
size="small"
|
||||
icon={<span className="material-icons">delete</span>}
|
||||
aria-label="Delete token"
|
||||
>
|
||||
Delete
|
||||
</CunninghamButton>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
$direction="column"
|
||||
$width="100%"
|
||||
$padding={{
|
||||
top: 'base',
|
||||
horizontal: 'md',
|
||||
bottom: 'md',
|
||||
}}
|
||||
>
|
||||
<Box $direction="row" $justify="space-between" $align="center">
|
||||
<h2 style={{ marginBottom: 'var(--c--theme--spacing--medium, 16px)' }}>
|
||||
User token management
|
||||
</h2>
|
||||
<CunninghamButton
|
||||
onClick={() => void handleCreateToken()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Generating...' : 'Generate New Token'}
|
||||
</CunninghamButton>
|
||||
</Box>
|
||||
|
||||
{newToken && (
|
||||
<Box
|
||||
$background="var(--c--theme--colors--success-100)"
|
||||
$padding="md"
|
||||
$radius="10px"
|
||||
$margin={{ bottom: 'var(--c--theme--spacing--medium, 16px)' }}
|
||||
$direction="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>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isLoading && !tokens.length && (
|
||||
<Box $margin={{ bottom: 'var(--c--theme--spacing--small, 8px)' }}>
|
||||
Loading...
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Box
|
||||
$color="var(--c--theme--colors--danger-500, red)"
|
||||
$margin={{ bottom: 'var(--c--theme--spacing--small, 8px)' }}
|
||||
>
|
||||
{error}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DataGrid<UserTokenWithId>
|
||||
rows={tokens}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
emptyCta={<div>No tokens found.</div>}
|
||||
/>
|
||||
{modalState.isOpen && (
|
||||
<Modal
|
||||
isOpen={modalState.isOpen}
|
||||
onClose={() => setModalState((prev) => ({ ...prev, isOpen: false }))}
|
||||
title={modalState.title}
|
||||
size={modalState.size} // Use ModalSize enum directly
|
||||
actions={
|
||||
modalState.isConfirmation ? (
|
||||
<Box $width="100%" $direction="row" $justify="space-between">
|
||||
<CunninghamButton
|
||||
onClick={() =>
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
color="secondary"
|
||||
>
|
||||
Cancel
|
||||
</CunninghamButton>
|
||||
<CunninghamButton
|
||||
onClick={() => {
|
||||
if (modalState.onConfirm) {
|
||||
modalState.onConfirm();
|
||||
}
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||
}}
|
||||
color="danger"
|
||||
>
|
||||
{modalState.confirmText || 'Confirm'}
|
||||
</CunninghamButton>
|
||||
</Box>
|
||||
) : (
|
||||
<CunninghamButton
|
||||
onClick={() =>
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
color="primary"
|
||||
>
|
||||
Close
|
||||
</CunninghamButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
{modalState.message}
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { UserTokenManager } from './components/UserTokenManager';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { UserTokenManager } from '@/features/user-tokens';
|
||||
import { MainLayout } from '@/layouts';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const UserTokensPage: NextPageWithLayout = () => {
|
||||
return <UserTokenManager />;
|
||||
};
|
||||
|
||||
UserTokensPage.getLayout = function getLayout(page: React.ReactElement) {
|
||||
return <MainLayout backgroundColor="grey">{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default UserTokensPage;
|
||||
Reference in New Issue
Block a user