Skip to content
Snippets Groups Projects
Commit f4433d66 authored by Konrad Mohrfeldt's avatar Konrad Mohrfeldt :koala:
Browse files

feat: add account self-management page

refs #219
parent 26a5afd2
No related branches found
No related tags found
No related merge requests found
Pipeline #8184 passed
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
"version": "1.0.0-alpha4", "version": "1.0.0-alpha4",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@floating-ui/vue": "^1.0.6",
"@flowjs/flow.js": "^2.14.1", "@flowjs/flow.js": "^2.14.1",
"@fullcalendar/core": "^6.1.11", "@fullcalendar/core": "^6.1.11",
"@fullcalendar/interaction": "^6.1.11", "@fullcalendar/interaction": "^6.1.11",
...@@ -623,6 +624,68 @@ ...@@ -623,6 +624,68 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz",
"integrity": "sha512-+2XpQV9LLZeanU4ZevzRnGFg2neDeKHgFLjP6YLW+tly0IvrhqT4u8enLGjLH3qeh85g19xY5rsAusfwTdn5lg==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.0"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz",
"integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.0.0",
"@floating-ui/utils": "^0.2.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.2.tgz",
"integrity": "sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==",
"license": "MIT"
},
"node_modules/@floating-ui/vue": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.0.6.tgz",
"integrity": "sha512-EdrOljjkpkkqZnrpqUcPoz9NvHxuTjUtSInh6GMv3+Mcy+giY2cE2pHh9rpacRcZ2eMSCxel9jWkWXTjLmY55w==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.1",
"@floating-ui/utils": "^0.2.1",
"vue-demi": ">=0.13.0"
}
},
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
"version": "0.14.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
"integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@flowjs/flow.js": { "node_modules/@flowjs/flow.js": {
"version": "2.14.1", "version": "2.14.1",
"resolved": "https://registry.npmjs.org/@flowjs/flow.js/-/flow.js-2.14.1.tgz", "resolved": "https://registry.npmjs.org/@flowjs/flow.js/-/flow.js-2.14.1.tgz",
......
...@@ -22,6 +22,7 @@ ...@@ -22,6 +22,7 @@
"*.{js,ts,vue}": "eslint --color --ignore-path .gitignore --ignore-path .eslintignore" "*.{js,ts,vue}": "eslint --color --ignore-path .gitignore --ignore-path .eslintignore"
}, },
"dependencies": { "dependencies": {
"@floating-ui/vue": "^1.0.6",
"@flowjs/flow.js": "^2.14.1", "@flowjs/flow.js": "^2.14.1",
"@fullcalendar/core": "^6.1.11", "@fullcalendar/core": "^6.1.11",
"@fullcalendar/interaction": "^6.1.11", "@fullcalendar/interaction": "^6.1.11",
......
<template>
<router-view v-if="currentUser" :user="currentUser" />
</template>
<script lang="ts" setup>
import { useObjectFromStore } from '@rokoli/bnb/drf'
import { useAuthStore, useUserStore } from '@/stores'
const authStore = useAuthStore()
const userStore = useUserStore()
const { obj: currentUser } = useObjectFromStore(() => authStore.steeringUser?.id ?? null, userStore)
</script>
<template>
<div>
<AUserIdentity v-slot="{ identity }" :user="user">
<PageHeader :title="identity.name" />
<div class="tw-flex tw-gap-6 tw-items-start tw-flex-wrap">
<ASection :title="t('myAccount.data')" class="tw-flex-1 md:tw-w-fit md:tw-flex-none">
<AFieldset as="form" class="tw-bg-white md:tw-min-w-[500px] tw-max-w-2xl">
<AUserEditor :user="user" />
</AFieldset>
</ASection>
<ASection :title="t('myAccount.settings')" class="tw-flex-1 md:tw-w-fit md:tw-flex-none">
<AFieldset as="form" class="tw-bg-white md:tw-min-w-[500px] tw-max-w-2xl">
<FormTable>
<FormGroup v-slot="{ disabled }" :label="t('settings.language.label')" custom-control>
<ALanguageSelector :disabled="disabled" />
<ADescription>{{ t('settings.language.description') }}</ADescription>
</FormGroup>
</FormTable>
</AFieldset>
</ASection>
<ASection
v-if="profiles.value.length > 0"
:title="t('myAccount.profiles')"
class="tw-flex-1 md:tw-flex-none tw-w-fit"
>
<div class="tw-flex tw-gap-6 tw-items-start tw-flex-wrap">
<template v-for="profile in profiles.value" :key="profile.id">
<AFieldset
as="form"
title-tag="h3"
:title="profile.name"
class="tw-bg-white md:tw-min-w-[380px] tw-max-w-lg tw-flex-1"
>
<AProfileEditor :profile="profile" />
</AFieldset>
</template>
</div>
</ASection>
</div>
</AUserIdentity>
</div>
</template>
<script setup lang="ts">
import PageHeader from '@/components/PageHeader.vue'
import { useRelationList } from '@/form'
import { SteeringUser, useUserStore } from '@/stores/auth'
import { useHostStore } from '@/stores'
import AUserIdentity from '@/components/identities/AUserIdentity.vue'
import AFieldset from '@/components/generic/AFieldset.vue'
import ASection from '@/components/generic/ASection.vue'
import AProfileEditor from '@/components/identities/AProfileEditor.vue'
import AUserEditor from '@/components/identities/AUserEditor.vue'
import { useI18n } from '@/i18n'
import FormTable from '@/components/generic/FormTable.vue'
import FormGroup from '@/components/generic/FormGroup.vue'
import ALanguageSelector from '@/components/generic/ALanguageSelector.vue'
import ADescription from '@/components/generic/ADescription.vue'
const props = defineProps<{
user: SteeringUser
}>()
const { t } = useI18n()
const userStore = useUserStore()
const hostStore = useHostStore()
const profiles = useRelationList(userStore, () => props.user, 'hostIds', hostStore)
</script>
...@@ -14,14 +14,42 @@ ...@@ -14,14 +14,42 @@
test-id="username" test-id="username"
class="tw-bg-gray-800 tw-text-gray-200" class="tw-bg-gray-800 tw-text-gray-200"
> >
<button <Menu ref="userMenu">
type="button" <MenuButton
class="btn btn-default tw-p-0 tw-w-10 tw-h-10 tw-rounded-full tw-flex-none tw-justify-center" ref="userMenuButton"
data-testid="menu-logout" data-testid="usermenu"
@click="logoutRedirect" class="btn btn-default tw-p-0 tw-w-10 tw-h-10 tw-rounded-full tw-flex-none tw-justify-center"
> >
<icon-system-uicons-exit-right /> <icon-system-uicons-menu-hamburger />
</button> </MenuButton>
<Teleport to="body">
<MenuItems
ref="userMenuPopover"
:style="userMenuStyles"
class="a-menu-items tw-max-w-64 tw-drop-shadow-xl tw-relative"
>
<MenuItem v-slot="{ close }">
<RouterLink v-slot="{ href, navigate }" :to="{ name: 'my-account' }" custom>
<a
:href="href"
class="a-menu-item"
@click.prevent="onMenuClick($event, navigate, close)"
>
<icon-fluent-person-circle-28-regular />
<span>{{ t('userMenu.myAccount') }}</span>
</a>
</RouterLink>
</MenuItem>
<MenuItem class="a-menu-item is-danger">
<button type="button" data-testid="usermenu-logout" @click="logoutRedirect">
<icon-system-uicons-exit-right />
<span>{{ t('userMenu.logout') }}</span>
</button>
</MenuItem>
</MenuItems>
</Teleport>
</Menu>
</AUserPreview> </AUserPreview>
</header> </header>
...@@ -33,13 +61,35 @@ ...@@ -33,13 +61,35 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import LogoHeader from '../../../public/assets/logo-header.svg?component' import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { logoutRedirect } from '@/oidc'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import LogoHeader from '../../../public/assets/logo-header.svg?component'
import AUserPreview from '@/components/generic/AUserPreview.vue' import AUserPreview from '@/components/generic/AUserPreview.vue'
import { logoutRedirect } from '@/oidc'
import AMainNavMenu from '@/components/nav/AMainNavMenu.vue' import AMainNavMenu from '@/components/nav/AMainNavMenu.vue'
import { flip, offset, shift, useFloating } from '@floating-ui/vue'
import { ref } from 'vue'
import { useI18n } from '@/i18n'
const { t } = useI18n()
const authStore = useAuthStore() const authStore = useAuthStore()
const userMenuButton = ref()
const userMenuPopover = ref()
const { floatingStyles: userMenuStyles } = useFloating(userMenuButton, userMenuPopover, {
placement: 'bottom-end',
middleware: [offset(16), flip(), shift()],
})
// For some reason MenuItem swallows the click event from RouterLink
// but fails to close the menu. Therefore, we need a very verbose work around :(.
async function onMenuClick(event: Event, navigate: () => Promise<unknown>, close: () => unknown) {
event.preventDefault()
await navigate()
close()
}
</script> </script>
<style> <style>
......
...@@ -258,6 +258,24 @@ export default { ...@@ -258,6 +258,24 @@ export default {
data: 'Daten', data: 'Daten',
}, },
myAccount: {
data: 'Mein Konto',
profiles: 'Meine Profile',
settings: 'Einstellungen',
},
settings: {
language: {
label: 'Sprache',
description: 'Diese Einstellung wird in deinem Browser gespeichert.',
},
},
userMenu: {
myAccount: 'Mein Konto',
logout: 'Abmelden',
},
footer: { footer: {
tagline: 'Alles was Du für ein Freies Radio brauchst', tagline: 'Alles was Du für ein Freies Radio brauchst',
}, },
......
...@@ -258,6 +258,21 @@ export default { ...@@ -258,6 +258,21 @@ export default {
data: 'Data', data: 'Data',
}, },
myAccount: {
data: 'My Account',
profiles: 'My Profiles',
settings: 'Settings',
},
settings: {
language: { label: 'Language', description: 'This setting is saved in your browser.' },
},
userMenu: {
myAccount: 'My Account',
logout: 'Sign out',
},
footer: { footer: {
tagline: 'All the UI you need to run a community radio', tagline: 'All the UI you need to run a community radio',
}, },
......
...@@ -45,6 +45,17 @@ const routes: RouteRecordRaw[] = [ ...@@ -45,6 +45,17 @@ const routes: RouteRecordRaw[] = [
}, },
], ],
}, },
{
path: '/my',
component: () => import('@/Pages/CurrentUser.vue'),
children: [
{
path: 'account',
name: 'my-account',
component: () => import('@/Pages/MyAccount.vue'),
},
],
},
{ {
path: '/profiles', path: '/profiles',
children: [ children: [
......
...@@ -302,4 +302,27 @@ thead .fc-day-selected:hover { ...@@ -302,4 +302,27 @@ thead .fc-day-selected:hover {
@apply tw-bottom-0; @apply tw-bottom-0;
} }
} }
.a-menu-items {
@apply tw-block tw-p-3 tw-bg-white tw-z-50 tw-rounded-lg;
}
.a-menu-item {
@apply tw-grid tw-gap-2 tw-items-center tw-justify-start tw-select-none tw-text-left tw-transition tw-px-3 tw-min-w-[200px] tw-py-2 tw-rounded not-disabled:tw-cursor-pointer hocus:not-disabled:tw-bg-gray-200;
grid-template-columns: 28px minmax(0, 1fr);
grid-template-areas: 'icon text';
svg {
@apply tw-aspect-square tw-w-full tw-h-auto;
grid-area: icon;
}
span {
grid-area: text;
}
&.is-danger {
@apply hocus:not-disabled:tw-bg-rose-100 hocus:not-disabled:tw-text-rose-700;
}
}
} }
...@@ -9,6 +9,7 @@ module.exports = { ...@@ -9,6 +9,7 @@ module.exports = {
addVariant('user-invalid', ['&:user-invalid']) addVariant('user-invalid', ['&:user-invalid'])
addVariant('hocus', ['&:hover', '&:focus-visible']) addVariant('hocus', ['&:hover', '&:focus-visible'])
addVariant('group-hocus', ':merge(.group):is(:hover, :focus-visible) &') addVariant('group-hocus', ':merge(.group):is(:hover, :focus-visible) &')
addVariant('not-disabled', '&:not(:disabled):not(a:not([href]))')
addUtilities({ addUtilities({
'.inset-y-center': { '.inset-y-center': {
'inset-block': 0, 'inset-block': 0,
......
...@@ -21,8 +21,8 @@ test('Can login and logout again', async ({ browser }) => { ...@@ -21,8 +21,8 @@ test('Can login and logout again', async ({ browser }) => {
await expect(page.getByTestId('username')).toHaveText(USERNAME) await expect(page.getByTestId('username')).toHaveText(USERNAME)
await expect(loginButton).toHaveCount(0) await expect(loginButton).toHaveCount(0)
await page.getByTestId('username').click() await page.getByTestId('usermenu').click()
await page.getByTestId('menu-logout').click() await page.getByTestId('usermenu-logout').click()
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
await expect(loginButton).toHaveCount(1) await expect(loginButton).toHaveCount(1)
}) })
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment