@orsetra/shared-ui 1.10.9 → 1.10.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ import * as React from "react"
2
+ import { Lock } from "lucide-react"
3
+ import { cn } from "../../lib/utils"
4
+
5
+ export interface AccessDeniedStateProps {
6
+ resourceName: string
7
+ message?: string
8
+ className?: string
9
+ }
10
+
11
+ export function AccessDeniedState({ resourceName, message, className }: AccessDeniedStateProps) {
12
+ return (
13
+ <div
14
+ className={cn(
15
+ "flex flex-col md:flex-row items-center justify-center gap-8 md:gap-12 p-6 md:p-12 min-h-[450px] bg-white text-center md:text-left",
16
+ className
17
+ )}
18
+ >
19
+ <div className="flex-shrink-0 h-40 w-40 bg-red-50 flex items-center justify-center">
20
+ <Lock className="h-20 w-20 text-red-200" />
21
+ </div>
22
+ <div className="max-w-md">
23
+ <h2 className="text-xl md:text-2xl font-semibold text-ibm-gray-100 mb-3 text-center md:text-left">
24
+ Access denied
25
+ </h2>
26
+ <p className="text-ibm-gray-60 text-sm md:text-base leading-relaxed text-center md:text-left">
27
+ {message ?? `You don't have permission to view ${resourceName} in this business unit.`}
28
+ </p>
29
+ </div>
30
+ </div>
31
+ )
32
+ }
@@ -24,6 +24,7 @@ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './
24
24
  export { AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel } from './alert-dialog'
25
25
  export { Alert, AlertTitle, AlertDescription } from './alert'
26
26
  export { AlertBanner, useAlertBanner, type AlertBannerProps, type AlertState } from './alert-banner'
27
+ export { AccessDeniedState, type AccessDeniedStateProps } from './access-denied-state'
27
28
  export { ConfirmationDialog, type ConfirmationDialogProps } from './confirmation-dialog'
28
29
  export { AspectRatio } from './aspect-ratio'
29
30
  export { Badge } from './badge'
@@ -4,10 +4,11 @@ import React, { createContext, useContext } from "react"
4
4
  import type { OrgInfo, Project } from "../components/layout/sidebar/data"
5
5
 
6
6
  export interface UserProfile {
7
- email?: string
7
+ sub?: string
8
+ email?: string
8
9
  preferred_username?: string
9
- name?: string
10
- avatar?: string
10
+ name?: string
11
+ avatar?: string
11
12
  }
12
13
 
