Merge pull request #266 from SELab-2/feat/callback-classlink
feat: Klaslink met redirect na login
This commit is contained in:
commit
4d411e7d20
11 changed files with 157 additions and 81 deletions
|
@ -113,7 +113,7 @@ export async function createStudentRequestHandler(req: Request, res: Response):
|
||||||
const classId = req.body.classId;
|
const classId = req.body.classId;
|
||||||
requireFields({ username, classId });
|
requireFields({ username, classId });
|
||||||
|
|
||||||
const request = await createClassJoinRequest(username, classId);
|
const request = await createClassJoinRequest(username, classId.toUpperCase());
|
||||||
res.json({ request });
|
res.json({ request });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,4 +31,9 @@
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.search-field {
|
||||||
|
width: 25%;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -53,9 +53,9 @@
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.results-grid {
|
.results-grid {
|
||||||
margin: 20px;
|
margin: 20px auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
justify-content: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable
|
const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable
|
||||||
|
|
||||||
const name: string = auth.authState.user!.profile.name!;
|
const name: string = auth.authState.user!.profile.name!;
|
||||||
|
const username = auth.authState.user!.profile.preferred_username!;
|
||||||
const email = auth.authState.user!.profile.email;
|
const email = auth.authState.user!.profile.email;
|
||||||
const initials: string = name
|
const initials: string = name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
|
@ -180,10 +181,15 @@
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="mx-auto text-center">
|
<div class="mx-auto text-center">
|
||||||
<v-avatar color="#0e6942">
|
<v-avatar
|
||||||
<span class="text-h5">{{ initials }}</span>
|
color="#0e6942"
|
||||||
|
size="large"
|
||||||
|
class="user-button mb-3"
|
||||||
|
>
|
||||||
|
<span>{{ initials }}</span>
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
<h3>{{ name }}</h3>
|
<h3>{{ name }}</h3>
|
||||||
|
<p class="text-caption mt-1">{{ username }}</p>
|
||||||
<p class="text-caption mt-1">{{ email }}</p>
|
<p class="text-caption mt-1">{{ email }}</p>
|
||||||
<v-divider class="my-3"></v-divider>
|
<v-divider class="my-3"></v-divider>
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|
|
@ -14,6 +14,7 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue";
|
||||||
import SingleTheme from "@/views/SingleTheme.vue";
|
import SingleTheme from "@/views/SingleTheme.vue";
|
||||||
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
|
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
|
||||||
import authService from "@/services/auth/auth-service";
|
import authService from "@/services/auth/auth-service";
|
||||||
|
import { allowRedirect, Redirect } from "@/utils/redirect.ts";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
@ -143,7 +144,11 @@ router.beforeEach(async (to, _from, next) => {
|
||||||
// Verify if user is logged in before accessing certain routes
|
// Verify if user is logged in before accessing certain routes
|
||||||
if (to.meta.requiresAuth) {
|
if (to.meta.requiresAuth) {
|
||||||
if (!authService.isLoggedIn.value && !(await authService.loadUser())) {
|
if (!authService.isLoggedIn.value && !(await authService.loadUser())) {
|
||||||
next("/login");
|
const path = to.fullPath;
|
||||||
|
if (allowRedirect(path)) {
|
||||||
|
localStorage.setItem(Redirect.AFTER_LOGIN_KEY, path);
|
||||||
|
}
|
||||||
|
next(Redirect.LOGIN);
|
||||||
} else {
|
} else {
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
12
frontend/src/utils/redirect.ts
Normal file
12
frontend/src/utils/redirect.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
export enum Redirect {
|
||||||
|
AFTER_LOGIN_KEY = "redirectAfterLogin",
|
||||||
|
HOME = "/user",
|
||||||
|
LOGIN = "/login",
|
||||||
|
ROOT = "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOT_ALLOWED_REDIRECTS = new Set<Redirect>([Redirect.HOME, Redirect.ROOT, Redirect.LOGIN]);
|
||||||
|
|
||||||
|
export function allowRedirect(path: string): boolean {
|
||||||
|
return !NOT_ALLOWED_REDIRECTS.has(path as Redirect);
|
||||||
|
}
|
|
@ -3,6 +3,7 @@
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { onMounted, ref, type Ref } from "vue";
|
import { onMounted, ref, type Ref } from "vue";
|
||||||
import auth from "../services/auth/auth-service.ts";
|
import auth from "../services/auth/auth-service.ts";
|
||||||
|
import { Redirect } from "@/utils/redirect.ts";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
@ -10,10 +11,20 @@
|
||||||
|
|
||||||
const errorMessage: Ref<string | null> = ref(null);
|
const errorMessage: Ref<string | null> = ref(null);
|
||||||
|
|
||||||
|
async function redirectPage(): Promise<void> {
|
||||||
|
const redirectUrl = localStorage.getItem(Redirect.AFTER_LOGIN_KEY);
|
||||||
|
if (redirectUrl) {
|
||||||
|
localStorage.removeItem(Redirect.AFTER_LOGIN_KEY);
|
||||||
|
await router.replace(redirectUrl);
|
||||||
|
} else {
|
||||||
|
await router.replace(Redirect.HOME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await auth.handleLoginCallback();
|
await auth.handleLoginCallback();
|
||||||
await router.replace("/user"); // Redirect to theme page
|
await redirectPage();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = `${t("loginUnexpectedError")}: ${error}`;
|
errorMessage.value = `${t("loginUnexpectedError")}: ${error}`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,13 +35,14 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container d-flex flex-column align-items-center justify-center">
|
||||||
<using-query-result :query-result="themeQueryResult">
|
<using-query-result :query-result="themeQueryResult">
|
||||||
<h1>{{ currentThemeInfo!!.title }}</h1>
|
<h1>{{ currentThemeInfo!!.title }}</h1>
|
||||||
<p>{{ currentThemeInfo!!.description }}</p>
|
<p>{{ currentThemeInfo!!.description }}</p>
|
||||||
<div class="search-field-container">
|
<br />
|
||||||
|
<div class="search-field-container mt-sm-6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
class="search-field"
|
class="search-field mx-auto"
|
||||||
:label="t('search')"
|
:label="t('search')"
|
||||||
append-inner-icon="mdi-magnify"
|
append-inner-icon="mdi-magnify"
|
||||||
v-model="searchFilter"
|
v-model="searchFilter"
|
||||||
|
@ -60,13 +61,15 @@
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.search-field-container {
|
.search-field-container {
|
||||||
display: block;
|
justify-content: center !important;
|
||||||
margin: 20px;
|
|
||||||
}
|
}
|
||||||
.search-field {
|
.search-field {
|
||||||
max-width: 300px;
|
width: 25%;
|
||||||
|
min-width: 300px;
|
||||||
}
|
}
|
||||||
.container {
|
.container {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
justify-items: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import authState from "@/services/auth/auth-service.ts";
|
import authState from "@/services/auth/auth-service.ts";
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { validate, version } from "uuid";
|
import { useRoute } from "vue-router";
|
||||||
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
|
import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
|
||||||
import { useCreateJoinRequestMutation, useStudentClassesQuery } from "@/queries/students";
|
import { useCreateJoinRequestMutation, useStudentClassesQuery } from "@/queries/students";
|
||||||
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
|
import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
|
||||||
|
@ -15,6 +15,7 @@
|
||||||
import "../../assets/common.css";
|
import "../../assets/common.css";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
// Username of logged in student
|
// Username of logged in student
|
||||||
const username = ref<string | undefined>(undefined);
|
const username = ref<string | undefined>(undefined);
|
||||||
|
@ -38,6 +39,11 @@
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryCode = route.query.code as string | undefined;
|
||||||
|
if (queryCode) {
|
||||||
|
code.value = queryCode;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch all classes of the logged in student
|
// Fetch all classes of the logged in student
|
||||||
|
@ -75,11 +81,15 @@
|
||||||
|
|
||||||
// The code a student sends in to join a class needs to be formatted as v4 to be valid
|
// The code a student sends in to join a class needs to be formatted as v4 to be valid
|
||||||
// These rules are used to display a message to the user if they use a code that has an invalid format
|
// These rules are used to display a message to the user if they use a code that has an invalid format
|
||||||
|
function codeRegex(value: string): boolean {
|
||||||
|
return /^[a-zA-Z0-9]{6}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
const codeRules = [
|
const codeRules = [
|
||||||
(value: string | undefined): string | boolean => {
|
(value: string | undefined): string | boolean => {
|
||||||
if (value === undefined || value === "") {
|
if (value === undefined || value === "") {
|
||||||
return true;
|
return true;
|
||||||
} else if (value !== undefined && validate(value) && version(value) === 4) {
|
} else if (codeRegex(value)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return t("invalidFormat");
|
return t("invalidFormat");
|
||||||
|
@ -92,7 +102,7 @@
|
||||||
// Function called when a student submits a code to join a class
|
// Function called when a student submits a code to join a class
|
||||||
function submitCode(): void {
|
function submitCode(): void {
|
||||||
// Check if the code is valid
|
// Check if the code is valid
|
||||||
if (code.value !== undefined && validate(code.value) && version(code.value) === 4) {
|
if (code.value !== undefined && codeRegex(code.value)) {
|
||||||
mutate(
|
mutate(
|
||||||
{ username: username.value!, classId: code.value },
|
{ username: username.value!, classId: code.value },
|
||||||
{
|
{
|
||||||
|
@ -260,7 +270,7 @@
|
||||||
<v-text-field
|
<v-text-field
|
||||||
label="CODE"
|
label="CODE"
|
||||||
v-model="code"
|
v-model="code"
|
||||||
placeholder="XXXXXXXX-XXXX-4XXX-XXXX-XXXXXXXXXXXX"
|
placeholder="XXXXXX"
|
||||||
:rules="codeRules"
|
:rules="codeRules"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
|
|
|
@ -132,17 +132,12 @@
|
||||||
// Show the teacher, copying of the code was a successs
|
// Show the teacher, copying of the code was a successs
|
||||||
const copied = ref(false);
|
const copied = ref(false);
|
||||||
|
|
||||||
// Copy the generated code to the clipboard
|
async function copyToClipboard(code: string, isDialog = false, isLink = false): Promise<void> {
|
||||||
async function copyToClipboard(): Promise<void> {
|
const content = isLink ? `${window.location.origin}/user/class?code=${code}` : code;
|
||||||
await navigator.clipboard.writeText(code.value);
|
await navigator.clipboard.writeText(content);
|
||||||
copied.value = true;
|
copied.value = isDialog;
|
||||||
}
|
|
||||||
|
|
||||||
async function copyCode(selectedCode: string): Promise<void> {
|
if (!isDialog) showSnackbar(t("copied"), "white");
|
||||||
code.value = selectedCode;
|
|
||||||
await copyToClipboard();
|
|
||||||
showSnackbar(t("copied"), "white");
|
|
||||||
copied.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom breakpoints
|
// Custom breakpoints
|
||||||
|
@ -236,20 +231,34 @@
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<v-btn
|
<v-row
|
||||||
v-if="!isMdAndDown"
|
v-if="!isMdAndDown"
|
||||||
variant="text"
|
dense
|
||||||
append-icon="mdi-content-copy"
|
align="center"
|
||||||
@click="copyCode(c.id)"
|
no-gutters
|
||||||
>
|
>
|
||||||
{{ c.id }}
|
<v-btn
|
||||||
</v-btn>
|
variant="text"
|
||||||
|
append-icon="mdi-content-copy"
|
||||||
|
@click="copyToClipboard(c.id)"
|
||||||
|
>
|
||||||
|
{{ c.id }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
@click="copyToClipboard(c.id, false, true)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-link-variant</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-row>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
style="cursor: pointer"
|
style="cursor: pointer"
|
||||||
@click="openCodeDialog(c.id)"
|
@click="openCodeDialog(c.id)"
|
||||||
><v-icon icon="mdi-eye"></v-icon
|
>
|
||||||
></span>
|
<v-icon icon="mdi-eye"></v-icon>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>{{ c.students.length }}</td>
|
<td>{{ c.students.length }}</td>
|
||||||
|
@ -311,14 +320,29 @@
|
||||||
max-width="400px"
|
max-width="400px"
|
||||||
>
|
>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title class="headline">code</v-card-title>
|
<v-card-title class="headline">{{ t("code") }}</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="code"
|
v-model="code"
|
||||||
readonly
|
readonly
|
||||||
append-inner-icon="mdi-content-copy"
|
>
|
||||||
@click:append-inner="copyToClipboard"
|
<template #append>
|
||||||
></v-text-field>
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
@click="copyToClipboard(code, true)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-content-copy</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
@click="copyToClipboard(code, true, true)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-link-variant</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
<v-slide-y-transition>
|
<v-slide-y-transition>
|
||||||
<div
|
<div
|
||||||
v-if="copied"
|
v-if="copied"
|
||||||
|
@ -460,9 +484,24 @@
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="selectedCode"
|
v-model="selectedCode"
|
||||||
readonly
|
readonly
|
||||||
append-inner-icon="mdi-content-copy"
|
>
|
||||||
@click:append-inner="copyToClipboard"
|
<template #append>
|
||||||
></v-text-field>
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
@click="copyToClipboard(selectedCode, true)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-content-copy</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
@click="copyToClipboard(selectedCode, true, true)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-link-variant</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
<v-slide-y-transition>
|
<v-slide-y-transition>
|
||||||
<div
|
<div
|
||||||
v-if="copied"
|
v-if="copied"
|
||||||
|
|
|
@ -17,43 +17,29 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-container class="search-page-container">
|
<div class="search-page-container d-flex flex-column align-items-center justify-center">
|
||||||
<v-row
|
<div class="search-field-container">
|
||||||
justify="center"
|
<learning-path-search-field class="mx-auto" />
|
||||||
class="mb-6"
|
</div>
|
||||||
|
|
||||||
|
<using-query-result
|
||||||
|
:query-result="searchQueryResults"
|
||||||
|
v-slot="{ data }: { data: LearningPath[] }"
|
||||||
>
|
>
|
||||||
<v-col
|
<learning-paths-grid :learning-paths="data" />
|
||||||
cols="12"
|
</using-query-result>
|
||||||
sm="8"
|
|
||||||
md="6"
|
|
||||||
lg="4"
|
|
||||||
>
|
|
||||||
<learning-path-search-field class="search-field" />
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-row justify="center">
|
<div
|
||||||
<v-col cols="12">
|
v-if="!query"
|
||||||
<using-query-result
|
class="empty-state-container"
|
||||||
:query-result="searchQueryResults"
|
>
|
||||||
v-slot="{ data }: { data: LearningPath[] }"
|
<v-empty-state
|
||||||
>
|
icon="mdi-magnify"
|
||||||
<learning-paths-grid :learning-paths="data" />
|
:title="t('enterSearchTerm')"
|
||||||
</using-query-result>
|
:text="t('enterSearchTermDescription')"
|
||||||
|
/>
|
||||||
<div
|
</div>
|
||||||
v-if="!query"
|
</div>
|
||||||
class="empty-state-container"
|
|
||||||
>
|
|
||||||
<v-empty-state
|
|
||||||
icon="mdi-magnify"
|
|
||||||
:title="t('enterSearchTerm')"
|
|
||||||
:text="t('enterSearchTermDescription')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -61,8 +47,7 @@
|
||||||
padding-top: 40px;
|
padding-top: 40px;
|
||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
.search-field-container {
|
||||||
.search-field {
|
justify-content: center !important;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue