commit
ad82e0be9b
@ -0,0 +1,84 @@ |
||||
<script setup> |
||||
import { computed, reactive, unref } from "vue" |
||||
import { useI18n } from "vue-i18n" |
||||
|
||||
import BaseTextArea from "../basecomponents/BaseTextArea.vue" |
||||
import BaseButton from "../basecomponents/BaseButton.vue" |
||||
import BaseDialog from "../basecomponents/BaseDialog.vue" |
||||
import BaseInputText from "../basecomponents/BaseInputText.vue" |
||||
|
||||
import { createProfile, updateProfile } from "../../services/skillProfileService" |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const profile = reactive({ |
||||
title: "", |
||||
description: "", |
||||
}) |
||||
|
||||
const isVisible = defineModel("visible", { |
||||
required: true, |
||||
type: Boolean, |
||||
}) |
||||
|
||||
const skills = defineModel("skills", { |
||||
type: Array, |
||||
required: false, |
||||
}) |
||||
|
||||
const emit = defineEmits(["saved"]) |
||||
|
||||
async function saveProfile() { |
||||
if (profile["@id"]) { |
||||
await updateProfile({ |
||||
iri: profile["@id"], |
||||
title: profile.title, |
||||
description: profile.description, |
||||
}) |
||||
} else { |
||||
await createProfile({ |
||||
title: profile.title, |
||||
description: profile.description, |
||||
skills: skills.value.map((skill) => ({ skill: skill["@id"] })), |
||||
}) |
||||
} |
||||
|
||||
isVisible.value = false |
||||
|
||||
emit("saved", unref(profile)) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<BaseDialog |
||||
:title="t('Skill profile')" |
||||
v-model:is-visible="isVisible" |
||||
> |
||||
<BaseInputText |
||||
id="name_profile" |
||||
v-model="profile.title" |
||||
:label="t('Title')" |
||||
/> |
||||
<BaseTextArea |
||||
v-model="profile.description" |
||||
:label="t('Description')" |
||||
/> |
||||
|
||||
<template #footer> |
||||
<BaseButton |
||||
:label="t('Cancel')" |
||||
icon="close" |
||||
type="black" |
||||
@click="isVisible = false" |
||||
/> |
||||
<BaseButton |
||||
:label="t('Save')" |
||||
icon="save" |
||||
type="primary" |
||||
@click="saveProfile" |
||||
/> |
||||
|
||||
<pre>{{ skillIdList }}</pre> |
||||
</template> |
||||
</BaseDialog> |
||||
</template> |
@ -0,0 +1,47 @@ |
||||
<script setup> |
||||
import { ref } from "vue" |
||||
|
||||
import Skeleton from "primevue/skeleton" |
||||
|
||||
import { useNotification } from "../../composables/notification" |
||||
|
||||
import * as skillProfileService from "../../services/skillProfileService" |
||||
|
||||
const { showErrorNotification } = useNotification() |
||||
|
||||
const containerEl = ref() |
||||
const isLoading = ref(false) |
||||
|
||||
/** |
||||
* @param {Array<Object>} skills |
||||
* @returns {Promise<void>} |
||||
*/ |
||||
async function searchProfileMatches(skills) { |
||||
isLoading.value = true |
||||
|
||||
const skillIdList = skills.map((skill) => skill.id) |
||||
|
||||
try { |
||||
containerEl.value.innerHTML = await skillProfileService.matchProfiles(skillIdList) |
||||
} catch (e) { |
||||
showErrorNotification(e) |
||||
} finally { |
||||
isLoading.value = false |
||||
} |
||||
} |
||||
|
||||
defineExpose({ |
||||
searchProfileMatches, |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<Skeleton |
||||
v-if="isLoading" |
||||
height="10rem" |
||||
/> |
||||
<div |
||||
v-show="!isLoading" |
||||
ref="containerEl" |
||||
/> |
||||
</template> |
@ -0,0 +1,30 @@ |
||||
<script setup> |
||||
import { onMounted } from "vue" |
||||
|
||||
import Skeleton from "primevue/skeleton" |
||||
|
||||
import { useSkillWheel } from "../../composables/skill/skillWheel" |
||||
|
||||
const { wheelContainer, isLoading, loadSkills, showRoot, showSkill } = useSkillWheel() |
||||
|
||||
defineExpose({ |
||||
showRoot, |
||||
showSkill, |
||||
}) |
||||
|
||||
onMounted(() => { |
||||
loadSkills() |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="aspect-square"> |
||||
<div ref="wheelContainer" /> |
||||
|
||||
<Skeleton |
||||
shape="circle" |
||||
v-if="isLoading" |
||||
size="100%" |
||||
/> |
||||
</div> |
||||
</template> |
@ -0,0 +1,132 @@ |
||||
<script setup> |
||||
import { onMounted, ref } from "vue" |
||||
import { useI18n } from "vue-i18n" |
||||
|
||||
import BaseCard from "../basecomponents/BaseCard.vue" |
||||
import BaseButton from "../basecomponents/BaseButton.vue" |
||||
import Skeleton from "primevue/skeleton" |
||||
|
||||
import * as skillProfileService from "../../services/skillProfileService" |
||||
|
||||
import { useNotification } from "../../composables/notification" |
||||
import { useConfirm } from "primevue/useconfirm" |
||||
|
||||
const { t } = useI18n() |
||||
const { showErrorNotification, showSuccessNotification } = useNotification() |
||||
const confirm = useConfirm() |
||||
|
||||
const isLoading = ref(true) |
||||
|
||||
const profileList = ref([]) |
||||
|
||||
async function loadProfiles() { |
||||
try { |
||||
isLoading.value = true |
||||
|
||||
const { items } = await skillProfileService.findAll() |
||||
|
||||
profileList.value = items |
||||
} catch (e) { |
||||
showErrorNotification(e) |
||||
} finally { |
||||
isLoading.value = false |
||||
} |
||||
} |
||||
|
||||
defineExpose({ |
||||
loadProfiles, |
||||
}) |
||||
|
||||
onMounted(() => { |
||||
loadProfiles() |
||||
}) |
||||
|
||||
async function onClickDeleteProfile(profile) { |
||||
confirm.require({ |
||||
message: t('Are you sure you want to delete "%s"?', [profile.title]), |
||||
header: t("Delete skill profile"), |
||||
icon: "mdi mdi-alert", |
||||
async accept() { |
||||
if (!profile) { |
||||
return |
||||
} |
||||
|
||||
try { |
||||
isLoading.value = true |
||||
|
||||
await skillProfileService.deleteProfile(profile["@id"]) |
||||
|
||||
showSuccessNotification(t("Skill profile deleted")) |
||||
} catch (e) { |
||||
showErrorNotification(e) |
||||
} finally { |
||||
isLoading.value = false |
||||
} |
||||
|
||||
await loadProfiles() |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
const emit = defineEmits(["searchProfile"]) |
||||
</script> |
||||
|
||||
<template> |
||||
<BaseCard> |
||||
<template #title>{{ t("Skill profiles") }}</template> |
||||
|
||||
<div |
||||
v-if="isLoading" |
||||
class="space-y-2" |
||||
> |
||||
<div |
||||
v-for="v in 3" |
||||
:key="v" |
||||
class="flex flex-row gap-2 items-center" |
||||
> |
||||
<Skeleton |
||||
class="mr-auto" |
||||
width="10rem" |
||||
/> |
||||
<Skeleton size="2.5rem" /> |
||||
<Skeleton size="2.5rem" /> |
||||
</div> |
||||
</div> |
||||
|
||||
<ul |
||||
v-else-if="profileList.length > 0" |
||||
class="space-y-2" |
||||
> |
||||
<li |
||||
v-for="(profile, i) in profileList" |
||||
:key="i" |
||||
class="flex flex-row gap-2 items-center" |
||||
> |
||||
<span |
||||
class="mr-auto" |
||||
v-text="profile.title" |
||||
/> |
||||
|
||||
<BaseButton |
||||
:label="t('Search')" |
||||
icon="search" |
||||
only-icon |
||||
type="black" |
||||
@click="emit('searchProfile', profile)" |
||||
/> |
||||
<BaseButton |
||||
:label="t('Delete')" |
||||
icon="delete" |
||||
only-icon |
||||
type="danger" |
||||
@click="onClickDeleteProfile(profile)" |
||||
/> |
||||
</li> |
||||
</ul> |
||||
|
||||
<p |
||||
v-else |
||||
v-t="'No skill profiles'" |
||||
/> |
||||
</BaseCard> |
||||
</template> |
@ -0,0 +1,285 @@ |
||||
import { ref, unref, watch } from "vue" |
||||
import * as d3 from "d3" |
||||
import { getSkillTree } from "../../services/skillService" |
||||
import { useNotification } from "../notification" |
||||
|
||||
export function useSkillWheel() { |
||||
const isLoading = ref(true) |
||||
|
||||
const skillList = ref([]) |
||||
const wheelContainer = ref(null) |
||||
|
||||
const { showErrorNotification } = useNotification() |
||||
|
||||
const colorList = ["#deebf7", "#9ecae1", "#3182bd"] |
||||
|
||||
let root |
||||
let centralCircle |
||||
let path |
||||
|
||||
const width = 928 |
||||
const height = width |
||||
const radius = width / 6 |
||||
|
||||
function transformSkillToWheelItem({ |
||||
id, |
||||
title, |
||||
shortCode, |
||||
status, |
||||
children, |
||||
hasGradebook, |
||||
isSearched, |
||||
isAchievedByUser, |
||||
}) { |
||||
const item = { |
||||
id, |
||||
name: title, |
||||
shortCode, |
||||
status, |
||||
children: [], |
||||
hasGradebook, |
||||
isSearched, |
||||
isAchievedByUser, |
||||
} |
||||
|
||||
if (children.length) { |
||||
for (const child of children) { |
||||
item.children.push(transformSkillToWheelItem(child)) |
||||
} |
||||
} else { |
||||
item.value = 1 |
||||
} |
||||
|
||||
return item |
||||
} |
||||
|
||||
function render() { |
||||
const data = { |
||||
name: "root", |
||||
children: unref(skillList).map(transformSkillToWheelItem), |
||||
} |
||||
|
||||
// Compute the layout.
|
||||
const hierarchy = d3 |
||||
.hierarchy(data) |
||||
.sum((d) => d.value) |
||||
.sort((a, b) => b.id - a.id) |
||||
|
||||
root = d3.partition().size([2 * Math.PI, hierarchy.height + 1])(hierarchy) |
||||
|
||||
root.each((d) => (d.current = d)) |
||||
|
||||
// Create the arc generator.
|
||||
const arc = d3 |
||||
.arc() |
||||
.startAngle((d) => d.x0) |
||||
.endAngle((d) => d.x1) |
||||
.padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005)) |
||||
.padRadius(radius * 1.5) |
||||
.innerRadius((d) => d.y0 * radius) |
||||
.outerRadius((d) => Math.max(d.y0 * radius, d.y1 * radius - 1)) |
||||
|
||||
// Create the SVG container.
|
||||
const svg = d3 |
||||
.create("svg") |
||||
.attr("viewBox", [-width / 2, -height / 2, width, width]) |
||||
.style("font", "10px sans-serif") |
||||
|
||||
// Append the arcs.
|
||||
path = svg |
||||
.append("g") |
||||
.selectAll("path") |
||||
.data(root.descendants().slice(1)) |
||||
.join("path") |
||||
.attr("fill", setFillColor) |
||||
.attr("fill-opacity", (d) => (arcVisible(d.current) ? 1 : 0)) |
||||
.attr("pointer-events", (d) => (arcVisible(d.current) ? "auto" : "none")) |
||||
.attr("d", (d) => arc(d.current)) |
||||
.attr("id", (d) => "skill-" + d.data.id) |
||||
|
||||
// Make them clickable if they have children.
|
||||
path |
||||
.filter((d) => d.children) |
||||
.style("cursor", "pointer") |
||||
.on("click", clicked) |
||||
|
||||
path.append("title").text( |
||||
(d) => |
||||
`${d |
||||
.ancestors() |
||||
.filter((d) => d.depth > 0) |
||||
.map(setNodeText) |
||||
.reverse() |
||||
.join("/")}`,
|
||||
) |
||||
|
||||
const label = svg |
||||
.append("g") |
||||
.attr("pointer-events", "none") |
||||
.attr("text-anchor", "middle") |
||||
.style("user-select", "none") |
||||
.selectAll("text") |
||||
.data(root.descendants().slice(1)) |
||||
.join("text") |
||||
.attr("dy", "0.35em") |
||||
.attr("fill-opacity", (d) => +labelVisible(d.current)) |
||||
.attr("transform", (d) => labelTransform(d.current)) |
||||
.text(setNodeText) |
||||
|
||||
centralCircle = svg |
||||
.append("circle") |
||||
.datum(root) |
||||
.attr("r", radius) |
||||
.attr("fill", "none") |
||||
.attr("pointer-events", "all") |
||||
.on("click", clicked) |
||||
|
||||
// Handle zoom on click.
|
||||
function clicked(event, p) { |
||||
centralCircle.datum(p.parent || root) |
||||
|
||||
root.each( |
||||
(d) => |
||||
(d.target = { |
||||
x0: Math.max(0, Math.min(1, (d.x0 - p.x0) / (p.x1 - p.x0))) * 2 * Math.PI, |
||||
x1: Math.max(0, Math.min(1, (d.x1 - p.x0) / (p.x1 - p.x0))) * 2 * Math.PI, |
||||
y0: Math.max(0, d.y0 - p.depth), |
||||
y1: Math.max(0, d.y1 - p.depth), |
||||
}), |
||||
) |
||||
|
||||
const t = svg.transition().duration(750) |
||||
|
||||
// Transition the data on all arcs, even the ones that aren’t visible,
|
||||
// so that if this transition is interrupted, entering arcs will start
|
||||
// the next transition from the desired position.
|
||||
path |
||||
.transition(t) |
||||
.tween("data", (d) => { |
||||
const i = d3.interpolate(d.current, d.target) |
||||
|
||||
return (t) => (d.current = i(t)) |
||||
}) |
||||
.filter(function (d) { |
||||
return +this.getAttribute("fill-opacity") || arcVisible(d.target) |
||||
}) |
||||
.attr("fill-opacity", (d) => (arcVisible(d.target) ? 1 : 0)) |
||||
.attr("pointer-events", (d) => (arcVisible(d.target) ? "auto" : "none")) |
||||
.attrTween("d", (d) => () => arc(d.current)) |
||||
|
||||
label |
||||
.filter(function (d) { |
||||
return +this.getAttribute("fill-opacity") || labelVisible(d.target) |
||||
}) |
||||
.transition(t) |
||||
.attr("fill-opacity", (d) => +labelVisible(d.target)) |
||||
.attrTween("transform", (d) => () => labelTransform(d.current)) |
||||
} |
||||
|
||||
function arcVisible(d) { |
||||
return d.y1 <= 3 && d.y0 >= 1 && d.x1 > d.x0 |
||||
} |
||||
|
||||
function labelVisible(d) { |
||||
return d.y1 <= 3 && d.y0 >= 1 && (d.y1 - d.y0) * (d.x1 - d.x0) > 0.03 |
||||
} |
||||
|
||||
function labelTransform(d) { |
||||
const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI |
||||
const y = ((d.y0 + d.y1) / 2) * radius |
||||
|
||||
return `rotate(${x - 90}) translate(${y},0) rotate(${x < 180 ? 0 : 180})` |
||||
} |
||||
|
||||
function setFillColor(d, i) { |
||||
if (d.data.hasGradebook) { |
||||
return "#F89406" |
||||
} |
||||
|
||||
if (d.data.isSearched) { |
||||
return "#B94A48" |
||||
} |
||||
|
||||
if (d.data.isAchievedByUser) { |
||||
return "#A1D99B" |
||||
} |
||||
|
||||
if (!d.data.status) { |
||||
return "#48616C" |
||||
} |
||||
|
||||
return colorList[i % colorList.length] |
||||
} |
||||
|
||||
function setNodeText(d) { |
||||
if (d.data.shortCode) { |
||||
return d.data.shortCode |
||||
} |
||||
|
||||
return d.data.name |
||||
} |
||||
|
||||
return svg.node() |
||||
} |
||||
|
||||
function showRoot() { |
||||
if (isLoading.value) { |
||||
return |
||||
} |
||||
|
||||
centralCircle.datum(root).dispatch("click") |
||||
} |
||||
|
||||
function showSkill(skillId) { |
||||
if (isLoading.value) { |
||||
return |
||||
} |
||||
|
||||
const skillNode = root.descendants().find((d) => d.data.id === skillId) |
||||
|
||||
if (!skillNode) { |
||||
return |
||||
} |
||||
|
||||
if (skillNode.children && skillNode.children.length > 0) { |
||||
centralCircle.datum(skillNode).dispatch("click") |
||||
|
||||
return |
||||
} |
||||
|
||||
if (skillNode.parent) { |
||||
centralCircle.datum(skillNode.parent).dispatch("click") |
||||
|
||||
return |
||||
} |
||||
|
||||
showRoot() |
||||
} |
||||
|
||||
async function loadSkills() { |
||||
isLoading.value = true |
||||
|
||||
try { |
||||
skillList.value = await getSkillTree() |
||||
} catch (e) { |
||||
showErrorNotification(e) |
||||
} finally { |
||||
isLoading.value = false |
||||
} |
||||
} |
||||
|
||||
watch(skillList, () => { |
||||
if (wheelContainer.value) { |
||||
wheelContainer.value.innerHTML = "" |
||||
wheelContainer.value.appendChild(render()) |
||||
} |
||||
}) |
||||
|
||||
return { |
||||
wheelContainer, |
||||
isLoading, |
||||
loadSkills, |
||||
showRoot, |
||||
showSkill, |
||||
} |
||||
} |
@ -0,0 +1,18 @@ |
||||
export default { |
||||
path: "/skill", |
||||
component: () => import("../components/layout/SimpleRouterViewLayout.vue"), |
||||
children: [ |
||||
{ |
||||
name: "SkillWheel", |
||||
path: "wheel", |
||||
meta: { |
||||
requiresAuth: true, |
||||
requiresAdmin: true, |
||||
requiresSessionAdmin: false, |
||||
requiresHR: true, |
||||
showBreadcrumb: false, |
||||
}, |
||||
component: () => import("../views/skill/SkillWheel.vue"), |
||||
}, |
||||
], |
||||
} |
@ -0,0 +1,47 @@ |
||||
import baseService from "./baseService" |
||||
|
||||
/** |
||||
* @returns {Promise<{totalItems, items}>} |
||||
*/ |
||||
export async function findAll() { |
||||
return baseService.getCollection("/api/skill_profiles") |
||||
} |
||||
|
||||
/** |
||||
* @param {string} title |
||||
* @param {string} description |
||||
* @param {Array<{skill}>} skills |
||||
* @returns {Promise<Object>} |
||||
*/ |
||||
export async function createProfile({ title, description, skills }) { |
||||
return baseService.post("/api/skill_profiles", { title, description, skills }) |
||||
} |
||||
|
||||
/** |
||||
* @param {string} iri |
||||
* @param {string} title |
||||
* @param {string} description |
||||
* @returns {Promise<Object>} |
||||
*/ |
||||
export async function updateProfile({ iri, title, description }) { |
||||
return baseService.put(iri, { title, description }) |
||||
} |
||||
|
||||
/** |
||||
* @param {string} iri |
||||
* @returns {Promise<void>} |
||||
*/ |
||||
export async function deleteProfile(iri) { |
||||
await baseService.delete(iri) |
||||
} |
||||
|
||||
/** |
||||
* @param {Array<number>} idList |
||||
* @returns {Promise<string>} |
||||
*/ |
||||
export async function matchProfiles(idList) { |
||||
return await baseService.get("/main/inc/ajax/skill.ajax.php", { |
||||
a: "profile_matches", |
||||
skill_id: idList, |
||||
}) |
||||
} |
@ -0,0 +1,18 @@ |
||||
import baseService from "./baseService" |
||||
|
||||
/** |
||||
* @returns {Promise<Array>} |
||||
*/ |
||||
export async function getSkillTree() { |
||||
const { items } = await baseService.getCollection("/api/skills/tree") |
||||
|
||||
return items |
||||
} |
||||
|
||||
/** |
||||
* @param {Object} searchParams |
||||
* @returns {Promise<{totalItems, items}>} |
||||
*/ |
||||
export async function findAll(searchParams) { |
||||
return await baseService.getCollection("api/skills", searchParams) |
||||
} |
@ -0,0 +1,180 @@ |
||||
<script setup> |
||||
import { ref } from "vue" |
||||
import { useI18n } from "vue-i18n" |
||||
|
||||
import SectionHeader from "../../components/layout/SectionHeader.vue" |
||||
import BaseAutocomplete from "../../components/basecomponents/BaseAutocomplete.vue" |
||||
import BaseIcon from "../../components/basecomponents/BaseIcon.vue" |
||||
import BaseButton from "../../components/basecomponents/BaseButton.vue" |
||||
import BaseCard from "../../components/basecomponents/BaseCard.vue" |
||||
import SkillWheelProfileList from "../../components/skill/SkillWheelProfileList.vue" |
||||
import SkillProfileDialog from "../../components/skill/SkillProfileDialog.vue" |
||||
import SkillWheelGraph from "../../components/skill/SkillWheelGraph.vue" |
||||
|
||||
import { useNotification } from "../../composables/notification" |
||||
|
||||
import * as skillService from "../../services/skillService" |
||||
import SkillProfileMatches from "../../components/skill/SkillProfileMatches.vue" |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const { showErrorNotification } = useNotification() |
||||
|
||||
const profileListEL = ref() |
||||
const wheelEl = ref() |
||||
|
||||
const foundSkills = ref([]) |
||||
|
||||
/** |
||||
* @param {string} query |
||||
* @returns {Promise<Object[]>} |
||||
*/ |
||||
async function findSkills(query) { |
||||
try { |
||||
const { items } = await skillService.findAll({ title: query }) |
||||
|
||||
return items.map((item) => ({ name: item.title, value: item["@id"], ...item })) |
||||
} catch (e) { |
||||
showErrorNotification(e) |
||||
|
||||
return [] |
||||
} |
||||
} |
||||
|
||||
const showSkilProfileForm = ref(false) |
||||
|
||||
const profileMatchesEl = ref() |
||||
const showProfileMatches = ref(false) |
||||
|
||||
async function onClickSearchProfileMatches() { |
||||
showProfileMatches.value = true |
||||
|
||||
await profileMatchesEl.value.searchProfileMatches(foundSkills.value) |
||||
} |
||||
|
||||
async function onClickViewSkillWheel() { |
||||
showProfileMatches.value = false |
||||
|
||||
wheelEl.value.showRoot() |
||||
} |
||||
|
||||
async function onSearchProfile(profile) { |
||||
const profileSkills = profile.skills.map((skillRelProfile) => skillRelProfile.skill) |
||||
|
||||
showProfileMatches.value = true |
||||
|
||||
await profileMatchesEl.value.searchProfileMatches(profileSkills) |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<SectionHeader :title="t('Skill wheel')" /> |
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4"> |
||||
<div class="xl:col-span-1 skill-options flex flex-col gap-4"> |
||||
<SkillWheelProfileList |
||||
ref="profileListEL" |
||||
@search-profile="onSearchProfile" |
||||
/> |
||||
|
||||
<BaseCard> |
||||
<template #title>{{ t("What skills are you looking for?") }}</template> |
||||
|
||||
<template #footer> |
||||
<BaseButton |
||||
:label="t('View skills wheel')" |
||||
icon="wheel" |
||||
type="secondary" |
||||
@click="onClickViewSkillWheel()" |
||||
/> |
||||
</template> |
||||
|
||||
<BaseAutocomplete |
||||
id="skill_id" |
||||
v-model="foundSkills" |
||||
:label="t('Enter the skill name to search')" |
||||
:search="findSkills" |
||||
is-multiple |
||||
> |
||||
<template #chip="{ value }"> |
||||
{{ value.name }} |
||||
|
||||
<span |
||||
class="p-autocomplete-token-icon" |
||||
@click="wheelEl.showSkill(value.id)" |
||||
> |
||||
<BaseIcon |
||||
icon="crosshairs" |
||||
size="small" |
||||
/> |
||||
</span> |
||||
</template> |
||||
</BaseAutocomplete> |
||||
|
||||
<BaseButton |
||||
:disabled="!foundSkills.length" |
||||
:label="t('Search profile matches')" |
||||
icon="search" |
||||
type="black" |
||||
@click="onClickSearchProfileMatches" |
||||
/> |
||||
|
||||
<p v-t="'Is this what you were looking for?'" /> |
||||
|
||||
<BaseButton |
||||
:disabled="!foundSkills.length" |
||||
:label="t('Save this search')" |
||||
icon="search" |
||||
type="black" |
||||
@click="showSkilProfileForm = true" |
||||
/> |
||||
</BaseCard> |
||||
|
||||
<BaseCard> |
||||
<template #title>{{ t("Legend") }}</template> |
||||
|
||||
<ul class="fa-ul"> |
||||
<li> |
||||
<BaseIcon |
||||
class="skill-legend-basic" |
||||
icon="square" |
||||
/> |
||||
|
||||
{{ t("Basic skills") }} |
||||
</li> |
||||
<li> |
||||
<BaseIcon |
||||
class="skill-legend-add" |
||||
icon="square" |
||||
/> |
||||
{{ t("Skills you can learn") }} |
||||
</li> |
||||
<li> |
||||
<BaseIcon |
||||
class="skill-legend-search" |
||||
icon="square" |
||||
/> |
||||
{{ t("Skills searched for") }} |
||||
</li> |
||||
</ul> |
||||
</BaseCard> |
||||
</div> |
||||
|
||||
<div class="xl:col-span-2"> |
||||
<SkillWheelGraph |
||||
v-show="!showProfileMatches" |
||||
ref="wheelEl" |
||||
/> |
||||
<SkillProfileMatches |
||||
v-show="showProfileMatches" |
||||
ref="profileMatchesEl" |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
<SkillProfileDialog |
||||
v-model:skills="foundSkills" |
||||
v-model:visible="showSkilProfileForm" |
||||
@saved="profileListEL.loadProfiles()" |
||||
/> |
||||
</template> |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@ |
||||
<?php |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Chamilo\CoreBundle\Controller\OAuth2; |
||||
|
||||
use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper; |
||||
use KnpU\OAuth2ClientBundle\Client\ClientRegistry; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
|
||||
class AzureProviderController extends AbstractProviderController |
||||
{ |
||||
#[Route('/connect/azure', name: 'chamilo.oauth2_azure_start')] |
||||
public function connect( |
||||
ClientRegistry $clientRegistry, |
||||
AuthenticationConfigHelper $authenticationConfigHelper, |
||||
): Response { |
||||
return $this->getStartResponse('azure', $clientRegistry, $authenticationConfigHelper); |
||||
} |
||||
|
||||
#[Route('/connect/azure/check', name: 'chamilo.oauth2_azure_check')] |
||||
public function connectCheck(): void {} |
||||
} |
@ -0,0 +1,119 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
namespace Chamilo\CoreBundle\Controller; |
||||
|
||||
use Chamilo\CoreBundle\Entity\ValidationToken; |
||||
use Chamilo\CoreBundle\Repository\TrackEDefaultRepository; |
||||
use Chamilo\CoreBundle\Repository\ValidationTokenRepository; |
||||
use Chamilo\CoreBundle\ServiceHelper\ValidationTokenHelper; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Annotation\Route; |
||||
use Symfony\Component\Security\Core\Security; |
||||
|
||||
#[Route('/validate')] |
||||
class ValidationTokenController extends AbstractController |
||||
{ |
||||
public function __construct( |
||||
private readonly ValidationTokenHelper $validationTokenHelper, |
||||
private readonly ValidationTokenRepository $tokenRepository, |
||||
private readonly TrackEDefaultRepository $trackEDefaultRepository, |
||||
private readonly Security $security |
||||
) {} |
||||
|
||||
#[Route('/{type}/{hash}', name: 'validate_token')] |
||||
public function validate(string $type, string $hash): Response |
||||
{ |
||||
$token = $this->tokenRepository->findOneBy([ |
||||
'type' => $this->validationTokenHelper->getTypeId($type), |
||||
'hash' => $hash |
||||
]); |
||||
|
||||
if (!$token) { |
||||
throw $this->createNotFoundException('Invalid token.'); |
||||
} |
||||
|
||||
// Process the action related to the token type |
||||
$this->processAction($token); |
||||
|
||||
// Remove the used token |
||||
$this->tokenRepository->remove($token, true); |
||||
|
||||
// Register the token usage event |
||||
$this->registerTokenUsedEvent($token); |
||||
|
||||
return $this->render('@ChamiloCore/Validation/success.html.twig', [ |
||||
'type' => $type, |
||||
]); |
||||
} |
||||
|
||||
#[Route('/test/generate-token/{type}/{resourceId}', name: 'test_generate_token')] |
||||
public function testGenerateToken(string $type, int $resourceId): Response |
||||
{ |
||||
$typeId = $this->validationTokenHelper->getTypeId($type); |
||||
$token = new ValidationToken($typeId, $resourceId); |
||||
$this->tokenRepository->save($token, true); |
||||
|
||||
$validationLink = $this->generateUrl('validate_token', [ |
||||
'type' => $type, |
||||
'hash' => $token->getHash(), |
||||
], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL); |
||||
|
||||
return new Response("Generated token: {$token->getHash()}<br>Validation link: <a href='{$validationLink}'>{$validationLink}</a>"); |
||||
} |
||||
|
||||
private function processAction(ValidationToken $token): void |
||||
{ |
||||
switch ($token->getType()) { |
||||
case 1: // Assuming 1 is for 'ticket' |
||||
$this->processTicketValidation($token); |
||||
break; |
||||
case 2: // Assuming 2 is for 'user' |
||||
// Implement user validation logic here |
||||
break; |
||||
default: |
||||
throw new \InvalidArgumentException('Unrecognized token type'); |
||||
} |
||||
} |
||||
|
||||
private function processTicketValidation(ValidationToken $token): void |
||||
{ |
||||
$ticketId = $token->getResourceId(); |
||||
|
||||
// Simulate ticket validation logic |
||||
// Here you would typically check if the ticket exists and is valid |
||||
// For now, we'll just print a message to simulate this |
||||
// Replace this with your actual ticket validation logic |
||||
$ticketValid = $this->validateTicket($ticketId); |
||||
|
||||
if (!$ticketValid) { |
||||
throw new \RuntimeException('Invalid ticket.'); |
||||
} |
||||
|
||||
// If the ticket is valid, you can mark it as used or perform other actions |
||||
// For example, update the ticket status in the database |
||||
// $this->ticketRepository->markAsUsed($ticketId); |
||||
} |
||||
|
||||
private function validateTicket(int $ticketId): bool |
||||
{ |
||||
// Here you would implement the logic to check if the ticket is valid. |
||||
// This is a placeholder function to simulate validation. |
||||
|
||||
// For testing purposes, let's assume all tickets are valid. |
||||
// In a real implementation, you would query your database or service. |
||||
|
||||
return true; // Assume the ticket is valid for now |
||||
} |
||||
|
||||
private function registerTokenUsedEvent(ValidationToken $token): void |
||||
{ |
||||
$user = $this->security->getUser(); |
||||
$userId = $user?->getId(); |
||||
$this->trackEDefaultRepository->registerTokenUsedEvent($token, $userId); |
||||
} |
||||
} |
@ -0,0 +1,96 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
namespace Chamilo\CoreBundle\Entity; |
||||
|
||||
use Chamilo\CoreBundle\Repository\ValidationTokenRepository; |
||||
use Doctrine\ORM\Mapping as ORM; |
||||
|
||||
/** |
||||
* ValidationToken entity. |
||||
*/ |
||||
#[ORM\Table(name: 'validation_token')] |
||||
#[ORM\Index(columns: ['type', 'hash'], name: 'idx_type_hash')] |
||||
#[ORM\Entity(repositoryClass: ValidationTokenRepository::class)] |
||||
class ValidationToken |
||||
{ |
||||
#[ORM\Id] |
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')] |
||||
#[ORM\Column(type: 'integer')] |
||||
protected ?int $id = null; |
||||
|
||||
#[ORM\Column(type: 'integer')] |
||||
protected int $type; |
||||
|
||||
#[ORM\Column(type: 'bigint')] |
||||
protected int $resourceId; |
||||
|
||||
#[ORM\Column(type: 'string', length: 64)] |
||||
protected string $hash; |
||||
|
||||
#[ORM\Column(type: 'datetime')] |
||||
protected \DateTime $createdAt; |
||||
|
||||
public function __construct(int $type, int $resourceId) |
||||
{ |
||||
$this->type = $type; |
||||
$this->resourceId = $resourceId; |
||||
$this->hash = hash('sha256', uniqid((string) rand(), true)); |
||||
$this->createdAt = new \DateTime(); |
||||
} |
||||
|
||||
public function getId(): ?int |
||||
{ |
||||
return $this->id; |
||||
} |
||||
|
||||
public function getType(): int |
||||
{ |
||||
return $this->type; |
||||
} |
||||
|
||||
public function setType(int $type): self |
||||
{ |
||||
$this->type = $type; |
||||
return $this; |
||||
} |
||||
|
||||
public function getResourceId(): int |
||||
{ |
||||
return $this->resourceId; |
||||
} |
||||
|
||||
public function setResourceId(int $resourceId): self |
||||
{ |
||||
$this->resourceId = $resourceId; |
||||
return $this; |
||||
} |
||||
|
||||
public function getHash(): string |
||||
{ |
||||
return $this->hash; |
||||
} |
||||
|
||||
public function getCreatedAt(): \DateTime |
||||
{ |
||||
return $this->createdAt; |
||||
} |
||||
|
||||
public function setCreatedAt(\DateTime $createdAt): self |
||||
{ |
||||
$this->createdAt = $createdAt; |
||||
return $this; |
||||
} |
||||
|
||||
/** |
||||
* Generates a validation link. |
||||
*/ |
||||
public static function generateLink(int $type, int $resourceId): string |
||||
{ |
||||
$token = new self($type, $resourceId); |
||||
return '/validate/' . $type . '/' . $token->getHash(); |
||||
} |
||||
} |
@ -0,0 +1,28 @@ |
||||
<?php |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Chamilo\CoreBundle\Migrations\Schema\V200; |
||||
|
||||
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo; |
||||
use Doctrine\DBAL\Schema\Schema; |
||||
|
||||
class Version20241209103000 extends AbstractMigrationChamilo |
||||
{ |
||||
public function getDescription(): string |
||||
{ |
||||
return 'Change extra field boolean columns (visible_to_self, visible_to_others, changeable, filter) to not accept null values.'; |
||||
} |
||||
|
||||
public function up(Schema $schema): void |
||||
{ |
||||
$this->addSql('UPDATE extra_field SET visible_to_self = 0 WHERE visible_to_self IS NULL'); |
||||
$this->addSql('UPDATE extra_field SET visible_to_others = 0 WHERE visible_to_others IS NULL'); |
||||
$this->addSql('UPDATE extra_field SET changeable = 0 WHERE changeable IS NULL'); |
||||
$this->addSql('UPDATE extra_field SET filter = 0 WHERE filter IS NULL'); |
||||
|
||||
$this->addSql('ALTER TABLE extra_field CHANGE visible_to_self visible_to_self TINYINT(1) DEFAULT 0 NOT NULL, CHANGE visible_to_others visible_to_others TINYINT(1) DEFAULT 0 NOT NULL, CHANGE changeable changeable TINYINT(1) DEFAULT 0 NOT NULL, CHANGE filter filter TINYINT(1) DEFAULT 0 NOT NULL'); |
||||
} |
||||
} |
@ -0,0 +1,42 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
namespace Chamilo\CoreBundle\Migrations\Schema\V200; |
||||
|
||||
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo; |
||||
use Doctrine\DBAL\Schema\Schema; |
||||
|
||||
final class Version20241211183300 extends AbstractMigrationChamilo |
||||
{ |
||||
public function getDescription(): string |
||||
{ |
||||
return 'Migration for creating the validation_token table'; |
||||
} |
||||
|
||||
public function up(Schema $schema): void |
||||
{ |
||||
if (!$schema->hasTable('validation_token')) { |
||||
$this->addSql(" |
||||
CREATE TABLE validation_token ( |
||||
id INT AUTO_INCREMENT NOT NULL, |
||||
type INT NOT NULL, |
||||
resource_id BIGINT NOT NULL, |
||||
hash CHAR(64) NOT NULL, |
||||
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', |
||||
INDEX idx_type_hash (type, hash), |
||||
PRIMARY KEY(id) |
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB ROW_FORMAT = DYNAMIC |
||||
"); |
||||
} |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
if ($schema->hasTable('validation_token')) { |
||||
$this->addSql('DROP TABLE validation_token'); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,37 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
namespace Chamilo\CoreBundle\Repository; |
||||
|
||||
use Chamilo\CoreBundle\Entity\ValidationToken; |
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
||||
use Doctrine\Persistence\ManagerRegistry; |
||||
|
||||
class ValidationTokenRepository extends ServiceEntityRepository |
||||
{ |
||||
public function __construct(ManagerRegistry $registry) |
||||
{ |
||||
parent::__construct($registry, ValidationToken::class); |
||||
} |
||||
|
||||
public function save(ValidationToken $entity, bool $flush = false): void |
||||
{ |
||||
$this->getEntityManager()->persist($entity); |
||||
|
||||
if ($flush) { |
||||
$this->getEntityManager()->flush(); |
||||
} |
||||
} |
||||
|
||||
public function remove(ValidationToken $entity, bool $flush = false): void |
||||
{ |
||||
$this->getEntityManager()->remove($entity); |
||||
|
||||
if ($flush) { |
||||
$this->getEntityManager()->flush(); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,6 @@ |
||||
{% extends "@ChamiloCore/Layout/layout_one_col.html.twig" %} |
||||
|
||||
{% block content %} |
||||
<h1>Validation Successful</h1> |
||||
<p>The token for {{ type }} has been successfully validated.</p> |
||||
{% endblock %} |
@ -0,0 +1,83 @@ |
||||
<?php |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Chamilo\CoreBundle\Security\Authenticator\OAuth2; |
||||
|
||||
use Chamilo\CoreBundle\Entity\User; |
||||
use Chamilo\CoreBundle\Repository\Node\UserRepository; |
||||
use Chamilo\CoreBundle\ServiceHelper\AccessUrlHelper; |
||||
use Chamilo\CoreBundle\ServiceHelper\AuthenticationConfigHelper; |
||||
use Chamilo\CoreBundle\ServiceHelper\AzureAuthenticatorHelper; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Doctrine\ORM\NonUniqueResultException; |
||||
use KnpU\OAuth2ClientBundle\Client\ClientRegistry; |
||||
use League\OAuth2\Client\Token\AccessToken; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; |
||||
use Symfony\Component\Routing\RouterInterface; |
||||
use TheNetworg\OAuth2\Client\Provider\Azure; |
||||
|
||||
class AzureAuthenticator extends AbstractAuthenticator |
||||
{ |
||||
protected string $providerName = 'azure'; |
||||
|
||||
public function __construct( |
||||
ClientRegistry $clientRegistry, |
||||
RouterInterface $router, |
||||
UserRepository $userRepository, |
||||
AuthenticationConfigHelper $authenticationConfigHelper, |
||||
AccessUrlHelper $urlHelper, |
||||
EntityManagerInterface $entityManager, |
||||
private readonly AzureAuthenticatorHelper $azureHelper, |
||||
) { |
||||
parent::__construct( |
||||
$clientRegistry, |
||||
$router, |
||||
$userRepository, |
||||
$authenticationConfigHelper, |
||||
$urlHelper, |
||||
$entityManager |
||||
); |
||||
} |
||||
|
||||
public function supports(Request $request): ?bool |
||||
{ |
||||
return 'chamilo.oauth2_azure_check' === $request->attributes->get('_route'); |
||||
} |
||||
|
||||
/** |
||||
* @throws NonUniqueResultException |
||||
*/ |
||||
protected function userLoader(AccessToken $accessToken): User |
||||
{ |
||||
/** @var Azure $provider */ |
||||
$provider = $this->client->getOAuth2Provider(); |
||||
|
||||
$me = $provider->get('/me', $accessToken); |
||||
|
||||
if (empty($me['mail'])) { |
||||
throw new UnauthorizedHttpException( |
||||
'The mail field is empty in Azure AD and is needed to set the organisation email for this user.' |
||||
); |
||||
} |
||||
|
||||
if (empty($me['mailNickname'])) { |
||||
throw new UnauthorizedHttpException( |
||||
'The mailNickname field is empty in Azure AD and is needed to set the unique username for this user.' |
||||
); |
||||
} |
||||
|
||||
if (empty($me['objectId'])) { |
||||
throw new UnauthorizedHttpException( |
||||
'The id field is empty in Azure AD and is needed to set the unique Azure ID for this user.' |
||||
); |
||||
} |
||||
|
||||
$userId = $this->azureHelper->registerUser($me); |
||||
|
||||
return $this->userRepository->find($userId); |
||||
} |
||||
} |
@ -0,0 +1,198 @@ |
||||
<?php |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Chamilo\CoreBundle\ServiceHelper; |
||||
|
||||
use Chamilo\CoreBundle\Entity\ExtraField; |
||||
use Chamilo\CoreBundle\Entity\ExtraFieldValues; |
||||
use Chamilo\CoreBundle\Entity\User; |
||||
use Chamilo\CoreBundle\Repository\ExtraFieldRepository; |
||||
use Chamilo\CoreBundle\Repository\ExtraFieldValuesRepository; |
||||
use Chamilo\CoreBundle\Repository\Node\UserRepository; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Doctrine\ORM\NonUniqueResultException; |
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; |
||||
|
||||
readonly class AzureAuthenticatorHelper |
||||
{ |
||||
public const EXTRA_FIELD_ORGANISATION_EMAIL = 'organisationemail'; |
||||
public const EXTRA_FIELD_AZURE_ID = 'azure_id'; |
||||
public const EXTRA_FIELD_AZURE_UID = 'azure_uid'; |
||||
|
||||
public function __construct( |
||||
private ExtraFieldValuesRepository $extraFieldValuesRepo, |
||||
private ExtraFieldRepository $extraFieldRepo, |
||||
private UserRepository $userRepository, |
||||
private EntityManagerInterface $entityManager, |
||||
private AccessUrlHelper $urlHelper, |
||||
) {} |
||||
|
||||
/** |
||||
* @throws NonUniqueResultException |
||||
*/ |
||||
public function registerUser(array $azureUserInfo, string $azureUidKey = 'objectId'): User |
||||
{ |
||||
if (empty($azureUserInfo)) { |
||||
throw new UnauthorizedHttpException('User info not found.'); |
||||
} |
||||
|
||||
[ |
||||
$firstNme, |
||||
$lastName, |
||||
$username, |
||||
$email, |
||||
$phone, |
||||
$authSource, |
||||
$active, |
||||
$extra, |
||||
] = $this->formatUserData($azureUserInfo, $azureUidKey); |
||||
|
||||
$userId = $this->getUserIdByVerificationOrder($azureUserInfo, $azureUidKey); |
||||
|
||||
if (empty($userId)) { |
||||
$user = (new User()) |
||||
->setCreatorId($this->userRepository->getRootUser()->getId()) |
||||
; |
||||
} else { |
||||
$user = $this->userRepository->find($userId); |
||||
} |
||||
|
||||
$user |
||||
->setFirstname($firstNme) |
||||
->setLastname($lastName) |
||||
->setEmail($email) |
||||
->setUsername($username) |
||||
->setPlainPassword('azure') |
||||
->setStatus(STUDENT) |
||||
->setAuthSource($authSource) |
||||
->setPhone($phone) |
||||
->setActive($active) |
||||
->setRoleFromStatus(STUDENT) |
||||
; |
||||
|
||||
$this->userRepository->updateUser($user); |
||||
|
||||
$url = $this->urlHelper->getCurrent(); |
||||
$url->addUser($user); |
||||
|
||||
$this->entityManager->flush(); |
||||
|
||||
$this->extraFieldValuesRepo->updateItemData( |
||||
$this->getOrganizationEmailField(), |
||||
$user, |
||||
$extra['extra_'.self::EXTRA_FIELD_ORGANISATION_EMAIL] |
||||
); |
||||
|
||||
$this->extraFieldValuesRepo->updateItemData( |
||||
$this->getAzureIdField(), |
||||
$user, |
||||
$extra['extra_'.self::EXTRA_FIELD_AZURE_ID] |
||||
); |
||||
|
||||
$this->extraFieldValuesRepo->updateItemData( |
||||
$this->getAzureUidField(), |
||||
$user, |
||||
$extra['extra_'.self::EXTRA_FIELD_AZURE_UID] |
||||
); |
||||
|
||||
return $user; |
||||
} |
||||
|
||||
private function getOrganizationEmailField() |
||||
{ |
||||
return $this->extraFieldRepo->findByVariable( |
||||
ExtraField::USER_FIELD_TYPE, |
||||
self::EXTRA_FIELD_ORGANISATION_EMAIL |
||||
); |
||||
} |
||||
|
||||
private function getAzureIdField() |
||||
{ |
||||
return $this->extraFieldRepo->findByVariable( |
||||
ExtraField::USER_FIELD_TYPE, |
||||
self::EXTRA_FIELD_AZURE_ID |
||||
); |
||||
} |
||||
|
||||
private function getAzureUidField() |
||||
{ |
||||
return $this->extraFieldRepo->findByVariable( |
||||
ExtraField::USER_FIELD_TYPE, |
||||
self::EXTRA_FIELD_AZURE_UID |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* @throws NonUniqueResultException |
||||
*/ |
||||
public function getUserIdByVerificationOrder(array $azureUserData, string $azureUidKey = 'objectId'): ?int |
||||
{ |
||||
$selectedOrder = $this->getExistingUserVerificationOrder(); |
||||
|
||||
$organisationEmailField = $this->getOrganizationEmailField(); |
||||
$azureIdField = $this->getAzureIdField(); |
||||
$azureUidField = $this->getAzureUidField(); |
||||
|
||||
/** @var array<int, ExtraFieldValues> $positionsAndFields */ |
||||
$positionsAndFields = [ |
||||
1 => $this->extraFieldValuesRepo->findByVariableAndValue($organisationEmailField, $azureUserData['mail']), |
||||
2 => $this->extraFieldValuesRepo->findByVariableAndValue($azureIdField, $azureUserData['mailNickname']), |
||||
3 => $this->extraFieldValuesRepo->findByVariableAndValue($azureUidField, $azureUserData[$azureUidKey]), |
||||
]; |
||||
|
||||
foreach ($selectedOrder as $position) { |
||||
if (!empty($positionsAndFields[$position])) { |
||||
return $positionsAndFields[$position]->getItemId(); |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
public function getExistingUserVerificationOrder(): array |
||||
{ |
||||
return [1, 2, 3]; |
||||
} |
||||
|
||||
private function formatUserData( |
||||
array $azureUserData, |
||||
string $azureUidKey |
||||
): array { |
||||
$phone = null; |
||||
|
||||
if (isset($azureUserData['telephoneNumber'])) { |
||||
$phone = $azureUserData['telephoneNumber']; |
||||
} elseif (isset($azureUserData['businessPhones'][0])) { |
||||
$phone = $azureUserData['businessPhones'][0]; |
||||
} elseif (isset($azureUserData['mobilePhone'])) { |
||||
$phone = $azureUserData['mobilePhone']; |
||||
} |
||||
|
||||
// If the option is set to create users, create it |
||||
$firstNme = $azureUserData['givenName']; |
||||
$lastName = $azureUserData['surname']; |
||||
$email = $azureUserData['mail']; |
||||
$username = $azureUserData['userPrincipalName']; |
||||
$authSource = 'azure'; |
||||
$active = ($azureUserData['accountEnabled'] ? 1 : 0); |
||||
$extra = [ |
||||
'extra_'.self::EXTRA_FIELD_ORGANISATION_EMAIL => $azureUserData['mail'], |
||||
'extra_'.self::EXTRA_FIELD_AZURE_ID => $azureUserData['mailNickname'], |
||||
'extra_'.self::EXTRA_FIELD_AZURE_UID => $azureUserData[$azureUidKey], |
||||
]; |
||||
|
||||
return [ |
||||
$firstNme, |
||||
$lastName, |
||||
$username, |
||||
$email, |
||||
$phone, |
||||
$authSource, |
||||
$active, |
||||
$extra, |
||||
]; |
||||
} |
||||
} |
@ -0,0 +1,48 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
namespace Chamilo\CoreBundle\ServiceHelper; |
||||
|
||||
use Chamilo\CoreBundle\Entity\ValidationToken; |
||||
use Chamilo\CoreBundle\Repository\ValidationTokenRepository; |
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; |
||||
|
||||
class ValidationTokenHelper |
||||
{ |
||||
public function __construct( |
||||
private readonly ValidationTokenRepository $tokenRepository, |
||||
private readonly UrlGeneratorInterface $urlGenerator, |
||||
) {} |
||||
|
||||
public function generateLink(int $type, int $resourceId): string |
||||
{ |
||||
$token = new ValidationToken($type, $resourceId); |
||||
$this->tokenRepository->save($token, true); |
||||
|
||||
return $this->urlGenerator->generate('validate_token', [ |
||||
'type' => $this->getTypeString($type), |
||||
'hash' => $token->getHash(), |
||||
], UrlGeneratorInterface::ABSOLUTE_URL); |
||||
} |
||||
|
||||
public function getTypeId(string $type): int |
||||
{ |
||||
return match ($type) { |
||||
'ticket' => 1, |
||||
'user' => 2, |
||||
default => throw new \InvalidArgumentException('Unrecognized validation type'), |
||||
}; |
||||
} |
||||
|
||||
private function getTypeString(int $type): string |
||||
{ |
||||
return match ($type) { |
||||
1 => 'ticket', |
||||
2 => 'user', |
||||
default => throw new \InvalidArgumentException('Unrecognized validation type'), |
||||
}; |
||||
} |
||||
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue