Skip to content

Commit fe3b127

Browse files
committed
rbac for save instance settings
1 parent 6719e00 commit fe3b127

4 files changed

Lines changed: 123 additions & 81 deletions

File tree

packages/web-console/src/components/TopBar/InstanceSettingsPopper.tsx

Lines changed: 35 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,6 @@ const ColorValueInput = styled.input.attrs({ type: 'number', min: 0, max: 255 })
143143
}
144144
`
145145

146-
const ColorPreview = styled.div<{ color: string }>`
147-
width: 3rem;
148-
height: 3rem;
149-
border-radius: 0.4rem;
150-
background: ${({ color }) => color};
151-
border: 1px solid ${({ theme }) => theme.color.gray1};
152-
`
153-
154146
const StyledForm = styled.form`
155147
display: flex;
156148
flex-direction: column;
@@ -234,12 +226,19 @@ const ErrorText = styled(Text)`
234226
margin-top: 0.2rem;
235227
`
236228

229+
const Footer = styled.div`
230+
display: flex;
231+
flex-direction: column;
232+
align-items: center;
233+
`
234+
237235
type Props = {
238236
active: boolean
239237
onToggle: (active: boolean) => void
240238
values: Preferences
241239
onSave: (values: Preferences) => Promise<void>
242240
onValuesChange: (values: Preferences) => void
241+
error: string | null
243242
trigger: ReactNode
244243
}
245244

