Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,6 @@ SYSTEM_ADMIN_EMAILS=admin@example.com
# Requires pgvector migrations and OPENAI_API_KEY. Reuses the existing Inbox
# delayed-delivery adapters and recovery schedule; no extra queue/cron/secret.
HELP_VECTOR_SEARCH=false

# Optional sanitized agent stage timings (no image/message/prompt bodies).
AGENT_TIMING=false
250 changes: 189 additions & 61 deletions app/components/WidgetEmbed/WidgetEmbedChat.vue

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion app/composables/useApiFetch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { notifyPermissionDenied } from '#layers/feedlog/app/utils/permission-error'

// SSR-safe $fetch wrapper. Forwards request cookie and host headers to
// internal /api/* calls during server-side rendering — without cookie
// forwarding, the SSR pass has no session, so authenticated routes 401
Expand Down Expand Up @@ -29,7 +31,7 @@ export function useApiFetch<T>(url: string, opts?: Parameters<typeof $fetch>[1])
// Central catch so deep links and stale tabs get the prompt too. The call
// still rejects, so callers keep their own failure handling.
async onResponseError(ctx) {
promptLocalAuthIfRequired(ctx.response?._data)
if (!promptLocalAuthIfRequired(ctx.response?._data)) notifyPermissionDenied(ctx.response?._data, ctx.response?.status)
for (const hook of callerHooks) await (hook as (c: typeof ctx) => unknown)(ctx)
},
})
Expand Down
20 changes: 20 additions & 0 deletions app/composables/useDashboardAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { toast } from 'vue-sonner'
import { canVisitDashboard, dashboardEntry } from '#layers/feedlog/shared/auth/dashboard'

