From f4433d664c0a8d038073b50143158b1d45b03a0f Mon Sep 17 00:00:00 2001 From: Konrad Mohrfeldt <km@roko.li> Date: Tue, 4 Jun 2024 15:36:10 +0200 Subject: [PATCH] feat: add account self-management page refs #219 --- package-lock.json | 63 ++++++++++++++++++++++++++ package.json | 1 + src/Pages/CurrentUser.vue | 12 +++++ src/Pages/MyAccount.vue | 72 ++++++++++++++++++++++++++++++ src/components/nav/ANavSidebar.vue | 70 ++++++++++++++++++++++++----- src/i18n/de.js | 18 ++++++++ src/i18n/en.js | 15 +++++++ src/router.ts | 11 +++++ src/tailwind.css | 23 ++++++++++ tailwind.config.js | 1 + tests/login.spec.ts | 4 +- 11 files changed, 278 insertions(+), 12 deletions(-) create mode 100644 src/Pages/CurrentUser.vue create mode 100644 src/Pages/MyAccount.vue diff --git a/package-lock.json b/package-lock.json index 03ccb26c..df28ea66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0-alpha4", "license": "AGPL-3.0-only", "dependencies": { + "@floating-ui/vue": "^1.0.6", "@flowjs/flow.js": "^2.14.1", "@fullcalendar/core": "^6.1.11", "@fullcalendar/interaction": "^6.1.11", @@ -623,6 +624,68 @@ "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": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/@flowjs/flow.js/-/flow.js-2.14.1.tgz", diff --git a/package.json b/package.json index a75a8e3a..c80dd87e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "*.{js,ts,vue}": "eslint --color --ignore-path .gitignore --ignore-path .eslintignore" }, "dependencies": { + "@floating-ui/vue": "^1.0.6", "@flowjs/flow.js": "^2.14.1", "@fullcalendar/core": "^6.1.11", "@fullcalendar/interaction": "^6.1.11", diff --git a/src/Pages/CurrentUser.vue b/src/Pages/CurrentUser.vue new file mode 100644 index 00000000..329b28cd --- /dev/null +++ b/src/Pages/CurrentUser.vue @@ -0,0 +1,12 @@ +<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> diff --git a/src/Pages/MyAccount.vue b/src/Pages/MyAccount.vue new file mode 100644 index 00000000..83c846f3 --- /dev/null +++ b/src/Pages/MyAccount.vue @@ -0,0 +1,72 @@ +<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> diff --git a/src/components/nav/ANavSidebar.vue b/src/components/nav/ANavSidebar.vue index dbfaa324..33b1f4a5 100644 --- a/src/components/nav/ANavSidebar.vue +++ b/src/components/nav/ANavSidebar.vue @@ -14,14 +14,42 @@ test-id="username" class="tw-bg-gray-800 tw-text-gray-200" > - <button - type="button" - class="btn btn-default tw-p-0 tw-w-10 tw-h-10 tw-rounded-full tw-flex-none tw-justify-center" - data-testid="menu-logout" - @click="logoutRedirect" - > - <icon-system-uicons-exit-right /> - </button> + <Menu ref="userMenu"> + <MenuButton + ref="userMenuButton" + data-testid="usermenu" + 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-menu-hamburger /> + </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> </header> @@ -33,13 +61,35 @@ </template> <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 LogoHeader from '../../../public/assets/logo-header.svg?component' import AUserPreview from '@/components/generic/AUserPreview.vue' -import { logoutRedirect } from '@/oidc' 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 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> <style> diff --git a/src/i18n/de.js b/src/i18n/de.js index 47de4445..ec3aa8dd 100644 --- a/src/i18n/de.js +++ b/src/i18n/de.js @@ -258,6 +258,24 @@ export default { 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: { tagline: 'Alles was Du für ein Freies Radio brauchst', }, diff --git a/src/i18n/en.js b/src/i18n/en.js index 52dfc3cb..053424e0 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -258,6 +258,21 @@ export default { 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: { tagline: 'All the UI you need to run a community radio', }, diff --git a/src/router.ts b/src/router.ts index f81dd4eb..28d431bd 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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', children: [ diff --git a/src/tailwind.css b/src/tailwind.css index 34bffd7c..052c7b25 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -302,4 +302,27 @@ thead .fc-day-selected:hover { @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; + } + } } diff --git a/tailwind.config.js b/tailwind.config.js index 2c94db2a..0e45cadd 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -9,6 +9,7 @@ module.exports = { addVariant('user-invalid', ['&:user-invalid']) addVariant('hocus', ['&:hover', '&:focus-visible']) addVariant('group-hocus', ':merge(.group):is(:hover, :focus-visible) &') + addVariant('not-disabled', '&:not(:disabled):not(a:not([href]))') addUtilities({ '.inset-y-center': { 'inset-block': 0, diff --git a/tests/login.spec.ts b/tests/login.spec.ts index 6049101e..6ddb1a66 100644 --- a/tests/login.spec.ts +++ b/tests/login.spec.ts @@ -21,8 +21,8 @@ test('Can login and logout again', async ({ browser }) => { await expect(page.getByTestId('username')).toHaveText(USERNAME) await expect(loginButton).toHaveCount(0) - await page.getByTestId('username').click() - await page.getByTestId('menu-logout').click() + await page.getByTestId('usermenu').click() + await page.getByTestId('usermenu-logout').click() await page.waitForLoadState('networkidle') await expect(loginButton).toHaveCount(1) }) -- GitLab