13
14
  interface UserContextValue {
@@ -19,7 +20,7 @@ interface UserContextValue {
19
20
  openBusinessUnitSwitcher: () => void
20
21
  }
21
22
 
22
- const UserContext = createContext<UserContextValue | null>(null)
23
+ export const UserContext = createContext<UserContextValue | null>(null)
23
24
 
24
25
  export function useUserContext(): UserContextValue {
25
26
  const ctx = useContext(UserContext)
@@ -1,11 +1,12 @@
1
1
  "use client"
2
2
 
3
- import { useState, useEffect, useCallback } from "react"
3
+ import { useState, useEffect, useCallback, useContext } from "react"
4
+ import { UserContext } from "../context/user-context"
4
5
 
5
- const STORAGE_KEY = "sidebar:quick-access"
6
- const POOL_SIZE = 20 // internal storage
7
- const DISPLAY_SIZE = 4 // shown in UI
8
- const DECAY_DAYS = 14 // recency half-life
6
+ const BASE_KEY = "sidebar:quick-access"
7
+ const POOL_SIZE = 20 // internal storage
8
+ const DISPLAY_SIZE = 4 // shown in UI
9
+ const DECAY_DAYS = 14 // recency half-life
9
10
 
10
11
  export interface QuickAccessItem {
11
12
  id: string // origin + pathname (stable, no query params)
@@ -20,14 +21,14 @@ export interface QuickAccessItem {
20
21
  function score(item: QuickAccessItem): number {
21
22
  const ageMs = Date.now() - item.lastVisit
22
23
  const ageDays = ageMs / (1000 * 60 * 60 * 24)
23
- const recency = Math.exp(-ageDays / DECAY_DAYS) // 1.0 → 0 over time
24
+ const recency = Math.exp(-ageDays / DECAY_DAYS)
24
25
  return item.visitCount * 0.6 + recency * 0.4
25
26
  }
26
27
 
27
- function readStorage(): QuickAccessItem[] {
28
+ function readStorage(key: string): QuickAccessItem[] {
28
29
  if (typeof window === "undefined") return []
29
30
  try {
30
- const raw = localStorage.getItem(STORAGE_KEY)
31
+ const raw = localStorage.getItem(key)
31
32
  if (!raw) return []
32
33
  const parsed = JSON.parse(raw)
33
34
  return Array.isArray(parsed)
@@ -46,8 +47,8 @@ function readStorage(): QuickAccessItem[] {
46
47
  }
47
48
  }
48
49
 
49
- function writeStorage(items: QuickAccessItem[]): void {
50
- try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) } catch {}
50
+ function writeStorage(key: string, items: QuickAccessItem[]): void {
51
+ try { localStorage.setItem(key, JSON.stringify(items)) } catch {}
51
52
  }
52
53
 
53
54
  /** Top DISPLAY_SIZE items from pool, sorted by score descending. */
@@ -56,24 +57,38 @@ function topItems(pool: QuickAccessItem[]): QuickAccessItem[] {
56
57
  }
57
58
 
58
59
  export function useQuickAccess() {
59
- const [pool, setPool] = useState<QuickAccessItem[]>(readStorage)
60
- const [items, setItems] = useState<QuickAccessItem[]>(() => topItems(readStorage()))
60
+ // Read sub from UserContext without throwing when outside provider
61
+ const ctx = useContext(UserContext)
62
+ const sub = ctx?.profile?.sub
63
+ const storageKey = sub ? `${BASE_KEY}:${sub}` : null
64
+
65
+ const [pool, setPool] = useState<QuickAccessItem[]>(() => storageKey ? readStorage(storageKey) : [])
66
+ const [items, setItems] = useState<QuickAccessItem[]>(() => storageKey ? topItems(readStorage(storageKey)) : [])
67
+
68
+ // Re-read when storage key changes (user logs in / switches account)
69
+ useEffect(() => {
70
+ if (!storageKey) { setPool([]); setItems([]); return }
71
+ const p = readStorage(storageKey)
72
+ setPool(p)
73
+ setItems(topItems(p))
74
+ }, [storageKey])
61
75
 
62
76
  // Sync across tabs
63
77
  useEffect(() => {
78
+ if (!storageKey) return
64
79
  const onStorage = (e: StorageEvent) => {
65
- if (e.key === STORAGE_KEY) {
66
- const p = readStorage()
80
+ if (e.key === storageKey) {
81
+ const p = readStorage(storageKey)
67
82
  setPool(p)
68
83
  setItems(topItems(p))
69
84
  }
70
85
  }
71
86
  window.addEventListener("storage", onStorage)
72
87
  return () => window.removeEventListener("storage", onStorage)
73
- }, [])
88
+ }, [storageKey])
74
89
 
75
90
  const addItem = useCallback((item: { label: string; href: string; icon?: string }) => {
76
- // Normalize: strip query params and fragments — use origin + pathname as stable id
91
+ if (!storageKey) return
77
92
  let id: string
78
93
  try {
79
94
  const u = new URL(item.href)
@@ -81,38 +96,58 @@ export function useQuickAccess() {
81
96
  } catch {
82
97
  id = item.href
83
98
  }
99
+ // Normalise: strip trailing slash for consistent comparison
100
+ const normId = id.replace(/\/$/, "")
84
101
 
85
102
  setPool(prev => {
86
- const existing = prev.find(i => i.id === id)
103
+ const prefix = (ancestor: string, descendant: string) =>
104
+ descendant !== ancestor && descendant.startsWith(ancestor.replace(/\/$/, "") + "/")
105
+
106
+ // A deeper item for this path already exists → bump its score, skip adding the shallower one
107
+ const deeper = prev.find(i => prefix(normId, i.id))
108
+ if (deeper) {
109
+ const updated = { ...deeper, visitCount: deeper.visitCount + 1, lastVisit: Date.now() }
110
+ const next = [updated, ...prev.filter(i => i.id !== deeper.id)]
111
+ .sort((a, b) => score(b) - score(a))
112
+ .slice(0, POOL_SIZE)
113
+ writeStorage(storageKey, next)
114
+ setItems(topItems(next))
115
+ return next
116
+ }
117
+
118
+ // Remove any shallower items that are ancestors of the new path
119
+ const withoutAncestors = prev.filter(i => !prefix(i.id, normId))
120
+
121
+ const existing = withoutAncestors.find(i => i.id === normId)
87
122
  const updated: QuickAccessItem = existing
88
123
  ? { ...existing, label: item.label, icon: item.icon, visitCount: existing.visitCount + 1, lastVisit: Date.now() }
89
- : { id, label: item.label, href: id, icon: item.icon, visitCount: 1, lastVisit: Date.now() }
124
+ : { id: normId, label: item.label, href: normId, icon: item.icon, visitCount: 1, lastVisit: Date.now() }
90
125
 
91
- // Merge into pool, sort by score, keep top POOL_SIZE
92
- const next = [updated, ...prev.filter(i => i.id !== id)]
126
+ const next = [updated, ...withoutAncestors.filter(i => i.id !== normId)]
93
127
  .sort((a, b) => score(b) - score(a))
94
128
  .slice(0, POOL_SIZE)
95
129
 
96
- writeStorage(next)
130
+ writeStorage(storageKey, next)
97
131
  setItems(topItems(next))
98
132
  return next
99
133
  })
100
- }, [])
134
+ }, [storageKey])
101
135
 
102
136
  const removeItem = useCallback((id: string) => {
137
+ if (!storageKey) return
103
138
  setPool(prev => {
104
139
  const next = prev.filter(i => i.id !== id)
105
- writeStorage(next)
140
+ writeStorage(storageKey, next)
106
141
  setItems(topItems(next))
107
142
  return next
108
143
  })
109
- }, [])
144
+ }, [storageKey])
110
145
 
111
146
  const clearItems = useCallback(() => {
112
147
  setPool([])
113
148
  setItems([])
114
- try { localStorage.removeItem(STORAGE_KEY) } catch {}
115
- }, [])
149
+ if (storageKey) try { localStorage.removeItem(storageKey) } catch {}
150
+ }, [storageKey])
116
151
 
117
152
  return { items, pool, addItem, removeItem, clearItems }
118
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.10.9",
3
+ "version": "1.10.11",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",