Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add support for private redirecting link for studio admin #4919

Draft
wants to merge 8 commits into
base: unstable
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<LoadingText absolute />
</VCard>
<div v-else>
<ChannelSupportToken :channelId="channelId" />
<VDivider class="my-5" />
<h1 class="font-weight-bold title">
{{ $tr('inviteSubheading') }}
</h1>
Expand Down Expand Up @@ -64,6 +66,7 @@
<script>

import { mapGetters, mapActions } from 'vuex';
import ChannelSupportToken from './ChannelSupportToken.vue';
import ChannelSharingTable from './ChannelSharingTable';
import LoadingText from 'shared/views/LoadingText';
import { SharingPermissions } from 'shared/constants';
Expand All @@ -75,6 +78,7 @@
DropdownWrapper,
LoadingText,
ChannelSharingTable,
ChannelSupportToken,
},
props: {
channelId: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<template>

<div>
<h1 class="font-weight-bold title">
{{ $tr('supportTokenHeading') }}
</h1>
<p class="mt-2">
{{ $tr('supportTokenDescription') }}
</p>

<div v-if="loading" class="my-4">
<VProgressLinear indeterminate color="primary" />
</div>

<div v-else-if="supportToken" class="my-4">
<VTextField
v-model="supportToken"
readonly
box
color="primary"
:append-icon="clipboardAvailable ? 'content_copy' : null"
:title="clipboardAvailable ? $tr('copyPrompt') : ''"
class="notranslate token-field"
single-line
hide-details
@click:append="copyToken"
/>
<p v-if="copied" class="mt-2 success--text">
{{ $tr('tokenCopied') }}
</p>
</div>

<div v-else class="my-4 sm:w-[450px] w-full">
<KButton
class="support-token-button"
:disabled="generating"
@click="generateSupportToken"
>
<VProgressCircular
v-if="generating"
indeterminate
size="20"
width="3"
color="primary"
/>
{{ generating ? $tr('generating') : $tr('generateTokenButton') }}
</KButton>
</div>
</div>

</template>

<script>

export default {
name: 'ChannelSupportToken',
data() {
return {
supportToken: null,
loading: false,
generating: false,
copied: false,
};
},
computed: {
clipboardAvailable() {
return Boolean(navigator.clipboard);
},
},
methods: {
copyToken() {
if (this.clipboardAvailable) {
navigator.clipboard
.writeText(this.supportToken)
.then(() => {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 3000);
this.$emit('copied');
})
.catch(() => {
this.$store.dispatch('showSnackbar', {
text: this.$tr('copyFailed'),
color: 'error',
});
});
}
},
generateSupportToken() {
this.generating = true;
setTimeout(() => {
this.supportToken = 'NEW123TOKEN'; // Hardcoded new test token
this.generating = false;
}, 2000);
},
},
$trs: {
supportTokenHeading: 'Channel Support Token',
supportTokenDescription:
'This token can be used by support staff to access your channel for troubleshooting purposes.',
generateTokenButton: 'Generate Support Token',
generating: 'Generating...',
tokenCopied: 'Support token copied to clipboard',
copyPrompt: 'Copy token for support access',
copyFailed: 'Failed to copy token',
},
};

</script>

<style lang="scss" scoped>

.token-field {
width: 600px;
max-width: 75%;
}

.support-token-button {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
width: 48%;
max-width: 200px;
color: #374151;
background-color: #f3f4f6;
border: 1px solid #e5e7eb;
transition: background-color 0.2s ease-in-out;

&:hover {
background-color: #e5e7eb;
}

&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}

</style>

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 3.2.24 on 2025-02-23 09:13

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('contentcuration', '0151_alter_assessmentitem_type'),
]

operations = [
migrations.AddField(
model_name='channel',
name='support_token',
field=models.OneToOneField(blank=True, help_text='Token for support access', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='support_channels', to='contentcuration.secrettoken', verbose_name='support token'),
),
]
9 changes: 9 additions & 0 deletions contentcuration/contentcuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,15 @@ class Channel(models.Model):
verbose_name="secret tokens",
blank=True,
)
support_token = models.OneToOneField(
SecretToken,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='support_channels',
verbose_name="support token",
help_text="Token for support access",
)
source_url = models.CharField(max_length=200, blank=True, null=True)
demo_server_url = models.CharField(max_length=200, blank=True, null=True)

Expand Down
87 changes: 87 additions & 0 deletions contentcuration/contentcuration/tests/views/test_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import uuid
from django.urls import reverse
from rest_framework import status
from contentcuration.models import Channel, SecretToken
from contentcuration.tests import testdata
from contentcuration.tests.base import StudioTestCase

class SupportTokenRedirectTestCase(StudioTestCase):

@classmethod
def setUpClass(cls):
super(SupportTokenRedirectTestCase, cls).setUpClass()

@property
def channel_metadata(self):
return {
"name": "Test Channel",
"id": uuid.uuid4().hex,
"description": "A test channel for support token creation.",
}

def setUp(self):
"""
Set up test data before running each test.
"""
self.setUpBase()

self.user.is_admin = True
self.user.save()

self.channel = Channel.objects.create(actor_id=self.user.id, **self.channel_metadata)

self.valid_token_str = "bepud-dibub-dizok"
self.support_token = SecretToken.objects.create(token=self.valid_token_str)

