Merge pull request #3443 from gradido/project_brandings_add_db_and_graphql_resolver

feat(frontend): project branding and login forwarding to circle
This commit is contained in:
einhornimmond 2025-03-13 09:17:23 +01:00 committed by GitHub
commit 2353b93e57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 1517 additions and 141 deletions

View File

@ -55,7 +55,8 @@
"vue-router": "4.4.0",
"vue3-datepicker": "^0.4.0",
"vuex": "4.1.0",
"vuex-persistedstate": "4.1.0"
"vuex-persistedstate": "4.1.0",
"yup": "^1.6.1"
},
"devDependencies": {
"@apollo/client": "^3.10.8",
@ -88,6 +89,7 @@
"unplugin-icons": "^0.19.0",
"unplugin-vue-components": "^0.27.3",
"vite-plugin-environment": "^1.1.3",
"vite-plugin-graphql-loader": "^4.0.4",
"vitest": "^2.0.5",
"vitest-canvas-mock": "^0.3.3"
},

View File

@ -56,7 +56,7 @@ export default {
? formatDistanceToNow(new Date(dateString), {
includeSecond: true,
addSuffix: true,
locale: useDateLocale,
locale: useDateLocale(),
})
: ''
},

View File

@ -101,12 +101,13 @@ describe('NavBar', () => {
describe('Navbar Menu', () => {
it('has correct menu items', () => {
const navItems = wrapper.findAll('.nav-item a')
expect(navItems).toHaveLength(7)
expect(navItems).toHaveLength(8)
expect(navItems[0].attributes('href')).toBe('/user')
expect(navItems[1].attributes('href')).toBe('/creation-confirm')
expect(navItems[2].attributes('href')).toBe('/contribution-links')
expect(navItems[3].attributes('href')).toBe('/federation')
expect(navItems[4].attributes('href')).toBe('/statistic')
expect(navItems[4].attributes('href')).toBe('/projectBranding')
expect(navItems[5].attributes('href')).toBe('/statistic')
})
})

View File

@ -32,6 +32,13 @@
<BNavItem to="/federation" :active="isActive('federation')">
{{ $t('navbar.instances') }}
</BNavItem>
<BNavItem
to="/projectBranding"
:active="isActive('projectBranding')"
:title="$t('navbar.projectBrandingTooltip')"
>
{{ $t('navbar.projectBranding') }}
</BNavItem>
<BNavItem to="/statistic" :active="isActive('statistic')">
{{ $t('navbar.statistic') }}
</BNavItem>

View File

@ -0,0 +1,85 @@
<template>
<div>
<ul class="list-unstyled list-group mb-3">
<li
v-for="space in spaces"
:key="space.id"
:title="space.description"
:class="[
'list-group-item',
'list-group-item-action',
'd-flex',
'justify-content-between',
'align-items-center',
'cursor-pointer',
{ active: space.id === selectedSpaceId },
]"
@click="chooseSpace(space)"
>
<div>
<input v-model="selectedSpaceId" type="radio" :value="space.id" class="me-2" />
{{ space.name }}
</div>
<a :href="space.url" target="_blank" @click.stop>
{{ $t('projectBranding.openSpaceInHumhub') }}
</a>
</li>
</ul>
<b-pagination
v-if="result && pagination.total > ITEMS_PER_PAGE"
v-model="pagination.page"
:total-rows="pagination.total"
:per-page="ITEMS_PER_PAGE"
aria-controls="list-humhub-spaces"
@update:model-value="refetch({ page: $event })"
/>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { spaces as spacesQuery } from '@/graphql/projectBranding.graphql'
import { useQuery } from '@vue/apollo-composable'
const props = defineProps({
modelValue: {
type: Number,
default: null,
},
})
const emit = defineEmits(['chooseSpace'])
function chooseSpace(space) {
selectedSpaceId.value = space.id
emit('chooseSpace', space)
}
const ITEMS_PER_PAGE = 20
const page = ref(1)
const selectedSpaceId = ref(props.modelValue)
const { result, refetch } = useQuery(spacesQuery, { page: page.value, limit: ITEMS_PER_PAGE })
const spaces = computed(() => result.value?.spaces?.results || [])
const pagination = computed(() => result.value?.spaces?.pagination || {})
onMounted(() => {
if (props.modelValue) {
if (!spaces.value.some((space) => space.id === props.modelValue)) {
const targetPage = Math.ceil(props.modelValue / ITEMS_PER_PAGE)
page.value = targetPage
refetch({ page: targetPage })
}
}
})
</script>
<style scoped>
.list-group-item-action:hover:not(.active) {
background-color: #ececec;
color: #0056b3;
transition: background-color 0.2s ease-in-out;
}
.list-group-item-action.active > a {
color: white;
}
</style>

View File

@ -0,0 +1,171 @@
<template>
<div class="project-branding-form">
<BForm @submit.prevent="submit">
<ValidatedInput
:model-value="name"
name="name"
:label="$t('name')"
:rules="validationSchema.fields.name"
class="mb-3"
@update:model-value="updateField"
/>
<ValidatedInput
:model-value="alias"
name="alias"
:label="$t('alias')"
:rules="validationSchema.fields.alias"
class="mb-3"
@update:model-value="updateField"
/>
<ValidatedInput
:model-value="description"
name="description"
:label="$t('description')"
:rules="validationSchema.fields.description"
textarea="true"
class="mb-3"
@update:model-value="updateField"
/>
<BButton
variant="outline-secondary"
class="mb-3"
:title="result?.space.description"
@click="isModalVisible = true"
>
{{ selectedSpaceText }}
</BButton>
<BFormGroup
:label="$t('projectBranding.newUserToSpace')"
label-for="newUserToSpace-input-field"
class="mb-3"
>
<BFormCheckbox
id="newUserToSpace-input-field"
:model-value="newUserToSpace"
name="newUserToSpace"
value="true"
unchecked-value="false"
@update:model-value="(value) => updateField(value, 'newUserToSpace')"
>
{{ $t('projectBranding.newUserToSpaceTooltip') }}
</BFormCheckbox>
</BFormGroup>
<ValidatedInput
:model-value="logoUrl"
name="logoUrl"
:label="$t('logo')"
:rules="validationSchema.fields.logoUrl"
class="mb-3"
@update:model-value="updateField"
/>
<BFormInvalidFeedback v-if="errorMessage" class="d-block mb-3">
{{ errorMessage }}
</BFormInvalidFeedback>
<div class="d-flex gap-2">
<BButton type="submit" variant="primary">{{ $t('save') }}</BButton>
<BButton type="reset" variant="secondary" @click="resetForm">{{ $t('reset') }}</BButton>
</div>
</BForm>
<BModal v-model="isModalVisible" title="Select Space" hide-footer>
<ListHumhubSpaces :model-value="spaceId" @choose-space="chooseSpace" />
</BModal>
</div>
</template>
<script setup>
import ValidatedInput from '@/components/input/ValidatedInput'
import ListHumhubSpaces from '@/components/ProjectBranding/ListHumhubSpaces.vue'
import { spaceWithNameAndDescription } from '@/graphql/projectBranding.graphql'
import { useI18n } from 'vue-i18n'
import { reactive, computed, watch, ref } from 'vue'
import { object, string, boolean, number } from 'yup'
import { useQuery } from '@vue/apollo-composable'
const props = defineProps({
modelValue: { type: Object, required: true },
})
const form = reactive({ ...props.modelValue })
const isModalVisible = ref(false)
const errorMessage = ref('')
const { t } = useI18n()
watch(
() => props.modelValue,
(newValue) => Object.assign(form, newValue),
)
const name = computed(() => form.name)
const alias = computed(() => form.alias)
const description = computed(() => form.description)
const spaceId = computed(() => form.spaceId)
const newUserToSpace = computed(() => form.newUserToSpace)
const logoUrl = computed(() => form.logoUrl)
// show space
const { result } = useQuery(spaceWithNameAndDescription, () => ({ id: spaceId.value }), {
enabled: !!spaceId.value,
})
const selectedSpaceText = computed(() => {
if (!spaceId.value) {
return t('projectBranding.selectSpace')
}
if (!result.value?.space) {
return t('projectBranding.noAccessRightSpace', { spaceId: spaceId.value })
}
return t('projectBranding.chosenSpace', { space: result.value.space.name })
})
const validationSchema = object({
name: string().min(3).max(255).required(),
alias: string()
.matches(/^[a-z0-9-_]+$/, {
message: 'Alias can only contain lowercase letters, numbers, hyphens, and underscores.',
})
.min(3)
.max(32)
.required(),
description: string().nullable().optional(),
spaceId: number().nullable().optional(),
spaceUrl: string().url('Space URL must be a valid URL.').max(255).nullable().optional(),
newUserToSpace: boolean().optional(),
logoUrl: string().url('Logo URL must be a valid URL.').max(255).nullable().optional(),
})
function chooseSpace(value) {
updateField(value.id, 'spaceId')
updateField(value.url, 'spaceUrl')
}
function updateField(value, name) {
form[name] = value
}
const emit = defineEmits(['update:modelValue'])
function submit() {
validationSchema
.validate(form, { stripUnknown: true })
.then((cleanedForm) => {
emit('update:modelValue', { ...cleanedForm, id: props.modelValue.id })
})
.catch((err) => {
errorMessage.value = err.message
})
}
function resetForm() {
if (props.modelValue.id === undefined) {
Object.assign(form, {
name: '',
alias: '',
description: undefined,
spaceId: undefined,
spaceUrl: undefined,
newUserToSpace: false,
logoUrl: undefined,
})
return
} else {
Object.assign(form, props.modelValue)
}
errorMessage.value = ''
}
</script>

View File

@ -0,0 +1,117 @@
<template>
<div class="project-branding-item">
<BRow :title="item.description" @click="toggleDetails">
<BCol cols="3">
{{ item.name }}
<br />
{{ frontendLoginUrl }}
<BButton
v-if="frontendLoginUrl"
v-b-tooltip.hover
variant="secondary"
:title="$t('copy-to-clipboard')"
@click.stop="copyToClipboard(frontendLoginUrl)"
>
<i class="fas fa-copy"></i>
</BButton>
</BCol>
<BCol cols="2">{{ item.alias }}</BCol>
<BCol cols="2">
<span v-if="item.newUserToSpace" class="text-success">
<i class="fas fa-check"></i>
</span>
<span v-else class="text-danger">
<i class="fas fa-times"></i>
</span>
</BCol>
<BCol cols="3" class="me-2">
<img class="img-fluid" :src="item.logoUrl" :alt="item.logoUrl" />
</BCol>
<BCol v-if="store.state.moderator.roles.includes('ADMIN')" cols="1">
<BButton v-b-tooltip.hover variant="danger" :title="$t('delete')" @click.stop="deleteItem">
<i class="fas fa-trash-alt"></i>
</BButton>
</BCol>
</BRow>
<BRow v-if="details || item.id === undefined" class="details">
<BCol colspan="5">
<BCard>
<ProjectBrandingForm :model-value="item" @update:model-value="update" />
</BCard>
</BCol>
</BRow>
</div>
</template>
<script setup>
import { computed, ref, toRefs } from 'vue'
import ProjectBrandingForm from './ProjectBrandingForm.vue'
import { deleteProjectBranding, upsertProjectBranding } from '@/graphql/projectBranding.graphql'
import { useI18n } from 'vue-i18n'
import { useStore } from 'vuex'
import { useMutation } from '@vue/apollo-composable'
import CONFIG from '@/config'
import { useAppToast } from '@/composables/useToast'
const { t } = useI18n()
const store = useStore()
const { toastSuccess, toastError } = useAppToast()
const props = defineProps({
item: { type: Object, required: true },
})
const { item } = toRefs(props)
const details = ref(false)
const emit = defineEmits(['update:item', 'deleted:item'])
const frontendLoginUrl = computed(() => {
if (item.value.alias && item.value.alias.length > 0) {
return `${CONFIG.WALLET_LOGIN_URL}?project=${item.value.alias}`
}
return undefined
})
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text)
toastSuccess(t('copied-to-clipboard'))
} catch (err) {
toastError(err.message)
}
}
function toggleDetails() {
if (store.state.moderator.roles.includes('ADMIN')) {
details.value = !details.value
}
}
function update(form) {
const { mutate } = useMutation(upsertProjectBranding)
mutate({
input: { ...form },
})
.then(({ data }) => {
emit('update:item', data.upsertProjectBranding)
if (form.id) {
toastSuccess(t('projectBranding.updated'))
} else {
toastSuccess(t('projectBranding.created'))
}
details.value = false
})
.catch((error) => {
toastError(t('projectBranding.error', { message: error.message }))
})
}
function deleteItem() {
const { mutate } = useMutation(deleteProjectBranding)
mutate({
id: item.value.id,
}).then(() => {
emit('deleted:item', item.value.id)
})
}
</script>