@@ -249,15 +248,15 @@ export const InstanceSettingsPopper = ({
249248
values,
250249
onSave,
251250
onValuesChange,
251+
error,
252252
trigger,
253253
}: Props) => {
254-
const [error, setError] = useState<string | null>(null)
255254
const [isSaving, setIsSaving] = useState(false)
255+
const [instanceNameError, setInstanceNameError] = useState<string | null>(null)
256256
const [showCustomColor, setShowCustomColor] = useState(false)
257257
const [rgbValues, setRgbValues] = useState({ r: 0, g: 0, b: 0 })
258258
const inputRef = useRef<HTMLInputElement>(null)
259259

260-
// Parse RGB values when component mounts or values change
261260
useEffect(() => {
262261
if (values.instance_rgb && values.instance_rgb.startsWith('rgb')) {
263262
setShowCustomColor(true)
@@ -278,27 +277,19 @@ export const InstanceSettingsPopper = ({
278277
e.preventDefault()
279278

280279
if (!values?.instance_name?.trim()) {
281-
setError("Instance name is required")
280+
setInstanceNameError("Instance name is required")
282281
return
283282
}
284-
285-
setError(null)
283+
setInstanceNameError(null)
284+
286285
setIsSaving(true)
287-
288-
try {
289-
await onSave(values)
290-
onToggle(false)
291-
} finally {
292-
setIsSaving(false)
293-
}
286+
await onSave(values) // Errors are handled in the parent component
287+
setIsSaving(false)
294288
}
295289

296290
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
297291
const newValues = { ...values, instance_name: e.target.value }
298292
onValuesChange(newValues)
299-
if (error && e.target.value.trim()) {
300-
setError(null)
301-
}
302293
}
303294

304295
const handleColorSelect = (color: string | undefined) => {
@@ -352,7 +343,7 @@ export const InstanceSettingsPopper = ({
352343
placeholder="Enter instance name"
353344
ref={inputRef}
354345
/>
355-
{error && <ErrorText>{error}</ErrorText>}
346+
{instanceNameError && <ErrorText>{instanceNameError}</ErrorText>}
356347
</FormGroup>
357348
<FormGroup>
358349
<FormLabel htmlFor="instance-type-select">Instance Type</FormLabel>
@@ -466,24 +457,26 @@ export const InstanceSettingsPopper = ({
466457
</ColorPickerContainer>
467458
)}
468459
</FormGroup>
469-
470-
<Buttons>
471-
<StyledButton
472-
type="submit"
473-
prefixIcon={isSaving ? <Loader /> : undefined}
474-
data-hook="topbar-instance-save-button"
475-
>
476-
Save
477-
</StyledButton>
478-
<StyledButton
479-
type="button"
480-
onClick={() => onToggle(false)}
481-
skin="secondary"
482-
data-hook="topbar-instance-cancel-button"
483-
>
484-
Cancel
485-
</StyledButton>
486-
</Buttons>
460+
<Footer>
461+
{error && <ErrorText>{error}</ErrorText>}
462+
<Buttons>
463+
<StyledButton
464+
type="submit"
465+
prefixIcon={isSaving ? <Loader /> : undefined}
466+
data-hook="topbar-instance-save-button"
467+
>
468+
Save
469+
</StyledButton>
470+
<StyledButton
471+
type="button"
472+
onClick={() => onToggle(false)}
473+
skin="secondary"
474+
data-hook="topbar-instance-cancel-button"
475+
>
476+
Cancel
477+
</StyledButton>
478+
</Buttons>
479+
</Footer>
487480
</StyledForm>
488481
</Wrapper>
489482
</PopperToggle>

packages/web-console/src/components/TopBar/toolbar.tsx

Lines changed: 73 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ const Badge = styled(Box)<{ $badgeColors: { primary: string, secondary: string }
104104
105105
.instance-name {
106106
font-size: 1.6rem;
107-
display: inline;
107+
display: inline-flex;
108+
align-items: center;
108109
vertical-align: middle;
109110
text-overflow: ellipsis;
110111
overflow: hidden;
@@ -162,6 +163,15 @@ const EnterpriseBadge = styled.span`
162163
}
163164
`
164165

166+
const Separator = styled.span<{ $color: string }>`
167+
display: inline-block;
168+
width: 0.15rem;
169+
margin: 0 1rem;
170+
height: 1.8rem;
171+
background: ${({ $color }) => $color};
172+
173+
`
174+
165175
const getSecondaryBadgeColor = (primaryColor: string | null, theme?: any): string => {
166176
if (!primaryColor || !primaryColor.startsWith('rgb')) {
167177
return theme?.color.foreground || "inherit";
@@ -237,25 +247,17 @@ const useBadgeColors = (instance_rgb: string | null) => {
237247
}
238248
}
239249

240-
const EnvironmentIcon = ({ instanceType, color, background }: { instanceType: InstanceType | undefined, color?: string, background?: string }) => {
241-
const getIcon = () => {
242-
switch (instanceType) {
243-
case "development":
244-
return <Tools size="18px" color={color} />
245-
case "production":
246-
return <RocketTakeoff size="18px" color={color} />
247-
case "testing":
248-
return <Flask size="18px" color={color} style={{ transform: 'scale(1.2)' }} />
249-
default:
250-
return <InfoCircle size="18px" style={{ transform: 'translateY(-0.2rem)'}} color={color} />
251-
}
250+
const EnvironmentIcon = ({ instanceType, color }: { instanceType: InstanceType | undefined, color?: string }) => {
251+
switch (instanceType) {
252+
case "development":
253+
return <Tools size="18px" color={color} />
254+
case "production":
255+
return <RocketTakeoff size="18px" color={color} />
256+
case "testing":
257+
return <Flask size="18px" color={color} style={{ transform: 'scale(1.2)' }} />
258+
default:
259+
return <InfoCircle size="18px" style={{ transform: 'translateY(-0.2rem)'}} color={color} />
252260
}
253-
254-
return (
255-
<EnvIconWrapper $background={background}>
256-
{getIcon()}
257-
</EnvIconWrapper>
258-
)
259261
};
260262

261263
const CustomIconWithTooltip = ({
@@ -278,7 +280,9 @@ const CustomIconWithTooltip = ({
278280
<FlexCol>
279281
{shownValues?.instance_type && (
280282
<Title>
281-
<EnvironmentIcon color={badgeColors.secondary} background={badgeColors.primary} instanceType={shownValues?.instance_type} />
283+
<EnvIconWrapper $background={badgeColors.primary}>
284+
<EnvironmentIcon color={badgeColors.secondary} instanceType={shownValues?.instance_type} />
285+
</EnvIconWrapper>
282286
<Text color="foreground" weight={400}>You are connected to a QuestDB instance for {shownValues?.instance_type}</Text>
283287
</Title>
284288
)}
@@ -308,7 +312,12 @@ export const Toolbar = () => {
308312
const [currentUser, setCurrentUser] = useState<string | null>(null)
309313
const [settingsPopperActive, setSettingsPopperActive] = useState(false)
310314
const [previewValues, setPreviewValues] = useState<Preferences | null>(null)
315+
const [canEditInstanceName, setCanEditInstanceName] = useState(false)
316+
const [saveError, setSaveError] = useState<string | null>(null)
311317
const shownValues = settingsPopperActive ? previewValues : preferences
318+
const instanceTypeReadable = shownValues?.instance_type
319+
? shownValues.instance_type.charAt(0).toUpperCase() + shownValues.instance_type.slice(1)
320+
: ''
312321
const badgeColors = useBadgeColors(shownValues?.instance_rgb ?? null)
313322
const theme = useTheme()
314323

@@ -329,14 +338,33 @@ export const Toolbar = () => {
329338
if (authPayload && currentUser && settings["acl.oidc.client.id"]) {
330339
setSSOUserNameWithClientID(settings["acl.oidc.client.id"], currentUser)
331340
}
341+
return currentUser
332342
}
343+
return null
333344
} catch (e) {
345+
return null
346+
}
347+
}
348+
349+
const fetchEditSettingsPermission = async (currentUser: string | null) => {
350+
if (!currentUser) {
351+
setCanEditInstanceName(false)
334352
return
335353
}
354+
355+
try {
356+
const response = await quest.showPermissions(currentUser)
357+
// Admin user has no permissions listed
358+
const canEdit = response.type === QuestDB.Type.DQL
359+
&& (response.count === 0 || response.data.some(d => d.permission === 'SETTINGS'))
360+
setCanEditInstanceName(canEdit)
361+
} catch (e) {
362+
setCanEditInstanceName(false)
363+
}
336364
}
337365

338366
useEffect(() => {
339-
fetchServerDetails()
367+
fetchServerDetails().then(fetchEditSettingsPermission)
340368
refreshSettingsAndPreferences()
341369
}, [])
342370

@@ -349,16 +377,23 @@ export const Toolbar = () => {
349377

350378
const handleSaveSettings = async (values: Preferences) => {
351379
try {
380+
setSaveError(null)
352381
await quest.savePreferences(values)
382+
handleToggle(false)
353383
} catch (e) {
354-
// Handle error
384+
console.error(e)
385+
setSaveError(`Failed to save instance settings: ${e instanceof Error ? e.message : e}`)
386+
} finally {
387+
await refreshSettingsAndPreferences()
355388
}
356-
await refreshSettingsAndPreferences()
357389
}
358390

359391
const handleToggle = useCallback((active: boolean) => {
360392
setSettingsPopperActive(active)
361393
setPreviewValues(active ? preferences : null)
394+
if (!active) {
395+
setSaveError(null)
396+
}
362397
}, [preferences])
363398

364399
return (
@@ -378,10 +413,10 @@ export const Toolbar = () => {
378413
$badgeColors={badgeColors}
379414
data-hook="topbar-instance-badge"
380415
>
381-
<Box style={{ padding: '0.7rem' }}>
416+
<Box>
382417
{(shownValues?.instance_type) ? (
383418
<CustomIconWithTooltip
384-
icon={<div data-hook="topbar-instance-icon"><EnvironmentIcon instanceType={shownValues?.instance_type} color={badgeColors.secondary} background={badgeColors.primary} /></div>}
419+
icon={<div data-hook="topbar-instance-icon" style={{ padding: '0.7rem' }}><EnvironmentIcon instanceType={shownValues?.instance_type} color={badgeColors.secondary} /></div>}
385420
placement="bottom"
386421
shownValues={shownValues}
387422
/>
@@ -391,22 +426,23 @@ export const Toolbar = () => {
391426
</Box>
392427
{shownValues?.instance_name
393428
? <Text data-hook="topbar-instance-name" className="instance-name">
394-
{shownValues?.instance_type
395-
? `${shownValues?.instance_type.charAt(0).toUpperCase()}${shownValues?.instance_type.slice(1)} | `
396-
: ''}
429+
{instanceTypeReadable}
430+
<Separator $color={badgeColors.secondary} />
397431
{shownValues?.instance_name}
398432
</Text>
399433
: <Text data-hook="topbar-instance-name" className="instance-name placeholder">Instance name is not set</Text>
400434
}
401-
402-
<InstanceSettingsPopper
403-
active={settingsPopperActive}
404-
onToggle={handleToggle}
405-
values={previewValues ?? preferences}
406-
onSave={handleSaveSettings}
407-
onValuesChange={setPreviewValues}
408-
trigger={<Edit data-hook="topbar-instance-edit-icon" size="18px" className={`edit-icon ${shownValues?.instance_name ? '' : 'placeholder'}`} />}
409-
/>
435+
{canEditInstanceName && (
436+
<InstanceSettingsPopper
437+
active={settingsPopperActive}
438+
onToggle={handleToggle}
439+
values={previewValues ?? preferences}
440+
onSave={handleSaveSettings}
441+
onValuesChange={setPreviewValues}
442+
error={saveError}
443+
trigger={<Edit data-hook="topbar-instance-edit-icon" size="18px" className={`edit-icon ${shownValues?.instance_name ? '' : 'placeholder'}`} />}
444+
/>
445+
)}
410446
</Badge>
411447
)}
412448
<Box gap="0.5rem">

packages/web-console/src/utils/questdb/client.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
UploadResult,
2424
Value,
2525
Preferences,
26+
Permission,
2627
} from "./types"
2728

2829
export class Client {
@@ -328,6 +329,10 @@ export class Client {
328329
return response
329330
}
330331

332+
async showPermissions(user: string): Promise<QueryResult<Permission>> {
333+
return await this.query<Permission>(`SHOW PERMISSIONS ${user};`)
334+
}
335+
331336
async showColumns(table: string): Promise<QueryResult<Column>> {
332337
return await this.query<Column>(`SHOW COLUMNS FROM '${table}';`)
333338
}
@@ -429,10 +434,10 @@ export class Client {
429434
body: JSON.stringify(prefs),
430435
},
431436
)
432-
await response.json()
433437
if (!response.ok) {
434-
throw new Error(`Status code: ${response.status}`);
438+
throw new Error(response.statusText);
435439
}
440+
await response.json()
436441
}
437442

438443
async exportQueryToCsv(query: string) {

packages/web-console/src/utils/questdb/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ export type Preferences = Partial<{
2121
instance_type: InstanceType
2222
}>
2323

24+
export type Permission = {
25+
grant_option: boolean
26+
origin: string
27+
permission: string
28+
table_name: string | null
29+
column_name: string | null
30+
}
31+
2432
export type Timings = {
2533
compiler: number
2634
authentication: number

0 commit comments

Comments
 (0)