| <!-- |
| Licensed to the Apache Software Foundation (ASF) under one |
| or more contributor license agreements. See the NOTICE file |
| distributed with this work for additional information |
| regarding copyright ownership. The ASF licenses this file |
| to you under the Apache License, Version 2.0 (the |
| "License"); you may not use this file except in compliance |
| with the License. You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, |
| software distributed under the License is distributed on an |
| "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| KIND, either express or implied. See the License for the |
| specific language governing permissions and limitations |
| under the License. |
| --> |
| |
| <script lang="ts" module> |
| type GlobalPermissionsSnakeCase = keyof KeysToSnakeCase<GlobalPermissions>; |
| type StreamPermissionsSnakeCase = Exclude<keyof KeysToSnakeCase<StreamPermissions>, 'topics'>; |
| |
| type TopicsPerms = Record< |
| Topic['id'], |
| Record<keyof TopicPermissions, { name: string; checked: false }> |
| >; |
| |
| type StreamsPerms = Record< |
| Stream['id'], |
| Record< |
| StreamPermissionsSnakeCase, |
| { |
| name: string; |
| checked: boolean; |
| disabled: boolean; |
| globalPermsKey: GlobalPermissionsSnakeCase; |
| } |
| > & { topicPerms: TopicsPerms } |
| >; |
| |
| type GlobalPerms = Record< |
| GlobalPermissionsSnakeCase, |
| { name: string; checked: boolean; relatesTo?: StreamPermissionsSnakeCase } |
| >; |
| </script> |
| |
| <script lang="ts"> |
| import { run } from 'svelte/legacy'; |
| |
| import Icon from './Icon.svelte'; |
| import Combobox from './Combobox.svelte'; |
| import type { Stream } from '$lib/domain/Stream'; |
| import { topicMapper, type Topic } from '$lib/domain/Topic'; |
| import { fetchRouteApi } from '$lib/api/fetchRouteApi'; |
| import { showToast } from './AppToasts.svelte'; |
| import type { KeysToSnakeCase } from '$lib/utils/utilTypes'; |
| import type { |
| GlobalPermissions, |
| StreamPermissions, |
| TopicPermissions |
| } from '$lib/domain/Permissions'; |
| import Checkbox from './Checkbox.svelte'; |
| import { twMerge } from 'tailwind-merge'; |
| import { noTypeCheck } from '$lib/utils/noTypeCheck'; |
| import { fade } from 'svelte/transition'; |
| import { SvelteSet } from 'svelte/reactivity'; |
| |
| interface Props { |
| streams: Stream[]; |
| value?: any; |
| } |
| |
| let { streams, value = $bindable() }: Props = $props(); |
| |
| let topics: Topic[] = $state([]); |
| let fetchingTopics = $state(false); |
| let selectedStream: { id: number; name: string } | undefined = $state(undefined); |
| let selectedTopic: { id: number; name: string } | undefined = $state(undefined); |
| |
| if (streams.length > 0) { |
| selectedStream = { name: streams[0].name, id: streams[0].id }; |
| } |
| |
| const fetchTopics = async (id: number) => { |
| fetchingTopics = true; |
| const { data, ok } = await fetchRouteApi({ |
| method: 'GET', |
| path: `/streams/${id}/topics` |
| }); |
| |
| if (!ok) { |
| showToast({ type: 'error', description: 'Something went wrong' }); |
| return; |
| } |
| fetchingTopics = false; |
| const newTopics = data.map(topicMapper) as Topic[]; |
| |
| if (newTopics.length > 0) { |
| selectedTopic = { name: newTopics[0].name, id: newTopics[0].id }; |
| } else { |
| selectedTopic = undefined; |
| } |
| |
| topics = newTopics; |
| }; |
| |
| const buildTopicsPerms = (newTopics: Topic[]) => { |
| const tempTopicPerms: TopicsPerms = {}; |
| |
| if (!selectedStream || Object.keys(streamsPerms[selectedStream.id].topicPerms).length > 0) { |
| return; |
| } |
| |
| newTopics.forEach((t) => { |
| tempTopicPerms[t.id] = { |
| manageTopic: { |
| checked: false, |
| name: 'Manage topic' |
| }, |
| pollMessages: { |
| checked: false, |
| name: 'Poll messages' |
| }, |
| readTopic: { |
| checked: false, |
| name: 'Read topic' |
| }, |
| sendMessages: { |
| checked: false, |
| name: 'Send messages' |
| } |
| }; |
| }); |
| |
| streamsPerms[selectedStream.id].topicPerms = tempTopicPerms; |
| streamsPerms = streamsPerms; |
| }; |
| |
| const onGlobalPermChanged = (key: GlobalPermissionsSnakeCase, checked: boolean) => { |
| const relatesTo = globalPerms[key].relatesTo; |
| |
| if (relatesTo) { |
| Object.keys(streamsPerms).forEach((k) => { |
| streamsPerms[k][relatesTo] = { ...streamsPerms[k][relatesTo], checked, disabled: checked }; |
| streamsPerms = streamsPerms; |
| }); |
| } |
| }; |
| |
| const globalPerms: GlobalPerms = $state({ |
| manage_servers: { |
| name: 'Manage servers', |
| checked: false |
| }, |
| read_servers: { |
| name: 'Read servers', |
| checked: false |
| }, |
| manage_users: { |
| name: 'Manage users', |
| checked: false |
| }, |
| read_users: { |
| name: 'Read users', |
| checked: false |
| }, |
| manage_streams: { |
| name: 'Manage streams', |
| relatesTo: 'manage_stream', |
| checked: false |
| }, |
| read_streams: { |
| name: 'Read streams', |
| relatesTo: 'read_stream', |
| checked: false |
| }, |
| manage_topics: { |
| name: 'Manage topics', |
| relatesTo: 'manage_topics', |
| checked: false |
| }, |
| read_topics: { |
| name: 'Read topics', |
| relatesTo: 'read_topics', |
| checked: false |
| }, |
| poll_messages: { |
| name: 'Pool messages', |
| relatesTo: 'poll_messages', |
| checked: false |
| }, |
| send_messages: { |
| name: 'Send messages', |
| relatesTo: 'send_messages', |
| checked: false |
| } |
| }); |
| |
| let streamsPerms = $state( |
| (() => { |
| const tempPerms: StreamsPerms = {}; |
| |
| streams.forEach((s) => { |
| tempPerms[s.id] = { |
| manage_stream: { |
| name: 'Manage stream', |
| globalPermsKey: 'manage_streams', |
| checked: false, |
| disabled: false |
| }, |
| read_stream: { |
| name: 'Read stream', |
| globalPermsKey: 'read_streams', |
| checked: false, |
| disabled: false |
| }, |
| read_topics: { |
| name: 'Read topics', |
| globalPermsKey: 'read_topics', |
| checked: false, |
| disabled: false |
| }, |
| poll_messages: { |
| name: 'Poll messages', |
| globalPermsKey: 'poll_messages', |
| checked: false, |
| disabled: false |
| }, |
| send_messages: { |
| name: 'Send messages', |
| globalPermsKey: 'send_messages', |
| checked: false, |
| disabled: false |
| }, |
| manage_topics: { |
| name: 'Manage topics', |
| globalPermsKey: 'manage_topics', |
| checked: false, |
| disabled: false |
| }, |
| topicPerms: {} |
| }; |
| }); |
| |
| return tempPerms; |
| })() satisfies StreamsPerms |
| ); |
| |
| run(() => { |
| if (selectedStream) fetchTopics(selectedStream.id); |
| }); |
| run(() => { |
| buildTopicsPerms(topics); |
| }); |
| |
| let taintedStreams = $derived( |
| (() => { |
| const tainted: Set<number> = new SvelteSet([]); |
| |
| Object.keys(streamsPerms).forEach((streamId) => { |
| Object.keys(streamsPerms[streamId]).forEach((permissionKey) => { |
| if (permissionKey === 'topicPerms') { |
| const perm = streamsPerms[streamId][permissionKey]; |
| |
| Object.keys(perm).forEach((topicId) => { |
| const topicPerm = perm[topicId]; |
| const isTopicTained = Object.keys(topicPerm) |
| .map((k) => topicPerm[k]) |
| .some((p) => p.checked); |
| |
| if (isTopicTained) tainted.add(streamId); |
| }); |
| } else { |
| const perm = streamsPerms[streamId][permissionKey]; |
| if (perm.checked && !perm.disabled) tainted.add(streamId); |
| } |
| }); |
| }); |
| |
| return Array.from(tainted); |
| })().map((taintedStreamId) => { |
| const name = streams.find((stream) => stream.id === +taintedStreamId)!.name; |
| return { name, id: +taintedStreamId }; |
| }) |
| ); |
| |
| $effect(() => { |
| function formatGlobalPermissions() { |
| return Object.keys(globalPerms).reduce((result, key) => { |
| result[key] = globalPerms[key].checked; |
| return result; |
| }, {}); |
| } |
| |
| function hasAnyPermissionChecked( |
| permissionsObj: Record<string, any>, |
| excludeKeys: string[] = [] |
| ) { |
| return Object.keys(permissionsObj) |
| .filter((key) => !excludeKeys.includes(key)) |
| .some((key) => permissionsObj[key].checked); |
| } |
| |
| function formatTopicPermissions(topicPerms: Record<string, any>) { |
| const result = {}; |
| |
| Object.keys(topicPerms).forEach((topicId) => { |
| const topicPerm = topicPerms[topicId]; |
| |
| if (!hasAnyPermissionChecked(topicPerm)) { |
| return; |
| } |
| |
| result[topicId] = { |
| manage_topic: topicPerm.manageTopic.checked, |
| read_topic: topicPerm.readTopic.checked, |
| poll_messages: topicPerm.pollMessages.checked, |
| send_messages: topicPerm.sendMessages.checked |
| }; |
| }); |
| |
| return result; |
| } |
| |
| function formatStreamPermissions() { |
| const result = {}; |
| |
| Object.keys(streamsPerms).forEach((streamId) => { |
| const streamPerm = streamsPerms[streamId]; |
| const topicPerms = streamPerm.topicPerms; |
| |
| const hasStreamPermissions = hasAnyPermissionChecked(streamPerm, ['topicPerms']); |
| |
| const formattedTopics = formatTopicPermissions(topicPerms); |
| const hasTopicPermissions = Object.keys(formattedTopics).length > 0; |
| |
| if (!hasStreamPermissions && !hasTopicPermissions) { |
| return; |
| } |
| |
| result[streamId] = { |
| manage_stream: streamPerm.manage_stream.checked, |
| read_stream: streamPerm.read_stream.checked, |
| manage_topics: streamPerm.manage_topics.checked, |
| read_topics: streamPerm.read_topics.checked, |
| poll_messages: streamPerm.poll_messages.checked, |
| send_messages: streamPerm.send_messages.checked |
| }; |
| |
| if (hasTopicPermissions) { |
| result[streamId].topics = formattedTopics; |
| } |
| }); |
| |
| return result; |
| } |
| |
| value = { |
| global: formatGlobalPermissions(), |
| streams: formatStreamPermissions() |
| }; |
| }); |
| </script> |
| |
| <h4 class="ml-1 text-lg text-color mt-7">Global permissions</h4> |
| |
| <div class="grid grid-cols-4 mt-4"> |
| {#each Object.keys(globalPerms) as key (key)} |
| <label |
| class="flex gap-2 items-center text-color cursor-pointer" |
| for={`global-permissions-${key}`} |
| > |
| <Checkbox |
| bind:checked={globalPerms[key].checked} |
| value={globalPerms[key].name} |
| id={`global-permissions-${key}`} |
| name={globalPerms[key].name} |
| onclick={(e) => onGlobalPermChanged(key, noTypeCheck(e).target.checked)} |
| /> |
| <span class="text-sm">{globalPerms[key].name}</span> |
| </label> |
| {/each} |
| </div> |
| |
| {#if selectedStream} |
| <div class="flex flex-wrap gap-3 mt-6 items-center"> |
| <h4 class="text-lg text-color mr-2">Granular permissions</h4> |
| |
| {#each taintedStreams as { id, name } (id)} |
| <button |
| type="button" |
| onclick={() => (selectedStream = { name, id })} |
| transition:fade={{ duration: 80 }} |
| class={twMerge( |
| 'rounded-3xl px-3 py-1 whitespace-nowrap text-xs hover:shadow-lg hover:ring-2 transition-all dark:text-white ring-1 ring-green-500 shadow-md hover:cursor-pointer', |
| selectedStream?.id === id && 'bg-green-500 text-white' |
| )} |
| >id: {id}, {name} |
| </button> |
| {/each} |
| </div> |
| |
| <div class="grid grid-cols-[1fr_auto_1fr] gap-5 mt-4"> |
| <div class="w-full flex flex-col"> |
| <Combobox |
| items={streams} |
| formatter={(item) => `id: ${item.id}, ${item.name}`} |
| label="Stream" |
| bind:selectedValue={selectedStream} |
| /> |
| |
| <div class="grid grid-cols-2 mt-4"> |
| {#each Object.keys(streamsPerms[selectedStream.id]) as key (key)} |
| {#if key !== 'topicPerms'} |
| <label |
| class={twMerge( |
| 'flex gap-2 items-center text-color cursor-pointer', |
| streamsPerms[selectedStream.id][key].disabled && |
| 'cursor-not-allowed text-shade-l800' |
| )} |
| for={`stream-${key}-permission`} |
| > |
| <Checkbox |
| bind:checked={streamsPerms[selectedStream.id][key].checked} |
| value={streamsPerms[selectedStream.id][key].name} |
| disabled={streamsPerms[selectedStream.id][key].disabled} |
| id={`stream-${key}-permission`} |
| /> |
| <span class={twMerge('text-sm')}>{streamsPerms[selectedStream.id][key].name}</span> |
| </label> |
| {/if} |
| {/each} |
| </div> |
| </div> |
| |
| <div class="h-[68px] w-[40px] flex flex-col justify-end"> |
| <div class="w-fit h-fit"> |
| <Icon name="chevronRight" class="h-[40px] dark:stroke-white mt-auto w-auto" /> |
| </div> |
| </div> |
| |
| <div class="w-full flex flex-col"> |
| {#if topics.length === 0 && !streamsPerms[selectedStream.id].manage_topics.checked} |
| <em class="italic dark:text-white text-center block mt-[34px]"> |
| This stream has no topics. |
| </em> |
| {/if} |
| {#if streamsPerms[selectedStream.id].manage_topics.checked} |
| <div class="dark:text-white mt-9 text-center"> |
| <span> Every topic in stream </span> |
| <em class="text-green-500">{selectedStream.name}</em> |
| <span> {topics.length === 0 ? 'will have' : 'has'} full permissions </span> |
| </div> |
| {/if} |
| |
| {#if selectedTopic && Object.keys(streamsPerms[selectedStream.id].topicPerms).length > 0 && !streamsPerms[selectedStream.id].manage_topics.checked} |
| <div> |
| <Combobox |
| isLoading={fetchingTopics} |
| items={topics} |
| formatter={(item) => `id: ${item.id}, ${item.name}`} |
| label="Topic" |
| bind:selectedValue={selectedTopic} |
| /> |
| |
| <div class="grid grid-cols-2 mt-4"> |
| {#each Object.keys(streamsPerms[selectedStream.id].topicPerms[selectedTopic.id]) as key (key)} |
| <label class="flex gap-2 items-center text-color cursor-pointer"> |
| <Checkbox |
| bind:checked={ |
| streamsPerms[selectedStream.id].topicPerms[selectedTopic.id][key].checked |
| } |
| value="" |
| /> |
| <span class="text-sm" |
| >{streamsPerms[selectedStream.id].topicPerms[selectedTopic.id][key].name}</span |
| > |
| </label> |
| {/each} |
| </div> |
| </div> |
| {/if} |
| </div> |
| </div> |
| {/if} |