View File

@ -0,0 +1,45 @@
<template>
<div :class="wrapperClassName">
<BFormGroup :label="label" :label-for="labelFor">
<BFormTextarea
v-if="textarea"
v-bind="{ ...$attrs, id: labelFor, name }"
v-model="model"
trim
:rows="4"
:max-rows="4"
no-resize
/>
<BFormInput v-else v-bind="{ ...$attrs, id: labelFor, name }" v-model="model" />
<slot></slot>
</BFormGroup>
</div>
</template>
<script setup>
import { computed, defineOptions, defineModel } from 'vue'
defineOptions({
inheritAttrs: false,
})
const props = defineProps({
label: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
textarea: {
type: Boolean,
required: false,
default: false,
},
})
const model = defineModel()
const wrapperClassName = computed(() => (props.name ? `input-${props.name}` : 'input'))
const labelFor = computed(() => `${props.name}-input-field`)
</script>

View File

@ -0,0 +1,86 @@
<template>
<LabeledInput
v-bind="$attrs"
:min="minValue"
:max="maxValue"
:model-value="model"
:reset-value="resetValue"
:locale="$i18n.locale"
:required="!isOptional"
:label="label"
:name="name"
:state="valid"
@update:model-value="updateValue"
>
<BFormInvalidFeedback v-if="errorMessage">
{{ errorMessage }}
</BFormInvalidFeedback>
</LabeledInput>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import LabeledInput from './LabeledInput'
const props = defineProps({
label: {
type: String,
required: true,
},
modelValue: [String, Number, Date],
name: {
type: String,
required: true,
},
rules: {
type: Object,
required: true,
},
})
const model = ref(props.modelValue)
const valid = computed(() => {
if (
(props.modelValue === undefined || props.modelValue === '' || props.modelValue === null) &&
isOptional.value
) {
return null
}
return props.rules.isValidSync(props.modelValue)
})
const errorMessage = computed(() => {
if (props.modelValue === undefined || props.modelValue === '' || props.modelValue === null) {
return undefined
}
try {
props.rules.validateSync(props.modelValue)
return undefined
} catch (e) {
return e.message
}
})
const emit = defineEmits(['update:modelValue'])
const updateValue = (newValue) => {
emit('update:modelValue', newValue, props.name, valid.value)
}
// update model and if value changed and model isn't null, check validation,
// for loading Input with existing value and show correct validation state
watch(
() => props.modelValue,
() => {
model.value = props.modelValue
},
)
// extract additional parameter like min and max from schema
const schemaDescription = computed(() => props.rules.describe())
const getTestParameter = (name) =>
schemaDescription.value?.tests?.find((t) => t.name === name)?.params[name]
const minValue = computed(() => getTestParameter('min'))
const maxValue = computed(() => getTestParameter('max'))
const resetValue = computed(() => schemaDescription.value.default)
const isOptional = computed(() => schemaDescription.value.optional)
</script>

View File