export function useDashboardAccess() {
const org = useOrgContext()
const { canAccessInbox, canModerate, isOrgOwner } = usePermission(computed(() => undefined), 'post')
const { $i18n } = useNuxtApp()
const access = computed(() => ({ member: !!org.value.role, inbox: canAccessInbox.value, moderate: canModerate.value, owner: isOrgOwner.value }))
const canVisit = (path: string) => canVisitDashboard(path, access.value)
function notifyDenied() {
toast.error($i18n.t('dashboard.accessDenied'), { id: 'dashboard-access-denied', description: $i18n.t('dashboard.requestAccess') })
}
function guardNavigation(event: MouseEvent, path: string) {
if (canVisit(path)) return
event.preventDefault()
event.stopPropagation()
notifyDenied()
}
return { canVisit, entry: computed(() => dashboardEntry(access.value)), notifyDenied, guardNavigation }
}
3 changes: 1 addition & 2 deletions app/composables/useDashboardNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,11 @@ export interface DashboardNav {

export function useDashboardNav(): DashboardNav {
const { t } = useI18n()
const { canModerate } = usePermission(computed(() => undefined), 'post')
const { data: authConfig } = useFetch('/api/auth-config', { key: 'dashboard-auth-config' })
const org = useOrgContext()
return {
mainNav: computed(() => [
...(canModerate.value ? [{ label: t('inbox.title'), to: '/dashboard/inbox', icon: 'lucide:inbox' }] : []),
{ label: t('inbox.title'), to: '/dashboard/inbox', icon: 'lucide:inbox' },
{ label: t('nav.feedback'), to: '/dashboard/feedback', icon: 'lucide:message-square' },
{ label: t('nav.roadmap'), to: '/dashboard/roadmap', icon: 'lucide:map' },
{ label: t('nav.changelog'), to: '/dashboard/changelog', icon: 'lucide:newspaper' },
Expand Down
6 changes: 5 additions & 1 deletion app/composables/usePermission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export function usePermission(authorId: Ref<string | undefined> | ComputedRef<st
}) === true
})

const canAccessInbox = computed(() => !!role.value && authClient.organization.checkRolePermission({
role: role.value, permissions: { feedlog: ['inbox'] } as Record<string, string[]>,
}) === true)

// Kept as an alias for callers that still read `isOrgManager`. Semantically
// identical to `canModerate` — both ask "can this role moderate FeedLog
// content?". The role-string check (owner|manager) was a stale proxy.
Expand All @@ -47,5 +51,5 @@ export function usePermission(authorId: Ref<string | undefined> | ComputedRef<st

const showMenu = computed(() => canEdit.value || canDelete.value)

return { isOrgManager, isOrgOwner, isOwner, canModerate, canEdit, canDelete, showMenu }
return { isOrgManager, isOrgOwner, isOwner, canModerate, canAccessInbox, canEdit, canDelete, showMenu }
}
27 changes: 15 additions & 12 deletions app/composables/useWidgetEmbed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@
// a guest token this frame minted for itself on an earlier visit.

import type { InjectionKey } from 'vue'

import { CHAT_IMAGE_MAX_BYTES } from '#layers/feedlog/shared/agent/images'
import { GUEST_TOKEN_STORAGE_KEY } from '#layers/feedlog/shared/utils/guest'

declare module '#app' {
interface RuntimeNuxtHooks {
'widget:request': (request: { path: string; headers: Headers }) => void | Promise<void>
}
}
export interface WidgetEmbedUser {
id: string
email: string
Expand Down Expand Up @@ -62,12 +69,14 @@ function clearGuestToken(): void {
}

export function useWidgetEmbed() {
const nuxtApp = useNuxtApp()
const token = ref<string | null>(null)
const user = ref<WidgetEmbedUser | null>(null)
const status = ref<WidgetEmbedStatus>('loading')
// Set from /api/widget/config once it lands. Starts false so a config that
// never arrives is read as "no guests", the safe direction.
const allowGuest = ref(false)
const imageMaxBytes = ref(CHAT_IMAGE_MAX_BYTES)
let mintInFlight: Promise<boolean> | null = null

// The ONLY way this page talks to the API. `credentials: 'omit'` is the whole
Expand All @@ -76,17 +85,11 @@ export function useWidgetEmbed() {
// logged into this browser instead of the identity in the token. It also masks
// revoked sessions, which silently kills the expired-session path.
async function widgetRequest(path: string, init: RequestInit = {}): Promise<Response> {
const res = await fetch(path, {
...init,
credentials: 'omit',
headers: {
// FormData must set its own Content-Type so the multipart boundary
// survives; only JSON bodies get the header spelled out here.
...(init.body && !(init.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
...init.headers,
...(token.value ? { Authorization: `Bearer ${token.value}` } : {}),
},
})
const headers = new Headers(init.headers)
if (init.body && !(init.body instanceof FormData)) headers.set('Content-Type', 'application/json')
await nuxtApp.callHook('widget:request', { path, headers })
if (token.value) headers.set('Authorization', `Bearer ${token.value}`)
const res = await fetch(path, { ...init, credentials: 'omit', headers })
if (!res.ok) {
const body = await res.json().catch(() => null) as { message?: string; data?: { code?: string } } | null
// A plain Error with the status attached: createError() is server-flavoured
Expand Down Expand Up @@ -190,7 +193,7 @@ export function useWidgetEmbed() {
return mintInFlight ?? (mintInFlight = mint().finally(() => { mintInFlight = null }))
}

return { token, user, status, allowGuest, widgetFetch, widgetRequest, loadSession, ensureIdentity }
return { token, user, status, allowGuest, imageMaxBytes, widgetFetch, widgetRequest, loadSession, ensureIdentity }
}

// Refs are per call: a child calling the composable gets an unauthenticated one.
Expand Down
2 changes: 1 addition & 1 deletion app/composables/useWidgetResume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const chatResumeSchema = z.object({
articleSlug: z.string().nullable().default(null),
articleScrollTop: z.number().finite().nonnegative().default(0),
firstSeq: z.number().int().positive().nullable().default(null),
attachments: z.array(z.object({ key: z.string(), name: z.string() })),
attachments: z.array(z.object({ key: z.string(), name: z.string(), bytes: z.number().int().positive().optional() })),
scrollTop: z.number().finite().nonnegative(),
})

Expand Down
19 changes: 16 additions & 3 deletions app/layouts/dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ async function handleSignOut() {
}

const { mainNav, settingsNav, developerNav } = useDashboardNav()
const { canVisit, guardNavigation } = useDashboardAccess()

// Mobile menu
const mobileMenuOpen = ref(false)
Expand All @@ -72,11 +73,11 @@ useHead({ title: () => navTitle.value })
// Developer section: collapsed by default, but auto-expanded when the current
// route lives inside it (so the active page stays visible).
const { count: inboxCount, refresh: refreshInboxCount } = useInboxBadge()
const { canModerate } = usePermission(computed(() => undefined), 'post')
const { canAccessInbox } = usePermission(computed(() => undefined), 'post')
let inboxTimer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
if (canModerate.value) void refreshInboxCount()
inboxTimer = setInterval(() => { if (!document.hidden && canModerate.value) void refreshInboxCount() }, 10000)
if (canAccessInbox.value) void refreshInboxCount()
inboxTimer = setInterval(() => { if (!document.hidden && canAccessInbox.value) void refreshInboxCount() }, 10000)
})
onBeforeUnmount(() => clearInterval(inboxTimer))
const developerOpen = ref(developerNav.value.some(item => route.path.startsWith(item.to)))
Expand Down Expand Up @@ -127,8 +128,10 @@ const developerOpen = ref(developerNav.value.some(item => route.path.startsWith(
v-for="item in mainNav"
:key="item.to"
:to="localePath(item.to)"
:aria-disabled="!canVisit(item.to)"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-muted-foreground hover:bg-background hover:text-foreground transition-colors font-semibold"
active-class="!bg-secondary !text-primary !font-bold"
@click.capture="guardNavigation($event, item.to)"
>
<Icon :name="item.icon" size="20" />
<span class="text-sm">{{ item.label }}</span><span v-if="item.to === '/dashboard/inbox' && inboxCount" class="ml-auto inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] leading-none text-primary-foreground" :aria-label="$t('inbox.unreadOpenCount', { count: inboxCount })"><span class="relative top-px tabular-nums">{{ inboxCount > 99 ? '99+' : inboxCount }}</span></span>
Expand All @@ -142,8 +145,10 @@ const developerOpen = ref(developerNav.value.some(item => route.path.startsWith(
v-for="item in settingsNav"
:key="item.to"
:to="localePath(item.to)"
:aria-disabled="!canVisit(item.to)"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-muted-foreground hover:bg-background hover:text-foreground transition-colors font-semibold"
active-class="!bg-secondary !text-primary !font-bold"
@click.capture="guardNavigation($event, item.to)"
>
<Icon :name="item.icon" size="20" />
<span class="text-sm">{{ item.label }}</span><span v-if="item.to === '/dashboard/inbox' && inboxCount" class="ml-auto inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] leading-none text-primary-foreground" :aria-label="$t('inbox.unreadOpenCount', { count: inboxCount })"><span class="relative top-px tabular-nums">{{ inboxCount > 99 ? '99+' : inboxCount }}</span></span>
Expand All @@ -167,8 +172,10 @@ const developerOpen = ref(developerNav.value.some(item => route.path.startsWith(
v-for="item in developerNav"
:key="item.to"
:to="localePath(item.to)"
:aria-disabled="!canVisit(item.to)"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-muted-foreground hover:bg-background hover:text-foreground transition-colors font-semibold"
active-class="!bg-secondary !text-primary !font-bold"
@click.capture="guardNavigation($event, item.to)"
>
<Icon :name="item.icon" size="20" />
<span class="text-sm">{{ item.label }}</span><span v-if="item.to === '/dashboard/inbox' && inboxCount" class="ml-auto inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] leading-none text-primary-foreground" :aria-label="$t('inbox.unreadOpenCount', { count: inboxCount })"><span class="relative top-px tabular-nums">{{ inboxCount > 99 ? '99+' : inboxCount }}</span></span>
Expand Down Expand Up @@ -250,9 +257,11 @@ const developerOpen = ref(developerNav.value.some(item => route.path.startsWith(
v-for="item in mainNav"
:key="item.to"
:to="localePath(item.to)"
:aria-disabled="!canVisit(item.to)"
class="relative flex items-center gap-3 py-2 rounded-lg justify-center min-[1360px]:justify-start px-0 min-[1360px]:px-3 text-muted-foreground hover:bg-background hover:text-foreground transition-colors font-semibold"
:title="item.label"
active-class="!bg-secondary !text-primary !font-bold"
@click.capture="guardNavigation($event, item.to)"
>
<Icon :name="item.icon" size="20" class="shrink-0" />
<span class="hidden min-[1360px]:inline text-sm">{{ item.label }}</span>
Expand All @@ -269,9 +278,11 @@ const developerOpen = ref(developerNav.value.some(item => route.path.startsWith(
v-for="item in settingsNav"
:key="item.to"
:to="localePath(item.to)"
:aria-disabled="!canVisit(item.to)"
class="flex items-center gap-3 py-2 rounded-lg justify-center min-[1360px]:justify-start px-0 min-[1360px]:px-3 text-muted-foreground hover:bg-background hover:text-foreground transition-colors font-semibold"
:title="item.label"
active-class="!bg-secondary !text-primary !font-bold"
@click.capture="guardNavigation($event, item.to)"
>
<Icon :name="item.icon" size="20" class="shrink-0" />
<span class="hidden min-[1360px]:inline text-sm">{{ item.label }}</span>
Expand All @@ -297,9 +308,11 @@ const developerOpen = ref(developerNav.value.some(item => route.path.startsWith(
v-for="item in developerNav"
:key="item.to"
:to="localePath(item.to)"
:aria-disabled="!canVisit(item.to)"
class="flex items-center gap-3 py-2 rounded-lg justify-center min-[1360px]:justify-start px-0 min-[1360px]:px-3 text-muted-foreground hover:bg-background hover:text-foreground transition-colors font-semibold"
:title="item.label"
active-class="!bg-secondary !text-primary !font-bold"
@click.capture="guardNavigation($event, item.to)"
>
<Icon :name="item.icon" size="20" class="shrink-0" />
<span class="hidden min-[1360px]:inline text-sm">{{ item.label }}</span>
Expand Down
3 changes: 2 additions & 1 deletion app/lib/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createAuthClient } from 'better-auth/vue'
import { oauthProviderClient } from '@better-auth/oauth-provider/client'
import { adminClient, customSessionClient, organizationClient } from 'better-auth/client/plugins'
import { ac, contributor, manager, owner } from '~~/shared/auth/permissions'
import { notifyPermissionDenied } from '#layers/feedlog/app/utils/permission-error'
import { promptLocalAuthIfRequired } from '~/composables/useLoginModal'
import type { auth } from '~~/server/utils/better-auth'

Expand All @@ -21,7 +22,7 @@ export const authClient = createAuthClient({
// than through useApiFetch. Read `error`, not `response`: better-fetch hands
// back a plain web Response and spreads the parsed body onto `error`.
onError(ctx) {
promptLocalAuthIfRequired(ctx.error)
if (!promptLocalAuthIfRequired(ctx.error)) notifyPermissionDenied(ctx.error)
},
},
})
11 changes: 10 additions & 1 deletion app/middleware/admin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// /dashboard/* gate: any org staff (owner / manager / contributor) may enter.
// Fine-grained capability checks happen inside individual pages / API calls.
export default defineNuxtRouteMiddleware(async () => {
export default defineNuxtRouteMiddleware(async (to, from) => {
const { data } = await useAuthSession()

if (!data.value?.user) {
Expand All @@ -14,4 +14,13 @@ export default defineNuxtRouteMiddleware(async () => {
if (!orgList || orgList.length === 0) {
return navigateTo('/')
}
const { canVisit, entry, notifyDenied } = useDashboardAccess()
const localePath = useLocalePath()
const path = to.path.replace(/^\/(?:en|zh)(?=\/|$)/, '')
if (!canVisit(path)) {
if (import.meta.client) notifyDenied()
// A direct URL needs a destination; an in-app refusal stays on the current page.
if (import.meta.client && !useNuxtApp().isHydrating && from.matched.length && from.path !== to.path) return abortNavigation()
return navigateTo(localePath(entry.value), { replace: true })
}
})
10 changes: 5 additions & 5 deletions app/pages/dashboard/inbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const statusOptions = ['all', 'open', 'pending', 'snoozed', 'closed', 'ai_handli
const snoozeChoices = ['hour', 'threeHours', 'tomorrow', 'monday', 'week', 'month']
const emailSending = reactive<Record<string, boolean>>({})
const route = useRoute()
const { canModerate } = usePermission(
const { canAccessInbox } = usePermission(
computed(() => undefined),
'post',
)
Expand All @@ -49,7 +49,7 @@ const hasFilters = computed(() => !!filters.q.trim() || filters.status !== 'all'
const selected = ref<string | null>(null)
const detail = ref<InboxDetail | null>(null)
function showSystemNotice(part: Content['parts'][number]) {
return part.type === 'system_event' && (part.kind === 'handoff'
return part.type === 'system_event' && ((part.kind === 'handoff' && part.reason !== 'ai_error')
|| (part.reason === 'manual' && (part.kind === 'snoozed' || part.kind === 'closed')))
}
function handoffNotice(part: SystemEventPart) {
Expand Down Expand Up @@ -376,7 +376,7 @@ onMounted(() => {
updateWidth()
media.addEventListener('change', updateWidth)
document.addEventListener('visibilitychange', reportRead)
if (!canModerate.value) return
if (!canAccessInbox.value) return
void refreshBadge()
if (typeof route.query.conversationId === 'string') void open(route.query.conversationId)
polling = setInterval(async () => {
Expand All @@ -401,7 +401,7 @@ onBeforeUnmount(() => {

const { data: initialList, error: initialListError } = await useFetch<{ items: typeof rows.value; nextCursor: string | null }>('/api/admin/inbox/conversations', {
query: { ...filters },
immediate: canModerate.value,
immediate: canAccessInbox.value,
})
if (initialList.value) {
rows.value = initialList.value.items
Expand All @@ -412,7 +412,7 @@ loading.value = false
</script>

<template>
<div v-if="!canModerate" class="p-8">{{ t('inbox.forbidden') }}</div>
<div v-if="!canAccessInbox" class="p-8">{{ t('inbox.forbidden') }}</div>
<div v-else class="flex h-full min-h-0 flex-col bg-background" data-inbox>
<div v-if="error" role="alert" class="border-b bg-destructive/10 px-4 py-2 text-xs text-destructive">{{ error }}</div>
<div class="flex min-h-0 flex-1">
Expand Down
8 changes: 4 additions & 4 deletions app/pages/dashboard/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
definePageMeta({
middleware: 'admin',
redirect: (to) => `${to.path.replace(/\/$/, '')}/inbox`,
})
definePageMeta({ middleware: 'admin' })
const { entry } = useDashboardAccess()
const localePath = useLocalePath()
await navigateTo(localePath(entry.value), { replace: true })
</script>
2 changes: 2 additions & 0 deletions app/pages/widget/embed.vue
Original file line number Diff line number Diff line change
Expand Up @@ -317,12 +317,14 @@ onMounted(async () => {
const config = $fetch<{
org: { name: string, logo: string | null }
allowGuest: boolean
imageMaxBytes?: number
branding: { primary: string, primaryForeground: string }
}>('/api/widget/config')
.then((cfg) => {
applyBrand(cfg.branding)
org.value = cfg.org
allowGuest.value = cfg.allowGuest
if (cfg.imageMaxBytes) embed.imageMaxBytes.value = cfg.imageMaxBytes
})
.catch(() => {})

Expand Down
Loading
Loading