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
16 changes: 16 additions & 0 deletions bwh_hive/bwh_hive/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,19 @@ def pinned_project_has_permission(doc, ptype: str | None = None, user: str | Non
if ptype == "create":
return True
return doc.user == user


# Roles that grant access to Hive at all. Anyone on the team or on a client's
# side belongs in the app; everyone else is kept off the apps screen.
HIVE_ROLES = ("System Manager", "Hive Team", "Hive Client")


def has_hive_access(user: str | None = None) -> bool:
"""Whether `user` is allowed to see Hive at all."""
if not user:
user = frappe.session.user

if user == "Administrator":
return True

return any(role in frappe.get_roles(user) for role in HIVE_ROLES)
18 changes: 9 additions & 9 deletions bwh_hive/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
# required_apps = []

# Each item in the list will be shown as an app in the apps page
# add_to_apps_screen = [
# {
# "name": "bwh_hive",
# "logo": "/assets/bwh_hive/logo.png",
# "title": "BWH Hive",
# "route": "/bwh_hive",
# "has_permission": "bwh_hive.api.permission.has_app_permission"
# }
# ]
add_to_apps_screen = [
{
"name": "bwh_hive",
"logo": "/assets/bwh_hive/images/hive-mark.svg",
"title": "Hive",
"route": "/hive",
"has_permission": "bwh_hive.bwh_hive.permissions.has_hive_access",
}
]

# Includes in <head>
# ------------------
Expand Down
1 change: 1 addition & 0 deletions bwh_hive/public/images/hive-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions bwh_hive/www/hive.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,7 @@ def get_boot():
"site_name": frappe.local.site,
"read_only_mode": frappe.flags.read_only,
"system_timezone": get_system_timezone(),
# The app switcher offers Desk only to those who can actually open it.
"is_system_manager": "System Manager" in frappe.get_roles(),
}
)
35 changes: 35 additions & 0 deletions e2e/tests/app-switcher.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test, expect } from "../helpers/app";
import { callMethodGet } from "../helpers/frappe";
import { gotoHive } from "../helpers/ui";

/**
* The switcher is fed by `frappe.apps.get_apps` — the site-wide apps-screen
* list — so what it offers depends on which apps the site has installed. The
* one row it always has is Desk, added client-side for System Managers.
*/
test.describe("App switcher", () => {
test("lists Desk and the site's other apps, but not Hive itself", async ({
page,
request,
}) => {
const apps = await callMethodGet<
{ name: string; title: string; route: string }[]
>(request, "frappe.apps.get_apps");

await gotoHive(page, "/");

await page.locator('[data-slot="sidebar-header"] button').click();
await page.getByRole("menuitem", { name: "Switch app" }).hover();

const desk = page.getByRole("menuitem", { name: "Desk" });
await expect(desk).toBeVisible({ timeout: 10000 });
await expect(page.getByRole("menuitem", { name: "Hive" })).toHaveCount(0);

for (const app of apps.filter((app) => app.name !== "bwh_hive")) {
await expect(page.getByRole("menuitem", { name: app.title })).toBeVisible();
}

await desk.click();
await expect(page).toHaveURL(/\/desk/);
});
});
61 changes: 57 additions & 4 deletions frontend/src/components/shell/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { computed, h } from 'vue'
import type { VNode } from 'vue'
import { useRoute } from 'vue-router'
import {
Avatar,
Expand All @@ -69,6 +70,7 @@ import {
SidebarHeader,
SidebarItem,
SidebarSection,
useCall,
type DropdownOptions,
} from 'frappe-ui'
import SidebarPinned from '@/components/shell/SidebarPinned.vue'
Expand All @@ -93,12 +95,22 @@ interface NavItem {
/** The `SidebarHeader` dropdown takes a narrower shape than `Dropdown` does. */
interface MenuItem {
label: string
icon: string
onClick: () => void
icon?: string
onClick?: () => void
submenu?: MenuItem[]
slots?: { prefix?: () => VNode }
}

/** One row of `frappe.apps.get_apps`: an installed app that opted into the apps screen. */
interface AppEntry {
name: string
logo: string
title: string
route: string
}

const route = useRoute()
const { user, isClient, logout } = useSession()
const { user, isClient, isSystemManager, logout } = useSession()
const { commandPaletteOpen, notificationsOpen, openSettings, shortcutsOpen } = useOverlays()
const unreadCount = useUnreadCount()

Expand All @@ -119,9 +131,50 @@ function isActive(item: NavItem) {
return item.exact ? route.path === item.to : route.path.startsWith(item.to)
}

// The apps screen hook is the site-wide list of installed apps, so the switcher
// stays correct as apps are installed or removed without a Hive-side registry.
const apps = useCall<AppEntry[]>({
url: '/api/v2/method/frappe.apps.get_apps',
method: 'GET',
cacheKey: 'installed-apps',
})

// Desk is not on the apps screen (it is every site's fallback), so it is added by
// hand — but only for System Managers, since a client user has no desk access and
// would land on a permission error. Hive itself is dropped: switching to where you
// already are is a no-op.
const deskApp: AppEntry = {
name: 'frappe',
logo: '/assets/frappe/images/framework.png',
title: 'Desk',
route: '/desk',
}

const appSwitcherItems = computed<MenuItem[]>(() =>
[
...(isSystemManager.value ? [deskApp] : []),
...(apps.data ?? []).filter((app) => app.name !== 'bwh_hive'),
].map((app) => ({
label: app.title,
onClick: () => {
window.location.href = app.route
},
slots: {
prefix: () => h('img', { src: app.logo, alt: '', class: 'size-4 rounded-sm' }),
},
})),
)

// Workspace-level actions belong to the workspace, so they hang off its header.
const workspaceMenu = computed<MenuItem[]>(() => {
const items: MenuItem[] = []
if (appSwitcherItems.value.length) {
items.push({
label: 'Switch app',
icon: 'lucide-layout-grid',
submenu: appSwitcherItems.value,
})
}
if (!isClient.value) {
items.push({
label: 'Settings',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/composables/useSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ function createSession() {
const user = computed(() => userDoc.doc)
const member = computed(() => memberDoc.doc)
const isClient = computed(() => member.value?.type === 'Client')
// Boot data lands on `window` before the app mounts, so this needs no request
// of its own; it never changes within a session.
const isSystemManager = computed(() => Boolean(window.is_system_manager))

/** True once we know whether there is a user, and have their doc if so. */
const ready = computed(
Expand All @@ -55,6 +58,7 @@ function createSession() {
member,
userId,
isClient,
isSystemManager,
isGuest,
ready,
logout,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ declare global {
system_timezone?: string
frappe_version?: string
read_only_mode?: boolean
is_system_manager?: boolean
}
}

Expand Down
Loading