@ -6,7 +6,7 @@ const pkg = require('../../package')
const version = {
ADMIN_MODULE_PROTOCOL: process.env.ADMIN_MODULE_PROTOCOL ?? 'http',
ADMIN_MODULE_HOST: process.env.ADMIN_MODULE_HOST ?? 'localhost',
ADMIN_MODULE_HOST: process.env.ADMIN_MODULE_HOST ?? '0.0.0.0',
ADMIN_MODULE_PORT: process.env.ADMIN_MODULE_PORT ?? '8080',
APP_VERSION: pkg.version,
BUILD_COMMIT: process.env.BUILD_COMMIT ?? undefined,

View File

@ -0,0 +1,23 @@
fragment SpaceFields on Space {
id
name
description
url
}
fragment PaginationFields on Pagination {
total
page
pages
}
fragment ProjectBrandingCommonFields on ProjectBranding {
id
name
alias
description
spaceId
newUserToSpace
logoUrl
}

View File

@ -0,0 +1,35 @@
#import './fragments.graphql'
query projectBrandings {
projectBrandings {
...ProjectBrandingCommonFields
}
}
mutation upsertProjectBranding($input: ProjectBrandingInput!) {
upsertProjectBranding(input: $input) {
...ProjectBrandingCommonFields
}
}
mutation deleteProjectBranding($id: ID!) {
deleteProjectBranding(id: $id)
}
query spaces($page: Int!, $limit: Int!) {
spaces(page: $page, limit: $limit) {
pagination {
...PaginationFields
}
results {
...SpaceFields
}
}
}
query spaceWithNameAndDescription($id: ID!) {
space(id: $id) {
name
description
}
}

View File

@ -1,5 +1,7 @@
{
"GDD": "GDD",
"actions": "Aktionen",
"alias": "Alias",
"all_emails": "Alle Nutzer",
"back": "zurück",
"change_user_role": "Nutzerrolle ändern",
@ -42,6 +44,8 @@
"denied": "Abgelehnt",
"open": "Offen"
},
"copied-to-clipboard": "In die Zwischenablage kopiert",
"copy-to-clipboard": "In die Zwischenablage kopieren",
"created": "Geschöpft",
"createdAt": "Angelegt",
"creation": "Schöpfung",
@ -68,6 +72,7 @@
"deleted": "gelöscht",
"deleted_user": "Alle gelöschten Nutzer",
"deny": "Ablehnen",
"description": "Beschreibung",
"e_mail": "E-Mail",
"edit": "bearbeiten",
"enabled": "aktiviert",
@ -127,6 +132,8 @@
"lastname": "Nachname",
"latitude": "Breitengrad:",
"latitude-longitude-smart": "Breitengrad, Längengrad",
"link": "Link",
"logo": "Logo",
"longitude": "Längengrad:",
"math": {
"equals": "=",
@ -155,6 +162,8 @@
"instances": "Instanzen",
"logout": "Abmelden",
"my-account": "Mein Konto",
"projectBranding": "Projekt Branding",
"projectBrandingTooltip": "Nutze ein eigenes Logo im Gradido Login und füge neue Benutzer einem Humhub-Space hinzu",
"statistic": "Statistik",
"user_search": "Nutzersuche"
},
@ -199,8 +208,23 @@
"yes": "Ja, Nutzer wiederherstellen"
}
},
"projectBranding": {
"addTooltip": "Neuen Projekt Branding Eintrag hinzufügen",
"chosenSpace": "Gewählter Space: {space}",
"created": "Neuer Projekt Branding Eintrag wurde erstellt.",
"error": "Fehler beim Erstellen des Projekt Branding Eintrags: {message}",
"noAccessRightSpace": "Gewählter Space: {spaceId} (Keine Zugriffsrechte)",
"openSpaceInHumhub": "In Humhub öffnen",
"spaceId": "Humhub Space ID",
"selectSpace": "Humhub Space auswählen",
"title": "Projekt Brandings",
"updated": "Projekt Branding Eintrag wurde aktualisiert.",
"newUserToSpace": "Benutzer hinzufügen?",
"newUserToSpaceTooltip": "Neue Benutzer automatisch zum Space hinzufügen, falls Space vorhanden"
},
"redeemed": "eingelöst",
"removeNotSelf": "Als Admin/Moderator kannst du dich nicht selber löschen.",
"reset": "Zurücksetzen",
"save": "Speichern",
"statistic": {
"activeUsers": "Aktive Mitglieder",

View File

@ -1,5 +1,7 @@
{
"GDD": "GDD",
"actions": "Actions",
"alias": "Alias",
"all_emails": "All users",
"back": "back",
"change_user_role": "Change user role",
@ -42,6 +44,8 @@
"denied": "Rejected",
"open": "Open"
},
"copied-to-clipboard": "Copied to clipboard",
"copy-to-clipboard": "Copy to clipboard",
"created": "Created for",
"createdAt": "Created at",
"creation": "Creation",
@ -68,6 +72,7 @@
"deleted": "deleted",
"deleted_user": "All deleted user",
"deny": "Reject",
"description": "Description",
"e_mail": "E-mail",
"edit": "edit",
"enabled": "enabled",
@ -127,6 +132,8 @@
"lastname": "Lastname",
"latitude": "Latitude:",
"latitude-longitude-smart": "Latitude, Longitude",
"link": "Link",
"logo": "Logo",
"longitude": "Longitude:",
"math": {
"equals": "=",
@ -155,6 +162,8 @@
"instances": "Instances",
"logout": "Logout",
"my-account": "My Account",
"projectBranding": "Project Branding",
"projectBrandingTooltip": "Use your own logo in the Gradido login and add new users to a Humhub space",
"statistic": "Statistic",
"user_search": "User search"
},
@ -199,9 +208,24 @@
"yes": "Yes,undelete user"
}
},
"projectBranding": {
"addTooltip": "Add new project branding entry",
"chosenSpace": "Choosen Humhub Space: {space}",
"created": "New project branding entry has been created.",
"error": "Error when creating the project branding entry: {message}",
"noAccessRightSpace": "Selected space: {spaceId} (No access rights)",
"openSpaceInHumhub": "Open in Humhub",
"spaceId": "Humhub Space ID",
"selectSpace": "Select Humhub Space",
"title": "Project Branding",
"updated": "Project branding entry has been updated.",
"newUserToSpace": "Add user?",
"newUserToSpaceTooltip": "The hours should contain a maximum of two decimal places"
},
"redeemed": "redeemed",
"removeNotSelf": "As an admin/moderator, you cannot delete yourself.",
"save": "Speichern",
"reset": "Reset",
"save": "Save",
"statistic": {
"activeUsers": "Active members",
"count": "Count",

View File

@ -0,0 +1,109 @@
<template>
<div class="project-branding">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="h2">{{ $t('projectBranding.title') }}</span>
<div>
<BButton
v-if="store.state.moderator.roles.includes('ADMIN')"
variant="primary"
data-test="project-branding-add-btn"
font-scale="2"
class="me-3"
:title="$t('projectBranding.addTooltip')"
:disabled="isAddButtonDisabled"
@click="createEntry"
>
<IBiPlus />
</BButton>
<BButton
:animation="animation"
data-test="project-branding-refresh-btn"
font-scale="2"
@click="refetch"
>
<IBiArrowClockwise />
</BButton>
</div>
</div>
<BListGroup>
<BRow>
<BCol cols="3" class="ms-1">{{ $t('name') }} + {{ $t('link') }}</BCol>
<BCol cols="2">{{ $t('alias') }}</BCol>
<BCol cols="2" :title="$t('projectBranding.newUserToSpaceTooltip')">
{{ $t('projectBranding.newUserToSpace') }}
</BCol>
<BCol cols="3">{{ $t('logo') }}</BCol>
<BCol v-if="store.state.moderator.roles.includes('ADMIN')" cols="1">
{{ $t('actions') }}
</BCol>
</BRow>
<BListGroupItem v-for="item in projectBrandings" :key="item.id">
<project-branding-item
:item="item"
@update:item="handleUpdateItem"
@deleted:item="handleDeleteItem"
/>
</BListGroupItem>
</BListGroup>
</div>
</template>
<script setup>
import { computed, watch, ref } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { useStore } from 'vuex'
import { useAppToast } from '@/composables/useToast'
import { projectBrandings as projectBrandingsQuery } from '@/graphql/projectBranding.graphql'
const { toastError } = useAppToast()
const store = useStore()
const { result, loading, refetch, error } = useQuery(projectBrandingsQuery, null, {
fetchPolicy: 'network-only',
})
const projectBrandings = ref([])
const isAddButtonDisabled = computed(() => {
return projectBrandings.value.some((item) => item.id === undefined)
})
watch(
result,
() => {
projectBrandings.value = result.value?.projectBrandings || []
},
{ immediate: true },
)
function createEntry() {
projectBrandings.value.push({
id: undefined,
name: '',
alias: '',
description: undefined,
spaceId: undefined,
spaceUrl: undefined,
newUserToSpace: false,
logoUrl: undefined,
})
}
function handleUpdateItem(updatedItem) {
const index = projectBrandings.value.findIndex(
(item) => item.id === updatedItem.id || item.id === undefined,
)
projectBrandings.value.splice(index, 1, updatedItem)
}
function handleDeleteItem(id) {
const index = projectBrandings.value.findIndex((item) => item.id === id)
projectBrandings.value.splice(index, 1)
}
watch(error, () => {
if (error.value) toastError(error.value.message)
})
const animation = computed(() => (loading.value ? 'spin' : ''))
</script>

View File

@ -36,6 +36,11 @@ const routes = [
name: 'federation',
component: () => import('@/pages/FederationVisualize.vue'),
},
{
path: '/projectBranding',
name: 'projectBranding',
component: () => import('@/pages/ProjectBranding.vue'),
},
{
path: '/:catchAll(.*)',
name: 'NotFound',

View File

@ -17,7 +17,8 @@ const CONFIG = require('./src/config')
const path = require('path')
export default defineConfig(({ command }) => {
export default defineConfig(async ({ command }) => {
const { vitePluginGraphqlLoader } = await import('vite-plugin-graphql-loader')
if (command === 'serve') {
CONFIG.ADMIN_HOSTING = 'nodejs'
} else {
@ -79,6 +80,7 @@ export default defineConfig(({ command }) => {
DEBUG_DISABLE_AUTH: CONFIG.DEBUG_DISABLE_AUTH ?? null, // null,
// CONFIG_VERSION: CONFIG.CONFIG_VERSION, // null,
}),
vitePluginGraphqlLoader(),
commonjs(),
],
build: {

View File

@ -10,34 +10,37 @@ import path from 'path'
// },
// })
export default defineConfig({
plugins: [Vue()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./test/vitest.setup.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/**', 'src/assets/**', '**/*.{spec,test}.js'],
lines: 95,
export default defineConfig(async () => {
const { vitePluginGraphqlLoader } = await import('vite-plugin-graphql-loader')
return {
plugins: [Vue(), vitePluginGraphqlLoader()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./test/vitest.setup.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/**', 'src/assets/**', '**/*.{spec,test}.js'],
lines: 95,
},
include: ['**/?(*.)+(spec|test).js?(x)'],
moduleNameMapper: {
'^@/(.*)$': path.resolve(__dirname, './src/$1'),
'\\.(css|less)$': 'identity-obj-proxy',
},
transformMode: {
web: [/\.[jt]sx$/],
},
deps: {
inline: [/vee-validate/, 'vitest-canvas-mock'],
},
},
include: ['**/?(*.)+(spec|test).js?(x)'],
moduleNameMapper: {
'^@/(.*)$': path.resolve(__dirname, './src/$1'),
'\\.(css|less)$': 'identity-obj-proxy',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
},
transformMode: {
web: [/\.[jt]sx$/],
},
deps: {
inline: [/vee-validate/, 'vitest-canvas-mock'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
},
}
})

View File

@ -4241,7 +4241,7 @@ graphql-tag@^2.12.6, graphql-tag@^2.4.2:
dependencies:
tslib "^2.1.0"
graphql@^16.9.0:
graphql@^16.8.1, graphql@^16.9.0:
version "16.10.0"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c"
integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==
@ -5014,7 +5014,7 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
magic-string@^0.30.11, magic-string@^0.30.12, magic-string@^0.30.14:
magic-string@^0.30.10, magic-string@^0.30.11, magic-string@^0.30.12, magic-string@^0.30.14:
version "0.30.17"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==
@ -5657,6 +5657,11 @@ prop-types@^15.7.2:
object-assign "^4.1.1"
react-is "^16.13.1"
property-expr@^2.0.5:
version "2.0.6"
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8"
integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==
proto-list@~1.2.1:
version "1.2.4"
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
@ -6484,6 +6489,11 @@ throttle-debounce@^5.0.0:
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz#ec5549d84e053f043c9fd0f2a6dd892ff84456b1"
integrity sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==
tiny-case@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03"
integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==
tinybench@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b"
@ -6543,6 +6553,11 @@ toidentifier@1.0.1:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
toposort@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330"
integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==
tough-cookie@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.0.0.tgz#6b6518e2b5c070cf742d872ee0f4f92d69eac1af"
@ -6608,6 +6623,11 @@ type-fest@^0.20.2:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
type-fest@^2.19.0:
version "2.19.0"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b"
integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==
type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
@ -6823,6 +6843,15 @@ vite-plugin-environment@^1.1.3:
resolved "https://registry.yarnpkg.com/vite-plugin-environment/-/vite-plugin-environment-1.1.3.tgz#d01a04abb2f69730a4866c9c9db51d3dab74645b"
integrity sha512-9LBhB0lx+2lXVBEWxFZC+WO7PKEyE/ykJ7EPWCq95NEcCpblxamTbs5Dm3DLBGzwODpJMEnzQywJU8fw6XGGGA==
vite-plugin-graphql-loader@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/vite-plugin-graphql-loader/-/vite-plugin-graphql-loader-4.0.4.tgz#cf6c599b3e5fa32bf2b768983da68f7beccc8486"
integrity sha512-lYnpQ2luV2fcuXmOJADljuktfMbDW00Y+6QS+Ek8Jz1Vdzlj/51LSGJwZqyjJ24a5YQ+o29Hr6el/5+nlZetvg==
dependencies:
graphql "^16.8.1"
graphql-tag "^2.12.6"
magic-string "^0.30.10"
vite@3.2.10:
version "3.2.10"
resolved "https://registry.yarnpkg.com/vite/-/vite-3.2.10.tgz#7ac79fead82cfb6b5bf65613cd82fba6dcc81340"
@ -7145,6 +7174,16 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
yup@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/yup/-/yup-1.6.1.tgz#8defcff9daaf9feac178029c0e13b616563ada4b"
integrity sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==
dependencies:
property-expr "^2.0.5"
tiny-case "^1.0.3"
toposort "^2.0.2"
type-fest "^2.19.0"
zen-observable-ts@^0.8.21:
version "0.8.21"
resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz#85d0031fbbde1eba3cd07d3ba90da241215f421d"

View File

@ -1,3 +1,4 @@
import { ProjectBranding } from '@entity/ProjectBranding'
import { SignJWT } from 'jose'
import { IRequestOptions, IRestResponse, RestClient } from 'typed-rest-client'
@ -8,6 +9,8 @@ import { backendLogger as logger } from '@/server/logger'
import { PostUserLoggingView } from './logging/PostUserLogging.view'
import { GetUser } from './model/GetUser'
import { PostUser } from './model/PostUser'
import { Space } from './model/Space'
import { SpacesResponse } from './model/SpacesResponse'
import { UsersResponse } from './model/UsersResponse'
/**
@ -61,16 +64,28 @@ export class HumHubClient {
return token
}
public async createAutoLoginUrl(username: string) {
public async createAutoLoginUrl(username: string, project?: string | null) {
const secret = new TextEncoder().encode(CONFIG.HUMHUB_JWT_KEY)
logger.info(`user ${username} as username for humhub auto-login`)
const token = await new SignJWT({ username })
let redirectLink: string | undefined
if (project) {
const projectBranding = await ProjectBranding.findOne({
where: { alias: project },
select: { spaceUrl: true },
})
if (projectBranding?.spaceUrl) {
redirectLink = projectBranding.spaceUrl
}
}
const token = await new SignJWT({ username, redirectLink })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(CONFIG.JWT_EXPIRES_IN)
.sign(secret)
return `${CONFIG.HUMHUB_API_URL}user/auth/external?authclient=jwt&jwt=${token}`
return `${CONFIG.HUMHUB_API_URL}${
CONFIG.HUMHUB_API_URL.endsWith('/') ? '' : '/'
}user/auth/external?authclient=jwt&jwt=${token}`
}
/**
@ -186,6 +201,40 @@ export class HumHubClient {
throw new LogError('error deleting user', { userId: humhubUserId, response })
}
}
}
// new RestClient('gradido', 'api/v1/')
// get spaces from humhub
// https://marketplace.humhub.com/module/rest/docs/html/space.html#tag/Space/paths/~1space/get
public async spaces(page = 0, limit = 20): Promise<SpacesResponse | null> {
const options = await this.createRequestOptions({ page, limit })
const response = await this.restClient.get<SpacesResponse>('/api/v1/space', options)
if (response.statusCode !== 200) {
throw new LogError('error requesting spaces from humhub', response)
}
return response.result
}
// get space by id from humhub instance
// https://marketplace.humhub.com/module/rest/docs/html/space.html#tag/Space/paths/~1space~1{id}/get
public async space(spaceId: number): Promise<Space | null> {
const options = await this.createRequestOptions()
const response = await this.restClient.get<Space>(`/api/v1/space/${spaceId}`, options)
if (response.statusCode !== 200) {
throw new LogError('error requesting space from humhub', response)
}
return response.result
}
// add user to space
// https://marketplace.humhub.com/module/rest/docs/html/space.html#tag/Membership/paths/~1space~1%7Bid%7D~1membership~1%7BuserId%7D/post
public async addUserToSpace(userId: number, spaceId: number): Promise<void> {
const options = await this.createRequestOptions()
const response = await this.restClient.create(
`/api/v1/space/${spaceId}/membership/${userId}`,
{ userId },
options,
)
if (response.statusCode !== 200) {
throw new LogError('error adding user to space', response)
}
}
}

View File

@ -0,0 +1,7 @@
export class Space {
id: number
guid: string
name: string
description: string
url: string
}

View File

@ -0,0 +1,8 @@
import { Space } from './Space'
export interface SpacesResponse {
total: number
page: number
pages: number
results: Space[]
}

View File

@ -8,4 +8,5 @@ export const ADMIN_RIGHTS = [
RIGHTS.COMMUNITY_BY_UUID,
RIGHTS.COMMUNITY_BY_IDENTIFIER,
RIGHTS.HOME_COMMUNITY,
RIGHTS.PROJECT_BRANDING_MUTATE,
]

View File

@ -9,4 +9,5 @@ export const INALIENABLE_RIGHTS = [
RIGHTS.QUERY_TRANSACTION_LINK,
RIGHTS.QUERY_OPT_IN,
RIGHTS.CHECK_USERNAME,
RIGHTS.PROJECT_BRANDING_BANNER,
]

View File

@ -8,6 +8,7 @@ export enum RIGHTS {
QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK',
QUERY_OPT_IN = 'QUERY_OPT_IN',
CHECK_USERNAME = 'CHECK_USERNAME',
PROJECT_BRANDING_BANNER = 'PROJECT_BRANDING_BANNER',
// User
VERIFY_LOGIN = 'VERIFY_LOGIN',
BALANCE = 'BALANCE',
@ -39,6 +40,8 @@ export enum RIGHTS {
USER = 'USER',
GMS_USER_PLAYGROUND = 'GMS_USER_PLAYGROUND',
HUMHUB_AUTO_LOGIN = 'HUMHUB_AUTO_LOGIN',
PROJECT_BRANDING_VIEW = 'PROJECT_BRANDING_VIEW',
LIST_HUMHUB_SPACES = 'LIST_HUMHUB_SPACES',
// Moderator
SEARCH_USERS = 'SEARCH_USERS',
ADMIN_CREATE_CONTRIBUTION = 'ADMIN_CREATE_CONTRIBUTION',
@ -64,4 +67,5 @@ export enum RIGHTS {
COMMUNITY_BY_IDENTIFIER = 'COMMUNITY_BY_IDENTIFIER',
HOME_COMMUNITY = 'HOME_COMMUNITY',
COMMUNITY_UPDATE = 'COMMUNITY_UPDATE',
PROJECT_BRANDING_MUTATE = 'PROJECT_BRANDING_MUTATE',
}

View File

@ -31,4 +31,6 @@ export const USER_RIGHTS = [
RIGHTS.USER,
RIGHTS.GMS_USER_PLAYGROUND,
RIGHTS.HUMHUB_AUTO_LOGIN,
RIGHTS.PROJECT_BRANDING_VIEW,
RIGHTS.LIST_HUMHUB_SPACES,
]

View File

@ -81,8 +81,8 @@ const loginServer = {
}
const email = {
EMAIL: process.env.EMAIL === 'true' ?? false,
EMAIL_TEST_MODUS: process.env.EMAIL_TEST_MODUS === 'true' ?? false,
EMAIL: process.env.EMAIL === 'true' || false,
EMAIL_TEST_MODUS: process.env.EMAIL_TEST_MODUS === 'true' || false,
EMAIL_TEST_RECEIVER: process.env.EMAIL_TEST_RECEIVER ?? 'stage1@gradido.net',
EMAIL_USERNAME: process.env.EMAIL_USERNAME ?? '',
EMAIL_SENDER: process.env.EMAIL_SENDER ?? 'info@gradido.net',
@ -123,7 +123,7 @@ const federation = {
process.env.FEDERATION_VALIDATE_COMMUNITY_TIMER ?? 60000,
),
FEDERATION_XCOM_SENDCOINS_ENABLED:
process.env.FEDERATION_XCOM_SENDCOINS_ENABLED === 'true' ?? false,
process.env.FEDERATION_XCOM_SENDCOINS_ENABLED === 'true' || false,
// default value for community-uuid is equal uuid of stage-3
FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID:
process.env.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID ?? '56a55482-909e-46a4-bfa2-cd025e894ebc',

View File

@ -40,6 +40,7 @@ export const sendAccountActivationEmail = (data: {
language: string
activationLink: string
timeDurationObject: Record<string, unknown>
logoUrl?: string | null
}): Promise<Record<string, unknown> | boolean | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
@ -50,6 +51,7 @@ export const sendAccountActivationEmail = (data: {
locale: data.language,
activationLink: data.activationLink,
timeDurationObject: data.timeDurationObject,
logoUrl: data.logoUrl,
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
supportEmail: CONFIG.COMMUNITY_SUPPORT_MAIL,
communityURL: CONFIG.COMMUNITY_URL,

View File

@ -1,6 +1,8 @@
extend ../layout.pug
block content
if logoUrl
img(src=logoUrl, alt="Banner", style="max-width: 680px; max-height: 250px;border-radius:20px")
h2= t('emails.accountActivation.title')
.text-block
include ../includes/salutation.pug

View File

@ -30,4 +30,8 @@ export class CreateUserArgs {
@Field(() => String, { nullable: true })
@IsString()
redeemCode?: string | null
@Field(() => String, { nullable: true })
@IsString()
project?: string | null
}

View File

@ -14,4 +14,8 @@ export class UnsecureLoginArgs {
@Field(() => Int, { nullable: true })
@IsInt()
publisherId?: number | null
@Field(() => String, { nullable: true })
@IsString()
project?: string | null
}

View File

@ -0,0 +1,44 @@
import { IsString, IsOptional, MaxLength, IsNumber, IsBoolean, IsUrl } from 'class-validator'
import { InputType, Field, Int } from 'type-graphql'
@InputType()
export class ProjectBrandingInput {
@Field(() => Int, { nullable: true })
@IsOptional()
id: number | null | undefined
@Field(() => String)
@IsString()
name: string
@Field(() => String)
@IsString()
@MaxLength(32)
alias: string
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
description: string | null | undefined
@Field(() => Int, { nullable: true })
@IsNumber()
@IsOptional()
spaceId: number | null | undefined
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@IsUrl()
spaceUrl: string | null | undefined
@Field(() => Boolean)
@IsBoolean()
newUserToSpace: boolean
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@IsUrl()
logoUrl: string | null | undefined
}

View File

@ -0,0 +1,19 @@
import { ObjectType, Field, Int } from 'type-graphql'
@ObjectType()
export class Pagination {
@Field(() => Int)
total: number
@Field(() => Int)
page: number
@Field(() => Int)
pages: number
constructor(total: number, page: number, pages: number) {
this.total = total
this.page = page
this.pages = pages
}
}

View File

@ -0,0 +1,33 @@
import { ProjectBranding as dbProjectBranding } from '@entity/ProjectBranding'
import { ObjectType, Field, Int } from 'type-graphql'
@ObjectType()
export class ProjectBranding {
constructor(projectBranding: dbProjectBranding) {
Object.assign(this, projectBranding)
}
@Field(() => Int)
id: number
@Field(() => String)
name: string
@Field(() => String)
alias: string
@Field(() => String, { nullable: true })
description: string | null
@Field(() => Int, { nullable: true })
spaceId: number | null
@Field(() => String, { nullable: true })
spaceUrl: string | null
@Field(() => Boolean)
newUserToSpace: boolean
@Field(() => String, { nullable: true })
logoUrl: string | null
}

View File

@ -0,0 +1,25 @@
import { ObjectType, Field, Int } from 'type-graphql'
import { Space as HumhubSpace } from '@/apis/humhub/model/Space'
@ObjectType()
export class Space {
@Field(() => Int)
id: number
@Field(() => String)
guid: string
@Field(() => String)
name: string
@Field(() => String)
description: string
@Field(() => String)
url: string
constructor(data: HumhubSpace) {
Object.assign(this, data)
}
}

View File

@ -0,0 +1,20 @@
import { ObjectType, Field } from 'type-graphql'
import { SpacesResponse } from '@/apis/humhub/model/SpacesResponse'
import { Pagination } from './Pagination'
import { Space } from './Space'
@ObjectType()
export class SpaceList {
@Field(() => Pagination)
pagination: Pagination
@Field(() => [Space])
results: Space[]
constructor(data: SpacesResponse) {
this.pagination = new Pagination(data.total, data.page, data.pages)
this.results = data.results
}
}

View File

@ -153,9 +153,9 @@ describe('ContributionMessageResolver', () => {
message: 'Test',
},
})
expect(logger.debug).toBeCalledTimes(4)
expect(logger.debug).toBeCalledTimes(5)
expect(logger.debug).toHaveBeenNthCalledWith(
4,
5,
'use UnconfirmedContributionUserAddMessageRole',
)
expect(mutationResult).toEqual(
@ -351,9 +351,9 @@ describe('ContributionMessageResolver', () => {
},
})
expect(logger.debug).toBeCalledTimes(4)
expect(logger.debug).toBeCalledTimes(5)
expect(logger.debug).toHaveBeenNthCalledWith(
4,
5,
'use UnconfirmedContributionAdminAddMessageRole',
)
@ -386,9 +386,9 @@ describe('ContributionMessageResolver', () => {
},
})
expect(logger.debug).toBeCalledTimes(4)
expect(logger.debug).toBeCalledTimes(5)
expect(logger.debug).toHaveBeenNthCalledWith(
4,
5,
'use UnconfirmedContributionAdminAddMessageRole',
)
@ -404,7 +404,7 @@ describe('ContributionMessageResolver', () => {
})
it('logs the error "ContributionMessage was not sent successfully: Error: missing right ADMIN_CREATE_CONTRIBUTION_MESSAGE for user"', () => {
expect(logger.debug).toBeCalledTimes(4)
expect(logger.debug).toBeCalledTimes(5)
expect(logger.error).toHaveBeenNthCalledWith(
1,
'missing right ADMIN_CREATE_CONTRIBUTION_MESSAGE for user',

View File

@ -0,0 +1,106 @@
import { ProjectBranding as DbProjectBranding } from '@entity/ProjectBranding'
import { Resolver, Query, Mutation, Arg, Int, Authorized, ID } from 'type-graphql'
import { ProjectBrandingInput } from '@input/ProjectBrandingInput'
import { ProjectBranding } from '@model/ProjectBranding'
import { Space } from '@model/Space'
import { SpaceList } from '@model/SpaceList'
import { HumHubClient } from '@/apis/humhub/HumHubClient'
import { RIGHTS } from '@/auth/RIGHTS'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
@Resolver(() => ProjectBranding)
export class ProjectBrandingResolver {
@Query(() => [ProjectBranding])
@Authorized([RIGHTS.PROJECT_BRANDING_VIEW])
async projectBrandings(): Promise<ProjectBranding[]> {
return (await DbProjectBranding.find()).map(
(entity: DbProjectBranding) => new ProjectBranding(entity),
)
}
@Query(() => ProjectBranding)
@Authorized([RIGHTS.PROJECT_BRANDING_VIEW])
async projectBranding(@Arg('id', () => Int) id: number): Promise<ProjectBranding> {
const projectBrandingEntity = await DbProjectBranding.findOneBy({ id })
if (!projectBrandingEntity) {
throw new LogError(`Project Branding with id: ${id} not found`)
}
return new ProjectBranding(projectBrandingEntity)
}
@Query(() => String, { nullable: true })
@Authorized([RIGHTS.PROJECT_BRANDING_BANNER])
async projectBrandingBanner(@Arg('alias', () => String) alias: string): Promise<string | null> {
const projectBrandingEntity = await DbProjectBranding.findOne({
where: { alias },
select: { id: true, logoUrl: true },
})
if (!projectBrandingEntity) {
throw new LogError(`Project Branding with alias: ${alias} not found`)
}
return projectBrandingEntity.logoUrl
}
@Mutation(() => ProjectBranding, { nullable: true })
@Authorized([RIGHTS.PROJECT_BRANDING_MUTATE])
async upsertProjectBranding(
@Arg('input') input: ProjectBrandingInput,
): Promise<ProjectBranding | null> {
const projectBranding = input.id
? await DbProjectBranding.findOneOrFail({ where: { id: input.id } })
: new DbProjectBranding()
Object.assign(projectBranding, input)
await projectBranding.save()
return new ProjectBranding(projectBranding)
}
@Mutation(() => Boolean)
@Authorized([RIGHTS.PROJECT_BRANDING_MUTATE])
async deleteProjectBranding(@Arg('id', () => ID) id: number): Promise<boolean> {
try {
await DbProjectBranding.delete({ id })
return true
} catch (err) {
logger.error(err)
return false
}
}
@Query(() => Space)
@Authorized([RIGHTS.LIST_HUMHUB_SPACES])
async space(@Arg('id', () => ID) id: number): Promise<Space> {
const humhub = HumHubClient.getInstance()
if (!humhub) {
throw new LogError('HumHub client not initialized')
}
const space = await humhub.space(id)
if (!space) {
throw new LogError(`Error requesting space with id: ${id} from HumHub`)
}
return new Space(space)
}
@Query(() => SpaceList)
@Authorized([RIGHTS.LIST_HUMHUB_SPACES])
async spaces(
@Arg('page', () => Int, { defaultValue: 1 }) page: number,
@Arg('limit', () => Int, { defaultValue: 20 }) limit: number,
): Promise<SpaceList> {
const humhub = HumHubClient.getInstance()
if (!humhub) {
throw new LogError('HumHub client not initialized')
}
const offset = (page - 1) * limit
const spaces = await humhub.spaces(offset, limit)
if (!spaces) {
throw new LogError('Error requesting spaces from HumHub')
}
return new SpaceList(spaces)
}
}

View File

@ -4,6 +4,7 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { getConnection, In, Point } from '@dbTools/typeorm'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { ProjectBranding } from '@entity/ProjectBranding'
import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink'
import { User as DbUser } from '@entity/User'
import { UserContact as DbUserContact } from '@entity/UserContact'
@ -145,10 +146,10 @@ export class UserResolver {
@Authorized([RIGHTS.LOGIN])
@Mutation(() => User)
async login(
@Args() { email, password, publisherId }: UnsecureLoginArgs,
@Args() { email, password, publisherId, project }: UnsecureLoginArgs,
@Ctx() context: Context,
): Promise<User> {
logger.info(`login with ${email}, ***, ${publisherId} ...`)
logger.info(`login with ${email}, ***, ${publisherId}, project=${project} ...`)
email = email.trim().toLowerCase()
let dbUser: DbUser
@ -177,6 +178,7 @@ export class UserResolver {
// request to humhub and klicktipp run in parallel
let humhubUserPromise: Promise<IRestResponse<GetUser>> | undefined
let projectBrandingPromise: Promise<ProjectBranding | null> | undefined
const klicktippStatePromise = getKlicktippState(dbUser.emailContact.email)
if (CONFIG.HUMHUB_ACTIVE && dbUser.humhubAllowed) {
const getHumhubUser = new PostUser(dbUser)
@ -184,6 +186,12 @@ export class UserResolver {
getHumhubUser.account.username,
)
}
if (project) {
projectBrandingPromise = ProjectBranding.findOne({
where: { alias: project },
select: { spaceId: true },
})
}
if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) {
dbUser.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
@ -213,11 +221,20 @@ export class UserResolver {
})
await EVENT_USER_LOGIN(dbUser)
const projectBranding = await projectBrandingPromise
logger.debug('project branding: ', projectBranding)
// load humhub state
if (humhubUserPromise) {
try {
const result = await humhubUserPromise
user.humhubAllowed = result?.result?.account.status === 1
if (user.humhubAllowed) {
let spaceId = null
if (projectBranding) {
spaceId = projectBranding.spaceId
}
void syncHumhub(null, dbUser, spaceId)
}
} catch (e) {
logger.error("couldn't reach out to humhub, disable for now", e)
user.humhubAllowed = false
@ -249,11 +266,12 @@ export class UserResolver {
language,
publisherId = null,
redeemCode = null,
project = null,
}: CreateUserArgs,
): Promise<User> {
logger.addContext('user', 'unknown')
logger.info(
`createUser(email=${email}, firstName=${firstName}, lastName=${lastName}, language=${language}, publisherId=${publisherId}, redeemCode =${redeemCode})`,
`createUser(email=${email}, firstName=${firstName}, lastName=${lastName}, language=${language}, publisherId=${publisherId}, redeemCode=${redeemCode}, project=${project})`,
)
// TODO: wrong default value (should be null), how does graphql work here? Is it an required field?
// default int publisher_id = 0;
@ -308,7 +326,13 @@ export class UserResolver {
return user
}
}
let projectBrandingPromise: Promise<ProjectBranding | null> | undefined
if (project) {
projectBrandingPromise = ProjectBranding.findOne({
where: { alias: project },
select: { logoUrl: true, spaceId: true },
})
}
const gradidoID = await newGradidoID()
const eventRegisterRedeem = Event(
@ -356,6 +380,7 @@ export class UserResolver {
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('REPEATABLE READ')
let projectBranding: ProjectBranding | null | undefined
try {
dbUser = await queryRunner.manager.save(dbUser).catch((error) => {
throw new LogError('Error while saving dbUser', error)
@ -373,8 +398,11 @@ export class UserResolver {
const activationLink = `${
CONFIG.EMAIL_LINK_VERIFICATION
}${emailContact.emailVerificationCode.toString()}${redeemCode ? `/${redeemCode}` : ''}`
}${emailContact.emailVerificationCode.toString()}${redeemCode ? `/${redeemCode}` : ''}${
project ? `?project=` + project : ''
}`
projectBranding = projectBrandingPromise ? await projectBrandingPromise : undefined
void sendAccountActivationEmail({
firstName,
lastName,
@ -382,6 +410,7 @@ export class UserResolver {
language,
activationLink,
timeDurationObject: getTimeDurationObject(CONFIG.EMAIL_CODE_VALID_TIME),
logoUrl: projectBranding?.logoUrl,
})
logger.info(`sendAccountActivationEmail of ${firstName}.${lastName} to ${email}`)
@ -397,7 +426,11 @@ export class UserResolver {
}
logger.info('createUser() successful...')
if (CONFIG.HUMHUB_ACTIVE) {
void syncHumhub(null, dbUser)
let spaceId: number | null = null
if (projectBranding) {
spaceId = projectBranding.spaceId
}
void syncHumhub(null, dbUser, spaceId)
}
if (redeemCode) {
@ -771,7 +804,10 @@ export class UserResolver {
@Authorized([RIGHTS.HUMHUB_AUTO_LOGIN])
@Query(() => String)
async authenticateHumhubAutoLogin(@Ctx() context: Context): Promise<string> {
async authenticateHumhubAutoLogin(
@Ctx() context: Context,
@Arg('project', () => String, { nullable: true }) project?: string | null,
): Promise<string> {
logger.info(`authenticateHumhubAutoLogin()...`)
const dbUser = getUser(context)
const humhubClient = HumHubClient.getInstance()
@ -790,7 +826,7 @@ export class UserResolver {
if (humhubUser.account.status !== 1) {
throw new LogError('user status is not 1', humhubUser.account.status)
}
return await humhubClient.createAutoLoginUrl(humhubUser.account.username)
return await humhubClient.createAutoLoginUrl(humhubUser.account.username, project)
}
@Authorized([RIGHTS.SEARCH_ADMIN_USERS])

View File

@ -5,6 +5,7 @@ import { ApolloServerTestClient } from 'apollo-server-testing'
import { testEnvironment, cleanDB, contributionDateFormatter } from '@test/helpers'
import { CONFIG } from '@/config'
import { userFactory } from '@/seeds/factory/user'
import { login, createContribution, adminCreateContribution } from '@/seeds/graphql/mutations'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
@ -14,6 +15,8 @@ import { getOpenCreations, getUserCreation } from './creations'
jest.mock('@/password/EncryptorUtils')
CONFIG.HUMHUB_ACTIVE = false
let mutate: ApolloServerTestClient['mutate'], con: Connection
let testEnv: {
mutate: ApolloServerTestClient['mutate']
@ -272,6 +275,7 @@ describe('util/creation', () => {
})
})
})
describe('getOpenCreations', () => {
beforeAll(() => {
// enable Fake timers

View File

@ -6,10 +6,17 @@ import { ExecutedHumhubAction, syncUser } from '@/apis/humhub/syncUser'
import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs'
import { backendLogger as logger } from '@/server/logger'
/**
* Syncs the user with humhub
* @param updateUserInfosArg
* @param user
* @returns humhub user id or undefined
*/
export async function syncHumhub(
updateUserInfosArg: UpdateUserInfosArgs | null,
user: User,
): Promise<void> {
spaceId?: number | null,
): Promise<number | undefined> {
// check for humhub relevant changes
if (
updateUserInfosArg &&
@ -47,4 +54,9 @@ export async function syncHumhub(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
result: ExecutedHumhubAction[result as ExecutedHumhubAction],
})
if (spaceId && humhubUser) {
await humhubClient.addUserToSpace(humhubUser.id, spaceId)
logger.debug(`user added to space ${spaceId}`)
}
return user.id
}

View File

@ -0,0 +1,28 @@
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from 'typeorm'
@Entity('project_brandings')
export class ProjectBranding extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'name', type: 'varchar', length: 255 })
name: string
@Column({ name: 'alias', type: 'varchar', length: 32 })
alias: string
@Column({ name: 'description', type: 'text', nullable: true, default: null })
description: string | null
@Column({ name: 'space_id', type: 'int', unsigned: true, nullable: true, default: null })
spaceId: number | null
@Column({ name: 'space_url', type: 'varchar', length: 255, nullable: true, default: null })
spaceUrl: string | null
@Column({ name: 'new_user_to_space', type: 'tinyint', width: 1, default: 0 })
newUserToSpace: boolean
@Column({ name: 'logo_url', type: 'varchar', length: 255, nullable: true, default: null })
logoUrl: string | null
}

View File

@ -0,0 +1 @@
export { ProjectBranding } from './0088-create_project_brandings/ProjectBranding'

View File

@ -2,6 +2,7 @@ import { ContributionLink } from './ContributionLink'
import { LoginElopageBuys } from './LoginElopageBuys'
import { LoginEmailOptIn } from './LoginEmailOptIn'
import { Migration } from './Migration'
import { ProjectBranding } from './ProjectBranding'
import { Transaction } from './Transaction'
import { TransactionLink } from './TransactionLink'
import { User } from './User'
@ -26,6 +27,7 @@ export const entities = [
LoginElopageBuys,
LoginEmailOptIn,
Migration,
ProjectBranding,
PendingTransaction,
Transaction,
TransactionLink,

View File

@ -0,0 +1,26 @@
/* MIGRATION TO CREATE THE project_brandings table
*
* This migration creates the `community` and 'communityfederation' tables in the `apollo` database (`gradido_community`).
*/
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`
CREATE TABLE \`project_brandings\`(
\`id\` INT UNSIGNED NOT NULL AUTO_INCREMENT,
\`name\` VARCHAR(255) NOT NULL,
\`alias\` VARCHAR(32) NOT NULL,
\`description\` TEXT NULL DEFAULT NULL,
\`space_id\` INT UNSIGNED NULL DEFAULT NULL,
\`space_url\` VARCHAR(255) NULL DEFAULT NULL,
\`new_user_to_space\` TINYINT(1) NOT NULL DEFAULT FALSE,
\`logo_url\` VARCHAR(255) NULL DEFAULT NULL,
PRIMARY KEY(\`id\`)
) ENGINE = InnoDB;
`)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('DROP TABLE `project_brandings`')
}

1
frontend/.gitignore vendored
View File

@ -2,6 +2,7 @@
node_modules/
build/
src/assets/css/gradido.css
src/assets/css/gradido.css.map
.cache/
.yarn/install-state.gz

View File

@ -106,6 +106,7 @@
"unplugin-icons": "^0.19.1",
"unplugin-vue-components": "^0.27.3",
"vite-plugin-environment": "^1.1.3",
"vite-plugin-graphql-loader": "^4.0.4",
"vite-plugin-html": "^3.2.2",
"vitest": "^2.0.5",
"vitest-canvas-mock": "^0.3.3"

File diff suppressed because one or more lines are too long

View File

@ -134,6 +134,14 @@ a:hover,
border-radius: 26px;
}
.rounded-20 {
border-radius: 20px;
}
.rounded-like-card {
border-radius: 23pt;
}
.alert {
border-radius: 26px;
box-shadow: rgb(0 0 0 / 14%) 0 24px 80px;

View File

@ -22,6 +22,12 @@ vi.mock('@vue/apollo-composable', () => ({
})),
}))
vi.mock('@/composables/useToast', () => ({
useAppToast: vi.fn(() => ({
addToast: vi.fn(),
})),
}))
describe('ContributionListItem', () => {
let wrapper

View File

@ -68,6 +68,7 @@ export const createUser = gql`
$language: String!
$publisherId: Int
$redeemCode: String
$project: String
) {
createUser(
email: $email
@ -76,6 +77,7 @@ export const createUser = gql`
language: $language
publisherId: $publisherId
redeemCode: $redeemCode
project: $project
) {
id
}
@ -166,8 +168,8 @@ export const createContributionMessage = gql`
`
export const login = gql`
mutation ($email: String!, $password: String!, $publisherId: Int) {
login(email: $email, password: $password, publisherId: $publisherId) {
mutation ($email: String!, $password: String!, $publisherId: Int, $project: String) {
login(email: $email, password: $password, publisherId: $publisherId, project: $project) {
gradidoID
alias
firstName

View File

@ -0,0 +1,3 @@
query projectBrandingBanner($project: String!) {
projectBrandingBanner(alias: $project)
}

View File

@ -42,6 +42,11 @@ export const authenticateHumhubAutoLogin = gql`
authenticateHumhubAutoLogin
}
`
export const authenticateHumhubAutoLoginProject = gql`
query ($project: String!) {
authenticateHumhubAutoLogin(project: $project)
}
`
export const transactionsQuery = gql`
query ($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) {

View File

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createStore } from 'vuex'
import AuthLayout from './AuthLayout'
import {
BAvatar,
@ -39,6 +40,19 @@ vi.mock('@/config', () => ({
describe('AuthLayout', () => {
let wrapper
const createVuexStore = () => {
return createStore({
state: {
project: '',
},
actions: {
project: vi.fn(),
},
mutations: {
project: vi.fn(),
},
})
}
const createWrapper = () => {
return mount(AuthLayout, {
@ -54,6 +68,7 @@ describe('AuthLayout', () => {
BImg,
BPopover,
},
plugins: [createVuexStore()],
mocks: {
$i18n: {
locale: 'en',
@ -97,17 +112,16 @@ describe('AuthLayout', () => {
expect(wrapper.find('nav#sidenav-main').exists()).toBe(false)
})
it('has LanguageSwitch2', () => {
expect(wrapper.findComponent({ name: 'LanguageSwitch2' }).exists()).toBe(true)
})
it('displays the community name', () => {
expect(wrapper.find('.h1').text()).toBe('Test Community')
})
it('test size in setTextSize', async () => {
const mockEl = { style: {} }
vi.spyOn(document, 'querySelector').mockReturnValue(mockEl)
await wrapper.vm.setTextSize(0.85)
expect(wrapper.vm.$refs.pageFontSize.$el.style.fontSize).toBe('0.85rem')
expect(mockEl.style.fontSize).toBe('0.85rem')
})
})
@ -115,6 +129,7 @@ describe('AuthLayout', () => {
beforeEach(() => {
wrapper = mount(AuthLayout, {
global: {
plugins: [createVuexStore()],
mocks: {
$i18n: {
locale: 'en',

View File

@ -19,11 +19,21 @@
<BCol sm="12" md="8" lg="6" class="zindex1000">
<div class="ms-3 ms-sm-4 me-3 me-sm-4">
<BRow class="d-none d-md-block d-lg-none">
<BCol class="mb--4">
<BCol>
<auth-navbar-small />
</BCol>
</BRow>
<BRow class="mt-0 mt-md-5 ps-2 ps-md-0 ps-lg-0">
<BRow v-if="projectBannerResult || projectBannerLoading" class="d-none d-md-block">
<BCol>
<BImg
v-if="projectBannerResult"
:src="projectBannerResult.projectBrandingBanner"
class="img-fluid rounded-like-card"
alt="project banner"
/>
</BCol>
</BRow>
<BRow v-else class="mt-0 mt-md-5 ps-2 ps-md-0 ps-lg-0">
<BCol lg="9" md="9" sm="12">
<div class="mb--2">{{ $t('welcome') }}</div>
<div class="h1 mb-0">{{ communityName }}</div>
@ -33,7 +43,7 @@
<BAvatar src="/img/brand/gradido_coin_128x128.png" size="6rem" />
</BCol>
</BRow>
<BCard ref="pageFontSize" no-body class="border-0 mt-4 gradido-custom-background">
<BCard no-body class="border-0 mt-4 gradido-custom-background page-font-size">
<BRow class="p-4">
<BCol cols="10">
<language-switch-2 class="ms-3" />
@ -61,7 +71,14 @@
</BRow>
<BRow class="d-inline d-sm-inline d-md-none d-lg-none mb-3">
<BCol class="text-center">
<BImg
v-if="projectBannerResult"
:src="projectBannerResult.projectBrandingBanner"
class="img-fluid ms-1 me-1 col-10 col-sm-10 rounded-20"
alt="project banner"
/>
<BAvatar
v-else
src="/img/brand/gradido_coin_128x128.png"
size="6rem"
bg-variant="transparent"
@ -85,34 +102,45 @@
</div>
</template>
<script>
<script setup>
import { onBeforeMount, computed, watchEffect } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { projectBrandingBanner } from '@/graphql/projectBranding.graphql'
import AuthNavbar from '@/components/Auth/AuthNavbar'
import AuthNavbarSmall from '@/components/Auth/AuthNavbarSmall'
import AuthCarousel from '@/components/Auth/AuthCarousel'
import LanguageSwitch2 from '@/components/LanguageSwitch2'
import AuthFooter from '@/components/Auth/AuthFooter'
import CONFIG from '@/config'
import { useStore } from 'vuex'
export default {
name: 'AuthLayout',
components: {
AuthNavbar,
AuthNavbarSmall,
AuthCarousel,
LanguageSwitch2,
AuthFooter,
},
data() {
return {
communityName: CONFIG.COMMUNITY_NAME,
}
},
methods: {
setTextSize(size) {
this.$refs.pageFontSize.$el.style.fontSize = size + 'rem'
},
},
const communityName = CONFIG.COMMUNITY_NAME
const store = useStore()
const project = computed(() => {
const urlParams = new URLSearchParams(window.location.search)
return urlParams.get('project')
})
const setTextSize = (size) => {
document.querySelector('.page-font-size').style.fontSize = size + 'rem'
}
const { result: projectBannerResult, loading: projectBannerLoading } = useQuery(
projectBrandingBanner,
{ project: project.value },
{ enabled: !!project.value },
)
onBeforeMount(() => {
// clear state
store.commit('project', null)
})
// put project value into store, if projectBrandingBanner query don't throw an error, so project exists
watchEffect(() => {
if (projectBannerResult.value) {
store.commit('project', project.value)
}
})
</script>
<style lang="scss" scoped>

View File

@ -126,6 +126,7 @@
"session-expired": "Die Sitzung wurde aus Sicherheitsgründen beendet.",
"unknown-error": "Unbekannter Fehler: "
},
"existingGradidoAccount": "Du hast schon ein {communityName} Konto?",
"followUs": "folge uns:",
"footer": {
"app_version": "App version {version}",
@ -275,6 +276,7 @@
"title": "Danke!",
"unsetPassword": "Dein Passwort wurde noch nicht gesetzt. Bitte setze es neu."
},
"missingGradidoAccount": "Noch kein {communityName} Konto?",
"moderatorChangedMemo": "Text vom Moderator bearbeitet",
"moderatorChat": "Moderator Chat",
"navigation": {

View File

@ -126,6 +126,7 @@
"session-expired": "The session was closed for security reasons.",
"unknown-error": "Unknown error: "
},
"existingGradidoAccount": "Already have a {communityName} account?",
"followUs": "follow us:",
"footer": {
"app_version": "App version {version}",
@ -275,6 +276,7 @@
"title": "Thank you!",
"unsetPassword": "Your password has not been set yet. Please set it again."
},
"missingGradidoAccount": "Don't have a {communityName} account yet?",
"moderatorChangedMemo": "Text edited by moderator",
"moderatorChat": "Moderator Chat",
"navigation": {

View File

@ -34,6 +34,9 @@ vi.mock('@vue/apollo-composable', () => ({
useMutation: () => ({
mutate: mockMutate,
}),
useLazyQuery: () => ({
load: vi.fn(),
}),
}))
describe('Login', () => {

View File

@ -32,6 +32,18 @@
</BButton>
</BCol>
</BRow>
<BRow>
<BCol class="mt-3">
{{ $t('missingGradidoAccount', { communityName: CONFIG.COMMUNITY_NAME }) }}
</BCol>
</BRow>
<BRow>
<BCol class="mt-1">
<BLink :to="register()" class="register-nav-item">
{{ $t('signup') }}
</BLink>
</BCol>
</BRow>
</form>
</BContainer>
<BContainer v-else>
@ -50,13 +62,16 @@ import InputPassword from '@/components/Inputs/InputPassword'
import InputEmail from '@/components/Inputs/InputEmail'
import Message from '@/components/Message/Message'
import { login } from '@/graphql/mutations'
import { authenticateHumhubAutoLoginProject } from '@/graphql/queries'
import { ref, computed } from 'vue'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'
import { useRouter, useRoute } from 'vue-router'
import { useForm } from 'vee-validate'
import { useMutation } from '@vue/apollo-composable'
import { useMutation, useLazyQuery } from '@vue/apollo-composable'
import { useAppToast } from '@/composables/useToast'
import { useAuthLinks } from '@/composables/useAuthLinks'
import CONFIG from '@/config'
// import { useLoading } from 'vue-loading-overlay'
const router = useRouter()
@ -64,15 +79,17 @@ const route = useRoute()
const store = useStore()
const { t } = useI18n()
const { mutate } = useMutation(login)
const { load } = useLazyQuery(authenticateHumhubAutoLoginProject)
// const $loading = useLoading() // TODO needs to be updated but there is some sort of an issue that breaks the app.
const { toastError } = useAppToast()
const { register } = useAuthLinks()
const form = ref({
email: '',
password: '',
})
const { handleSubmit, meta, values } = useForm({
const { handleSubmit, meta } = useForm({
initialValues: form.value,
})
@ -91,12 +108,21 @@ const onSubmit = handleSubmit(async (values) => {
email: values.email,
password: values.password,
publisherId: store.state.publisherId,
project: store.state.project,
})
const { login: loginResponse } = result.data
await store.dispatch('login', loginResponse)
store.commit('email', values.email)
// await loader.hide()
if (store.state.project) {
const result = await load(authenticateHumhubAutoLoginProject, {
project: store.state.project,
})
window.location.href = result.authenticateHumhubAutoLogin
return
}
if (route.params.code) {
await router.push(`/redeem/${route.params.code}`)
} else {
@ -135,4 +161,8 @@ const enterData = computed(() => !showPageMessage.value)
padding-right: 0;
padding-left: 0;
}
a.register-nav-item {
color: #0e79bc !important;
}
</style>

View File

@ -70,6 +70,18 @@
</BButton>
</BCol>
</BRow>
<BRow>
<BCol class="mt-3">
{{ $t('existingGradidoAccount', { communityName: CONFIG.COMMUNITY_NAME }) }}
</BCol>
</BRow>
<BRow>
<BCol class="mt-1">
<BLink :to="login()" class="login-nav-item">
{{ $t('signin') }}
</BLink>
</BCol>
</BRow>
</BForm>
</BContainer>
<BContainer v-else>
@ -89,26 +101,29 @@ import { createUser } from '@/graphql/mutations'
import { useI18n } from 'vue-i18n'
import { useStore } from 'vuex'
import { useRoute } from 'vue-router'
import { useAuthLinks } from '@/composables/useAuthLinks'
import CONFIG from '@/config'
const { toastError } = useAppToast()
const { login } = useAuthLinks()
const { mutate } = useMutation(createUser)
const { values: formValues, meta: formMeta, defineField, handleSubmit } = useForm()
const [firstname, firstnameProps] = defineField('firstname')
const [firstname] = defineField('firstname')
const { meta: firstnameMeta, errorMessage: firstnameError } = useField('firstname', {
required: true,
min: 3,
})
const [lastname, lastnameProps] = defineField('lastname')
const [lastname] = defineField('lastname')
const { meta: lastnameMeta, errorMessage: lastnameError } = useField('lastname', {
required: true,
min: 2,
})
const [agree, agreeProps] = defineField('agree')
const [agree] = defineField('agree')
const { meta: agreeMeta } = useField('agree', 'required')
const { t } = useI18n()
@ -116,10 +131,8 @@ const store = useStore()
const { params } = useRoute()
const showPageMessage = ref(false)
const submitted = ref(false)
const publisherId = ref(store.state.publisherId)
const redeemCode = ref(params.code)
const CONFIG = window.config
const enterData = computed(() => {
return !showPageMessage.value
@ -134,6 +147,7 @@ async function onSubmit() {
language: store.state.language,
publisherId: publisherId.value,
redeemCode: redeemCode.value,
project: store.state.project,
})
showPageMessage.value = true
} catch (error) {
@ -152,4 +166,8 @@ async function onSubmit() {
padding-right: 0;
padding-left: 0;
}
a.login-nav-item {
color: #0e79bc !important;
}
</style>

View File

@ -49,6 +49,9 @@ export const mutations = {
gmsPublishLocation: (state, gmsPublishLocation) => {
state.gmsPublishLocation = gmsPublishLocation
},
project: (state, project) => {
state.project = project
},
publisherId: (state, publisherId) => {
let pubId = parseInt(publisherId)
if (isNaN(pubId)) pubId = null
@ -113,6 +116,7 @@ export const actions = {
commit('humhubPublishName', null)
commit('gmsPublishLocation', null)
commit('hasElopage', false)
commit('project', null)
commit('publisherId', null)
commit('roles', null)
commit('hideAmountGDD', false)
@ -153,6 +157,7 @@ try {
humhubPublishName: null,
gmsPublishLocation: null,
hasElopage: false,
project: null,
publisherId: null,
hideAmountGDD: null,
hideAmountGDT: null,

View File

@ -161,9 +161,9 @@ describe('Vuex store', () => {
const commit = vi.fn()
const state = {}
it('calls nineteen commits', () => {
it('calls twenty commits', () => {
logout({ commit, state })
expect(commit).toHaveBeenCalledTimes(19)
expect(commit).toHaveBeenCalledTimes(20)
})
// ... (other logout action tests remain largely the same)

View File

@ -18,7 +18,8 @@ dotenv.config() // load env vars from .env
const CONFIG = require('./src/config')
// https://vitejs.dev/config/
export default defineConfig(({ command }) => {
export default defineConfig(async ({ command }) => {
const { vitePluginGraphqlLoader } = await import('vite-plugin-graphql-loader')
if (command === 'serve') {
CONFIG.FRONTEND_HOSTING = 'nodejs'
} else {
@ -117,6 +118,7 @@ export default defineConfig(({ command }) => {
META_KEYWORDS_EN: null,
META_AUTHOR: null,
}),
vitePluginGraphqlLoader(),
commonjs(),
],
css: {

View File

@ -2,41 +2,44 @@ import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [Vue()],
test: {
globals: true,
environment: 'jsdom',
// setupFiles: ['./test/vitest.setup.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/**', 'src/assets/**', '**/*.{spec,test}.js'],
lines: 95,
},
include: ['**/?(*.)+(spec|test).js?(x)'],
moduleNameMapper: {
'^@/(.*)$': path.resolve(__dirname, './src/$1'),
'\\.(css|less)$': 'identity-obj-proxy',
},
transformMode: {
web: [/\.[jt]sx$/],
},
server: {
deps: {
inline: [/vee-validate/, 'vitest-canvas-mock'],
export default defineConfig(async () => {
const { vitePluginGraphqlLoader } = await import('vite-plugin-graphql-loader')
return {
plugins: [Vue(), vitePluginGraphqlLoader()],
test: {
globals: true,
environment: 'jsdom',
// setupFiles: ['./test/vitest.setup.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/**', 'src/assets/**', '**/*.{spec,test}.js'],
lines: 95,
},
include: ['**/?(*.)+(spec|test).js?(x)'],
moduleNameMapper: {
'^@/(.*)$': path.resolve(__dirname, './src/$1'),
'\\.(css|less)$': 'identity-obj-proxy',
},
transformMode: {
web: [/\.[jt]sx$/],
},
server: {
deps: {
inline: [/vee-validate/, 'vitest-canvas-mock'],
},
},
alias: {
'@': path.resolve(__dirname, './src'),
'@test': path.resolve(__dirname, './test'),
},
},
alias: {
'@': path.resolve(__dirname, './src'),
'@test': path.resolve(__dirname, './test'),
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@test': path.resolve(__dirname, './test'),
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@test': path.resolve(__dirname, './test'),
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
},
}
})

View File

@ -4218,7 +4218,7 @@ graphql-tag@^2.12.6, graphql-tag@^2.4.2:
dependencies:
tslib "^2.1.0"
graphql@^16.9.0:
graphql@^16.8.1, graphql@^16.9.0:
version "16.10.0"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c"
integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==
@ -5074,7 +5074,7 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
magic-string@^0.30.11, magic-string@^0.30.12, magic-string@^0.30.14, magic-string@^0.30.17:
magic-string@^0.30.10, magic-string@^0.30.11, magic-string@^0.30.12, magic-string@^0.30.14, magic-string@^0.30.17:
version "0.30.17"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==
@ -6466,7 +6466,14 @@ string.prototype.trimstart@^1.0.8:
define-properties "^1.2.1"
es-object-atoms "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1, strip-ansi@^7.1.0:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1, strip-ansi@^7.1.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -7168,6 +7175,15 @@ vite-plugin-environment@^1.1.3:
resolved "https://registry.yarnpkg.com/vite-plugin-environment/-/vite-plugin-environment-1.1.3.tgz#d01a04abb2f69730a4866c9c9db51d3dab74645b"
integrity sha512-9LBhB0lx+2lXVBEWxFZC+WO7PKEyE/ykJ7EPWCq95NEcCpblxamTbs5Dm3DLBGzwODpJMEnzQywJU8fw6XGGGA==
vite-plugin-graphql-loader@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/vite-plugin-graphql-loader/-/vite-plugin-graphql-loader-4.0.4.tgz#cf6c599b3e5fa32bf2b768983da68f7beccc8486"
integrity sha512-lYnpQ2luV2fcuXmOJADljuktfMbDW00Y+6QS+Ek8Jz1Vdzlj/51LSGJwZqyjJ24a5YQ+o29Hr6el/5+nlZetvg==
dependencies:
graphql "^16.8.1"
graphql-tag "^2.12.6"
magic-string "^0.30.10"
vite-plugin-html@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/vite-plugin-html/-/vite-plugin-html-3.2.2.tgz#661834fa09015d3fda48ba694dbaa809396f5f7a"
@ -7443,7 +7459,16 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@7.0.0, wrap-ansi@^8.1.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@7.0.0, wrap-ansi@^8.1.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==