feat(admin): Serve the admin console from the shared layer - #114
Conversation
Code Review Summary✨ The admin console moves out of this app and onto the shared Two product-facing pieces ship alongside: users can send feedback and read the status of what they sent ( |
| // Auto-sync auth state on client-side | ||
| if (typeof window !== 'undefined') { | ||
| syncAuthState(); |
There was a problem hiding this comment.
This call previously ran behind a typeof window !== 'undefined' guard (the guard and the // Auto-sync auth state on client-side comment were both removed here). syncAuthState() reads persisted auth state, and this composable is now also invoked from route middleware, which executes during SSR. Calling it unconditionally on the server risks touching browser-only storage during server render. Keep the client-only guard so SSR is unaffected.
| // Auto-sync auth state on client-side | |
| if (typeof window !== 'undefined') { | |
| syncAuthState(); | |
| + if (import.meta.client) { | |
| + syncAuthState(); | |
| + } |
|
|
||
| const router = useRouter(); | ||
| let currentPage = useRoute().fullPath; | ||
| router.afterEach((to) => { |
There was a problem hiding this comment.
router.afterEach(...) returns an unsubscribe function that is discarded, so the navigation hook stays registered until the process ends. Since the plugin already has an app:beforeUnmount teardown point, capture the unsubscriber and call it there so teardown is symmetric with setup.
| router.afterEach((to) => { | |
| const stopTracking = router.afterEach((to) => { | |
| if (to.fullPath === currentPage) return; | |
| currentPage = to.fullPath; | |
| engagement.page(); | |
| }); | |
| nuxtApp.hook('app:beforeUnmount', () => { | |
| stopTracking(); | |
| engagement.destroy(); | |
| }); |
| ><span>{{ t('Message') }}</span | ||
| ><textarea v-model="form.message" required rows="7" maxlength="5000" /> | ||
| </label> | ||
| <TButton :text="t('Send feedback')" :loading="sending" /> |
There was a problem hiding this comment.
The form submits via @submit.prevent="submit", but the only control inside it is <TButton>. If TButton renders a <button> with type="button" (a common default for wrapper components), the submit event never fires and clicking "Send feedback" does nothing. Making the submit intent explicit is safe and unambiguous.
| <TButton :text="t('Send feedback')" :loading="sending" /> | |
| <TButton type="submit" :text="t('Send feedback')" :loading="sending" /> |
| > | ||
| <div class="history"> | ||
| <article v-for="item in items" :key="item.id"> | ||
| <span class="status">{{ item.status.replaceAll('_', ' ') }}</span> |
There was a problem hiding this comment.
item.status.replaceAll(...) is only safe when status is always a non-null string. If the feedback endpoint ever returns a record whose status is absent (e.g. a still-queued item), the render throws and the whole history list fails to mount. Guard the access defensively; the fallback is cosmetic and cannot mask a real status.
| <span class="status">{{ item.status.replaceAll('_', ' ') }}</span> | |
| <span class="status">{{ (item.status ?? '').replaceAll('_', ' ') }}</span> |
|
|
||
| export default defineNuxtRouteMiddleware(async (to, _from) => { | ||
| const { isAuthenticated } = useAuth(); | ||
| const { token, isAuthenticated, fetchUser, syncAuthState } = useAuth(); |
There was a problem hiding this comment.
useAuth() on line 5 now runs syncAuthState() itself (see the added syncAuthState(); in composables/useAuth.ts), so this explicit call repeats the exact same hydration step in the same tick. The identical line was also added to middleware/admin.ts, which means the same responsibility is now implemented in three places: the composable, this middleware, and the admin middleware. The two middleware are otherwise near-identical copies of each other, so they will drift independently. The composable is invoked on the line above, so removing the explicit call keeps behaviour identical while leaving useAuth as the single owner of the sync; middleware/admin.ts should drop its copy as well.
| const { token, isAuthenticated, fetchUser, syncAuthState } = useAuth(); | |
| + const { token, isAuthenticated, fetchUser } = useAuth(); |
| siteKey | ||
| }); | ||
|
|
||
| const router = useRouter(); |
There was a problem hiding this comment.
The first page view of every session is never reported. currentPage is seeded with the route the user landed on, and router.afterEach only fires on subsequent navigations, so the entry page is silently dropped from analytics. Seed an empty marker and emit one page event for the initial route so the landing page is counted like any other navigation.
| const router = useRouter(); | |
| + const router = useRouter(); | |
| + let currentPage = ''; | |
| + // `afterEach` does not fire for the route the app booted on, so report it here. | |
| + engagement.page(); | |
| + currentPage = useRoute().fullPath; | |
| + router.afterEach((to) => { | |
| + if (to.fullPath === currentPage) return; | |
| + currentPage = to.fullPath; | |
| + engagement.page(); | |
| + }); |
The console was a single page in this app, so every metric, column and drilldown it showed was hard-coded here and could not be reused. It now comes from the shared admin layer, with Trakli supplying its own brand, navigation, user columns and drilldown targets as configuration. Users can send feedback and read the status of what they sent, and admins triage it. Product analytics are collected once consent is given.
81b0c35 to
79ca7fc
Compare
Deploying trakli-dev with
|
| Latest commit: |
7a64aac
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://dcba1cbb.trakli-dev.pages.dev |
| Branch Preview URL: | https://feat-welcome-email-feedback.trakli-dev.pages.dev |
Deploying webui with
|
| Latest commit: |
7a64aac
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://95e155ef.webui-9fh.pages.dev |
| Branch Preview URL: | https://feat-welcome-email-feedback.webui-9fh.pages.dev |
| @@ -1,4 +1,5 @@ | |||
| NUXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1 | |||
| NUXT_PUBLIC_ENGAGEMENT_SITE_KEY=trakli-dashboard | |||
There was a problem hiding this comment.
The new variable has a non-obvious effect: plugins/engagement.client.ts returns early when no site key is configured, so an empty value silently disables analytics. Every other variable in this file carries a short explanatory comment; add one here too so operators know what leaving it empty does.
| NUXT_PUBLIC_ENGAGEMENT_SITE_KEY=trakli-dashboard | |
| +# Product analytics. Leave empty to disable collection: the engagement plugin | |
| +# returns early when no site key is configured. | |
| +NUXT_PUBLIC_ENGAGEMENT_SITE_KEY=trakli-dashboard |
| export interface FeedbackItem { | ||
| id: number; | ||
| type: string; | ||
| status: string; |
There was a problem hiding this comment.
subject is already nullable, which shows this payload can omit fields, yet status is typed as a non-null string. The feedback list renders item.status.replaceAll(...) directly, so a record returned without a status will throw at render time. Typing it as string | null forces every call site to handle the missing case (the render site should fall back to a neutral label rather than assume a status).
| status: string; | |
| + status: string | null; |
| <span class="status">{{ item.status.replaceAll('_', ' ') }}</span> | ||
| <h3 v-if="item.subject">{{ item.subject }}</h3> | ||
| <p>{{ item.message }}</p> | ||
| <small>{{ new Date(item.created_at).toLocaleString() }}</small> |
There was a problem hiding this comment.
created_at comes straight off the API response and is formatted without a guard. If a record is returned without a date (or with an unparseable one), new Date(...).toLocaleString() renders the literal string Invalid Date rather than throwing, but it is still wrong output in the history list. Guard the access the same way the status field is guarded.
| <small>{{ new Date(item.created_at).toLocaleString() }}</small> | |
| <small>{{ item.created_at ? new Date(item.created_at).toLocaleString() : '' }}</small> |
| import TCard from '@/components/TCard.vue'; | ||
| import TButton from '@/components/TButton.vue'; | ||
| import ComponentLoader from '@/components/ComponentLoader.vue'; | ||
| import { useFeedback, type FeedbackItem } from '@/composables/useFeedback'; |
There was a problem hiding this comment.
useFeedback is imported explicitly on this line, but useNotifications() is called on line 76 without an import. Sibling pages (e.g. pages/budgets/index.vue) import useNotifications from @/composables/useNotifications explicitly, so this file is inconsistent and would throw useNotifications is not defined if auto-import is ever disabled. Import it alongside useFeedback.
| import { useFeedback, type FeedbackItem } from '@/composables/useFeedback'; | |
| import { useFeedback, type FeedbackItem } from '@/composables/useFeedback'; | |
| import { useNotifications } from '@/composables/useNotifications'; |
| export interface FeedbackItem { | ||
| id: number; | ||
| type: string; | ||
| status: string; |
There was a problem hiding this comment.
FeedbackItem is the app's declaration of the producer's response shape, and it types status as a mandatory string. The single consumer renders item.status.replaceAll(...), i.e. it already treats the field as possibly absent. If the record can be returned without a status, the type is wrong and it silently promises the consumer something the producer does not guarantee. Making the field nullable moves the check to compile time, so a missing status becomes a type error instead of a runtime throw during render.
| status: string; | |
| status: string | null; |
| if (!isAuthenticated.value) { | ||
| syncAuthState(); | ||
|
|
||
| if (!token.value) { |
There was a problem hiding this comment.
This twin middleware now decides authorization differently from middleware/auth.ts. auth.ts hydrates the user (if (token.value && !isAuthenticated.value) await fetchUser();) and then gates on isAuthenticated.value; admin.ts gates on token.value alone and never hydrates in the shown hunk. syncAuthState() restores the token from persisted storage, so a token can be present while no user object has been loaded. Gating on token presence admits the admin route in that window, and the downstream admin-role check (which reads user) then runs against a null user — a path that bypasses the shared authentication contract the rest of the app relies on. Mirror auth.ts so both middleware share one authorization contract.
| if (!token.value) { | |
| if (token.value && !isAuthenticated.value) { | |
| await fetchUser(); | |
| } | |
| if (!isAuthenticated.value) { |
The only control in the feedback form rendered as an ordinary button, so clicking it did nothing and no message could be sent. The page now also imports what it uses rather than relying on an ambient global, so it can be mounted outside the framework. Analytics teardown unregisters the navigation hook it registered, and the two route guards no longer repeat work the composable already does on creation.
The admin console was a single page in this app, so its metrics, columns and drilldowns
could not be reused. It now comes from
@whilesmart/eloquent-admin-ui, and Trakli suppliesits brand, navigation, user columns and drilldown targets as configuration.
Users can send feedback and read the status of what they sent. Product analytics are
collected once consent is given.
The lint and format checks were already failing on
dev, in files this work does nototherwise touch. The first commit clears them so the rest can be read against a green base.