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