(null)
+
+ useEffect(() => {
+ let isCancelled = false
+
+ async function loadGroups() {
+ setIsLoadingGroups(true)
+ setGroupsLoadError(null)
+ try {
+ const response = await fetchGroups()
+ if (!isCancelled) {
+ setGroups(response.groups)
+ }
+ } catch (err) {
+ if (!isCancelled) {
+ setGroupsLoadError(
+ err instanceof Error ? err.message : 'Failed to load groups',
+ )
+ }
+ } finally {
+ if (!isCancelled) {
+ setIsLoadingGroups(false)
+ }
+ }
+ }
+
+ void loadGroups()
+
+ return () => {
+ isCancelled = true
+ }
+ }, [])
+
+ function togglePermission(permission: string, checked: boolean) {
+ setDraftPermissions((prev) => {
+ if (checked) {
+ if (prev.includes(permission)) {
+ return prev
+ }
+ return [...prev, permission]
+ }
+
+ return prev.filter((item) => item !== permission)
+ })
+ }
+
+ async function handleSaveGroup() {
+ setSubmitError(null)
+
+ try {
+ const response = await upsertGroup({
+ name: draftGroupName,
+ scope_kind: draftScopeKind,
+ scope_name: draftScopeName,
+ permissions: draftPermissions,
+ })
+
+ setGroups(response.groups)
+ setDraftGroupName('')
+ setDraftScopeKind('node')
+ setDraftScopeName('')
+ setDraftPermissions([])
+ } catch (err) {
+ setSubmitError(
+ err instanceof Error ? err.message : 'Failed to save group',
+ )
+ }
+ }
+
+ async function handleDeleteGroup(groupName: string) {
+ setSubmitError(null)
+
+ try {
+ const response = await deleteGroup(groupName)
+ setGroups(response.groups)
+ } catch (err) {
+ setSubmitError(
+ err instanceof Error ? err.message : 'Failed to delete group',
+ )
+ }
+ }
+
+ return (
+
+
+
Configured groups
+ {isLoadingGroups ? (
+
Loading…
+ ) : groupsLoadError ? (
+
+ {groupsLoadError}
+
+ ) : groups.length === 0 ? (
+
No groups configured yet.
+ ) : (
+
+ {groups.map((group) => (
+ -
+
+
{group.name}
+
+ Scope: {group.scope_kind}: {group.scope_name}
+
+
+ Permissions: {group.permissions.join(', ')}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
Create or update a group
+
+ {submitError ? (
+
+ {submitError}
+
+ ) : null}
+
+
+
+ {
+ setDraftGroupName(event.target.value)
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ {
+ setDraftScopeName(event.target.value)
+ }}
+ />
+
+
+
+
+
+ {DEFAULT_PERMISSIONS.map((permission) => {
+ const checked = draftPermissions.includes(permission)
+ return (
+
+ )
+ })}
+
+
+
+
+
+
+ )
+}
+
function ConfigTabPanelContent({
activeConfigTab,
draftThemePreference,
@@ -140,6 +363,27 @@ function ConfigTabPanelContent({
)
}
+ if (activeConfigTab === 'roles') {
+ return (
+
+
+ Built-in permissions that roles can grant.
+
+
+ {DEFAULT_PERMISSIONS.map((permission) => (
+ -
+
{permission}
+
+ ))}
+
+
+ )
+ }
+
+ if (activeConfigTab === 'groups') {
+ return
+ }
+
if (activeConfigTab === 'security') {
return (
@@ -153,14 +397,16 @@ function ConfigTabPanelContent({
}
function ConfigurationPanel({
+ activeConfigTab,
+ onConfigTabChange,
themePreference,
onThemePreferenceChange,
}: {
+ activeConfigTab: ConfigTabId
+ onConfigTabChange: (tab: ConfigTabId) => void
themePreference: ThemePreference
onThemePreferenceChange: (preference: ThemePreference) => void
}) {
- const [activeConfigTab, setActiveConfigTab] =
- useState('overview')
const [draftThemePreference, setDraftThemePreference] =
useState(themePreference)
@@ -187,7 +433,7 @@ function ConfigurationPanel({
aria-selected={isSelected}
aria-controls="config-tab-panel"
tabIndex={isSelected ? 0 : -1}
- onClick={() => setActiveConfigTab(tab.id)}
+ onClick={() => onConfigTabChange(tab.id)}
>
{tab.label}
@@ -231,10 +477,24 @@ function ConfigurationPanel({
}
function App() {
- const [activeSection, setActiveSection] = useState('overview')
+ const initialLocation = parseLocation(window.location.pathname)
+ const [activeSection, setActiveSection] = useState(
+ initialLocation.section,
+ )
+ const [activeConfigTab, setActiveConfigTab] = useState(
+ initialLocation.configTab,
+ )
const [backendVersion, setBackendVersion] = useState('…')
const { themePreference, updateThemePreference } = useThemePreference()
+ useEffect(() => {
+ return subscribeToLocation((pathname) => {
+ const location = parseLocation(pathname)
+ setActiveSection(location.section)
+ setActiveConfigTab(location.configTab)
+ })
+ }, [])
+
useEffect(() => {
let isCancelled = false
@@ -258,6 +518,21 @@ function App() {
}
}, [])
+ function goToSection(section: SectionId) {
+ const nextTab = section === 'configuration' ? activeConfigTab : 'overview'
+ setActiveSection(section)
+ if (section !== 'configuration') {
+ setActiveConfigTab('overview')
+ }
+ navigateTo(pathFor(section, section === 'configuration' ? nextTab : 'overview'))
+ }
+
+ function goToConfigTab(tab: ConfigTabId) {
+ setActiveSection('configuration')
+ setActiveConfigTab(tab)
+ navigateTo(pathFor('configuration', tab))
+ }
+
return (