self.channel.secret_tokens.add(self.support_token)
self.channel.support_token = self.support_token
self.channel.save()

self.client = self.admin_client()

def test_valid_token_redirects_to_channel(self):
"""
Test that a valid token redirects to the correct channel page.
"""
url = reverse("support_token_redirect", kwargs={"token": self.valid_token_str})
response = self.client.get(url, format="json")

self.assertEqual(response.status_code, status.HTTP_302_FOUND)
self.assertEqual(response.url, f"channels/{self.channel.id}")

def test_invalid_token_format(self):
"""
Test that an invalid token format returns 400 Bad Request.
"""
url = reverse("support_token_redirect", kwargs={"token": "invalid_token_123"})
response = self.client.get(url, format="json")

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("Invalid token format", response.json()["Error"])

def test_token_not_found(self):
"""
Test that a non-existent token returns 404 Not Found.
"""
url = reverse("support_token_redirect", kwargs={"token": "bepud-befud-bidup"})
response = self.client.get(url, format="json")

self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertIn("Invalid token", response.json()["Error"])


def test_unauthorized_user_forbidden(self):
"""
Test that a non-admin user gets 403 Forbidden.
"""
self.client.logout()
non_admin_user = testdata.user()
non_admin_user.is_admin = False
non_admin_user.save()
self.client.force_login(non_admin_user)

url = reverse("support_token_redirect", kwargs={"token": self.valid_token_str})
response = self.client.get(url, format="json")

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

84 changes: 83 additions & 1 deletion contentcuration/contentcuration/tests/viewsets/test_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from contentcuration import models
from contentcuration import models as cc
from contentcuration.constants import channel_history
from contentcuration.models import ContentNode
from contentcuration.models import ContentNode, SecretToken
from contentcuration.tests import testdata
from contentcuration.tests.base import StudioAPITestCase
from contentcuration.tests.viewsets.base import generate_create_event
Expand Down Expand Up @@ -706,3 +706,85 @@ def _perform_action(self, url_path, channel_id):
self.client.force_authenticate(user=user)
response = self.client.get(reverse(url_path, kwargs={"pk": channel_id}), format="json")
return response

class SupportTokenTestCase(StudioAPITestCase):

@property
def channel_metadata(self):
return {
"name": "Test Channel",
"id": uuid.uuid4().hex,
"description": "A test channel for support token creation.",
}

def setUp(self):
super(SupportTokenTestCase, self).setUp()
self.user = testdata.user()
self.channel = models.Channel.objects.create(actor_id=self.user.id, **self.channel_metadata)
self.channel.editors.add(self.user)
self.client.force_authenticate(user=self.user)
self.channel.save()

def test_create_support_token(self):
"""
Ensure a support token is created successfully for a channel.
"""
url = reverse("channel-support-token", kwargs={"pk": self.channel.id})
response = self.client.post(url, format="json")
self.assertEqual(response.status_code, 201, response.content)

self.channel.refresh_from_db()
self.assertIsNotNone(self.channel.support_token)

# Ensure token exists in SecretToken model
token_exists = models.SecretToken.objects.filter(token=self.channel.support_token.token).exists()
self.assertTrue(token_exists, "Support token was not stored in the SecretToken table.")

def test_cannot_create_duplicate_support_token(self):
"""
Ensure creating a support token fails if one already exists.
"""
# First request creates the token
url = reverse("channel-support-token", kwargs={"pk": self.channel.id})
response = self.client.post(url, format="json")
self.assertEqual(response.status_code, 201, response.content)

# Second request should fail with 409 CONFLICT
response = self.client.post(url, format="json")
self.assertEqual(response.status_code, 409, response.content)

def test_get_support_token_existing(self):
"""
Ensure the API returns the support token when it exists.
"""
# Create a support token
# token_str = SecretToken.generate_new_token()
support_token = SecretToken.objects.create(token = "test-support-token")
self.channel.support_token = support_token
self.channel.save()

url = reverse("channel-support-token", kwargs={"pk": self.channel.id})
response = self.client.get(url, format="json")
print(response.content)

self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["support_token"], "test-support-token")

def test_get_support_token_channel_not_found(self):
"""
Ensure the API returns 404 when the channel does not exist.
"""
url = reverse("channel-support-token", kwargs={"pk": "non-existent-id"})
response = self.client.get(url, format="json")

self.assertEqual(response.status_code, 404, response.content)

def test_get_support_token_none(self):
"""
Ensure the API returns null when no support token exists.
"""
url = reverse("channel-support-token", kwargs={"pk": self.channel.id})
response = self.client.get(url, format="json")

self.assertEqual(response.status_code, 200, response.content)
self.assertIsNone(response.json()["support_token"])
1 change: 1 addition & 0 deletions contentcuration/contentcuration/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def get_redirect_url(self, *args, **kwargs):
# Add admin endpoints
urlpatterns += [
re_path(r'^api/send_custom_email/$', admin_views.send_custom_email, name='send_custom_email'),
re_path(r'^api/support_token_redirect/(?P<token>[^/]+)/$', admin_views.support_token_redirect, name='support_token_redirect'),
]

urlpatterns += [re_path(r'^jsreverse/$', django_js_reverse_views.urls_js, name='js_reverse')]
Expand Down
Loading
Loading