Reserved topic stuff
This commit is contained in:
		
							parent
							
								
									6c0429351a
								
							
						
					
					
						commit
						a91da7cf2c
					
				
					 11 changed files with 240 additions and 133 deletions
				
			
		|  | @ -108,6 +108,7 @@ type Config struct { | ||||||
| 	EnableLogin                          bool | 	EnableLogin                          bool | ||||||
| 	EnableEmailConfirm                   bool | 	EnableEmailConfirm                   bool | ||||||
| 	EnableResetPassword                  bool | 	EnableResetPassword                  bool | ||||||
|  | 	EnableAccountUpgrades                bool | ||||||
| 	Version                              string // injected by App | 	Version                              string // injected by App | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -40,12 +40,12 @@ import ( | ||||||
| 			message cache duration | 			message cache duration | ||||||
| 			Keep 10000 messages or keep X days? | 			Keep 10000 messages or keep X days? | ||||||
| 			Attachment expiration based on plan | 			Attachment expiration based on plan | ||||||
| 		reserve topics |  | ||||||
| 		purge accounts that were not logged into in X | 		purge accounts that were not logged into in X | ||||||
| 		reset daily limits for users | 		reset daily limits for users | ||||||
| 		Account usage not updated "in real time" |  | ||||||
| 		max token issue limit | 		max token issue limit | ||||||
| 		user db startup queries -> foreign keys | 		user db startup queries -> foreign keys | ||||||
|  | 		UI | ||||||
|  | 		- Feature flag for "reserve topic" feature | ||||||
| 		Sync: | 		Sync: | ||||||
| 			- "mute" setting | 			- "mute" setting | ||||||
| 			- figure out what settings are "web" or "phone" | 			- figure out what settings are "web" or "phone" | ||||||
|  | @ -447,17 +447,20 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi | ||||||
| 	if !s.config.WebRootIsApp { | 	if !s.config.WebRootIsApp { | ||||||
| 		appRoot = "/app" | 		appRoot = "/app" | ||||||
| 	} | 	} | ||||||
| 	disallowedTopicsStr := `"` + strings.Join(disallowedTopics, `", "`) + `"` | 	response := &apiConfigResponse{ | ||||||
|  | 		BaseURL:             "", // Will translate to window.location.origin | ||||||
|  | 		AppRoot:             appRoot, | ||||||
|  | 		EnableLogin:         s.config.EnableLogin, | ||||||
|  | 		EnableSignup:        s.config.EnableSignup, | ||||||
|  | 		EnableResetPassword: s.config.EnableResetPassword, | ||||||
|  | 		DisallowedTopics:    disallowedTopics, | ||||||
|  | 	} | ||||||
|  | 	b, err := json.Marshal(response) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
| 	w.Header().Set("Content-Type", "text/javascript") | 	w.Header().Set("Content-Type", "text/javascript") | ||||||
| 	_, err := io.WriteString(w, fmt.Sprintf(`// Generated server configuration | 	_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b))) | ||||||
| var config = { |  | ||||||
|   baseUrl: window.location.origin, |  | ||||||
|   appRoot: "%s", |  | ||||||
|   enableLogin: %t, |  | ||||||
|   enableSignup: %t, |  | ||||||
|   enableResetPassword: %t, |  | ||||||
|   disallowedTopics: [%s],  |  | ||||||
| };`, appRoot, s.config.EnableLogin, s.config.EnableSignup, s.config.EnableResetPassword, disallowedTopicsStr)) |  | ||||||
| 	return err | 	return err | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -280,3 +280,12 @@ type apiAccountAccessRequest struct { | ||||||
| 	Topic    string `json:"topic"` | 	Topic    string `json:"topic"` | ||||||
| 	Everyone string `json:"everyone"` | 	Everyone string `json:"everyone"` | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | type apiConfigResponse struct { | ||||||
|  | 	BaseURL             string   `json:"base_url"` | ||||||
|  | 	AppRoot             string   `json:"app_root"` | ||||||
|  | 	EnableLogin         bool     `json:"enable_login"` | ||||||
|  | 	EnableSignup        bool     `json:"enable_signup"` | ||||||
|  | 	EnableResetPassword bool     `json:"enable_reset_password"` | ||||||
|  | 	DisallowedTopics    []string `json:"disallowed_topics"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -83,6 +83,7 @@ | ||||||
|   "subscription_settings_dialog_title": "Subscription settings", |   "subscription_settings_dialog_title": "Subscription settings", | ||||||
|   "subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.", |   "subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.", | ||||||
|   "subscription_settings_dialog_display_name_placeholder": "Display name", |   "subscription_settings_dialog_display_name_placeholder": "Display name", | ||||||
|  |   "subscription_settings_dialog_reserve_topic_label": "Reserve topic and configure access", | ||||||
|   "subscription_settings_button_cancel": "Cancel", |   "subscription_settings_button_cancel": "Cancel", | ||||||
|   "subscription_settings_button_save": "Save", |   "subscription_settings_button_save": "Save", | ||||||
|   "notifications_loading": "Loading notifications …", |   "notifications_loading": "Loading notifications …", | ||||||
|  | @ -159,6 +160,7 @@ | ||||||
|   "subscribe_dialog_login_button_back": "Back", |   "subscribe_dialog_login_button_back": "Back", | ||||||
|   "subscribe_dialog_login_button_login": "Login", |   "subscribe_dialog_login_button_login": "Login", | ||||||
|   "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized", |   "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized", | ||||||
|  |   "subscribe_dialog_error_topic_already_reserved": "Topic already reserved", | ||||||
|   "subscribe_dialog_error_user_anonymous": "anonymous", |   "subscribe_dialog_error_user_anonymous": "anonymous", | ||||||
|   "account_basics_title": "Account", |   "account_basics_title": "Account", | ||||||
|   "account_basics_username_title": "Username", |   "account_basics_username_title": "Username", | ||||||
|  | @ -253,6 +255,7 @@ | ||||||
|   "prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe", |   "prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe", | ||||||
|   "prefs_reservations_dialog_title_add": "Reserve topic", |   "prefs_reservations_dialog_title_add": "Reserve topic", | ||||||
|   "prefs_reservations_dialog_title_edit": "Edit reserved topic", |   "prefs_reservations_dialog_title_edit": "Edit reserved topic", | ||||||
|  |   "prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.", | ||||||
|   "prefs_reservations_dialog_topic_label": "Topic", |   "prefs_reservations_dialog_topic_label": "Topic", | ||||||
|   "prefs_reservations_dialog_access_label": "Access", |   "prefs_reservations_dialog_access_label": "Access", | ||||||
|   "priority_min": "min", |   "priority_min": "min", | ||||||
|  |  | ||||||
|  | @ -231,6 +231,8 @@ class AccountApi { | ||||||
|         }); |         }); | ||||||
|         if (response.status === 401 || response.status === 403) { |         if (response.status === 401 || response.status === 403) { | ||||||
|             throw new UnauthorizedError(); |             throw new UnauthorizedError(); | ||||||
|  |         } else if (response.status === 409) { | ||||||
|  |             throw new TopicReservedError(); | ||||||
|         } else if (response.status !== 200) { |         } else if (response.status !== 200) { | ||||||
|             throw new Error(`Unexpected server response ${response.status}`); |             throw new Error(`Unexpected server response ${response.status}`); | ||||||
|         } |         } | ||||||
|  | @ -312,6 +314,13 @@ export class UsernameTakenError extends Error { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export class TopicReservedError extends Error { | ||||||
|  |     constructor(topic) { | ||||||
|  |         super("Topic already reserved"); | ||||||
|  |         this.topic = topic; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export class AccountCreateLimitReachedError extends Error { | export class AccountCreateLimitReachedError extends Error { | ||||||
|     constructor() { |     constructor() { | ||||||
|         super("Account creation limit reached"); |         super("Account creation limit reached"); | ||||||
|  |  | ||||||
|  | @ -1,2 +1,7 @@ | ||||||
| const config = window.config; | const config = window.config; | ||||||
|  | 
 | ||||||
|  | if (config.base_url === "") { | ||||||
|  |     config.base_url = window.location.origin; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export default config; | export default config; | ||||||
|  |  | ||||||
|  | @ -177,7 +177,7 @@ const Stats = () => { | ||||||
|             <PrefGroup> |             <PrefGroup> | ||||||
|                 <Pref title={t("account_usage_plan_title")}> |                 <Pref title={t("account_usage_plan_title")}> | ||||||
|                     <div> |                     <div> | ||||||
|                         {account?.role === "admin" |                         {account.role === "admin" | ||||||
|                             ? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></> |                             ? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></> | ||||||
|                             : t(`account_usage_plan_code_${planCode}`)} |                             : t(`account_usage_plan_code_${planCode}`)} | ||||||
|                     </div> |                     </div> | ||||||
|  | @ -187,28 +187,44 @@ const Stats = () => { | ||||||
|                         <Typography variant="body2" sx={{float: "left"}}>{account.stats.topics}</Typography> |                         <Typography variant="body2" sx={{float: "left"}}>{account.stats.topics}</Typography> | ||||||
|                         <Typography variant="body2" sx={{float: "right"}}>{account.limits.topics > 0 ? t("account_usage_of_limit", { limit: account.limits.topics }) : t("account_usage_unlimited")}</Typography> |                         <Typography variant="body2" sx={{float: "right"}}>{account.limits.topics > 0 ? t("account_usage_of_limit", { limit: account.limits.topics }) : t("account_usage_unlimited")}</Typography> | ||||||
|                     </div> |                     </div> | ||||||
|                     <LinearProgress variant="determinate" value={account.limits.topics > 0 ? normalize(account.stats.topics, account.limits.topics) : 100} /> |                     <LinearProgress | ||||||
|  |                         variant="determinate" | ||||||
|  |                         value={account.limits.topics > 0 ? normalize(account.stats.topics, account.limits.topics) : 100} | ||||||
|  |                         color={account?.role !== "admin" && account.stats.topics_remaining === 0 ? 'error' : 'primary'} | ||||||
|  |                     /> | ||||||
|                 </Pref> |                 </Pref> | ||||||
|                 <Pref title={t("account_usage_messages_title")}> |                 <Pref title={t("account_usage_messages_title")}> | ||||||
|                     <div> |                     <div> | ||||||
|                         <Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography> |                         <Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography> | ||||||
|                         <Typography variant="body2" sx={{float: "right"}}>{account.limits.messages > 0 ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography> |                         <Typography variant="body2" sx={{float: "right"}}>{account.limits.messages > 0 ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography> | ||||||
|                     </div> |                     </div> | ||||||
|                     <LinearProgress variant="determinate" value={account.limits.messages > 0 ? normalize(account.stats.messages, account.limits.messages) : 100} /> |                     <LinearProgress | ||||||
|  |                         variant="determinate" | ||||||
|  |                         value={account.limits.messages > 0 ? normalize(account.stats.messages, account.limits.messages) : 100} | ||||||
|  |                         color={account?.role !== "admin" && account.stats.messages_remaining === 0 ? 'error' : 'primary'} | ||||||
|  |                     /> | ||||||
|                 </Pref> |                 </Pref> | ||||||
|                 <Pref title={t("account_usage_emails_title")}> |                 <Pref title={t("account_usage_emails_title")}> | ||||||
|                     <div> |                     <div> | ||||||
|                         <Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography> |                         <Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography> | ||||||
|                         <Typography variant="body2" sx={{float: "right"}}>{account.limits.emails > 0 ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography> |                         <Typography variant="body2" sx={{float: "right"}}>{account.limits.emails > 0 ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography> | ||||||
|                     </div> |                     </div> | ||||||
|                     <LinearProgress variant="determinate" value={account.limits.emails > 0 ? normalize(account.stats.emails, account.limits.emails) : 100} /> |                     <LinearProgress | ||||||
|  |                         variant="determinate" | ||||||
|  |                         value={account.limits.emails > 0 ? normalize(account.stats.emails, account.limits.emails) : 100} | ||||||
|  |                         color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'} | ||||||
|  |                     /> | ||||||
|                 </Pref> |                 </Pref> | ||||||
|                 <Pref title={t("account_usage_attachment_storage_title")} subtitle={t("account_usage_attachment_storage_subtitle", { filesize: formatBytes(account.limits.attachment_file_size) })}> |                 <Pref title={t("account_usage_attachment_storage_title")} subtitle={account.role !== "admin" ? t("account_usage_attachment_storage_subtitle", { filesize: formatBytes(account.limits.attachment_file_size) }) : null}> | ||||||
|                     <div> |                     <div> | ||||||
|                         <Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography> |                         <Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography> | ||||||
|                         <Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography> |                         <Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography> | ||||||
|                     </div> |                     </div> | ||||||
|                     <LinearProgress variant="determinate" value={account.limits.attachment_total_size > 0 ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100} /> |                     <LinearProgress | ||||||
|  |                         variant="determinate" | ||||||
|  |                         value={account.limits.attachment_total_size > 0 ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100} | ||||||
|  |                         color={account.role !== "admin" && account.stats.attachment_total_size_remaining === 0 ? 'error' : 'primary'} | ||||||
|  |                     /> | ||||||
|                 </Pref> |                 </Pref> | ||||||
|             </PrefGroup> |             </PrefGroup> | ||||||
|             {account.limits.basis === "ip" && |             {account.limits.basis === "ip" && | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
| import {useEffect, useState} from 'react'; | import {useEffect, useState} from 'react'; | ||||||
| import { | import { | ||||||
|  |     Alert, | ||||||
|     CardActions, |     CardActions, | ||||||
|     CardContent, |     CardContent, | ||||||
|     FormControl, |     FormControl, | ||||||
|  | @ -44,6 +45,8 @@ import LockIcon from "@mui/icons-material/Lock"; | ||||||
| import {Public, PublicOff} from "@mui/icons-material"; | import {Public, PublicOff} from "@mui/icons-material"; | ||||||
| import ListItemIcon from "@mui/material/ListItemIcon"; | import ListItemIcon from "@mui/material/ListItemIcon"; | ||||||
| import ListItemText from "@mui/material/ListItemText"; | import ListItemText from "@mui/material/ListItemText"; | ||||||
|  | import DialogContentText from "@mui/material/DialogContentText"; | ||||||
|  | import ReserveTopicSelect from "./ReserveTopicSelect"; | ||||||
| 
 | 
 | ||||||
| const Preferences = () => { | const Preferences = () => { | ||||||
|     return ( |     return ( | ||||||
|  | @ -482,10 +485,11 @@ const Reservations = () => { | ||||||
|     const [dialogKey, setDialogKey] = useState(0); |     const [dialogKey, setDialogKey] = useState(0); | ||||||
|     const [dialogOpen, setDialogOpen] = useState(false); |     const [dialogOpen, setDialogOpen] = useState(false); | ||||||
| 
 | 
 | ||||||
|     if (!session.exists() || !account) { |     if (!session.exists() || !account || account.role === "admin") { | ||||||
|         return <></>; |         return <></>; | ||||||
|     } |     } | ||||||
|     const reservations = account.reservations || []; |     const reservations = account.reservations || []; | ||||||
|  |     const limitReached = account.role === "user" && account.stats.topics_remaining === 0; | ||||||
| 
 | 
 | ||||||
|     const handleAddClick = () => { |     const handleAddClick = () => { | ||||||
|         setDialogKey(prev => prev+1); |         setDialogKey(prev => prev+1); | ||||||
|  | @ -505,7 +509,7 @@ const Reservations = () => { | ||||||
|         } catch (e) { |         } catch (e) { | ||||||
|             console.log(`[Preferences] Error topic reservation.`, e); |             console.log(`[Preferences] Error topic reservation.`, e); | ||||||
|         } |         } | ||||||
|         // FIXME handle 401/403
 |         // FIXME handle 401/403/409
 | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|  | @ -518,9 +522,15 @@ const Reservations = () => { | ||||||
|                     {t("prefs_reservations_description")} |                     {t("prefs_reservations_description")} | ||||||
|                 </Paragraph> |                 </Paragraph> | ||||||
|                 {reservations.length > 0 && <ReservationsTable reservations={reservations}/>} |                 {reservations.length > 0 && <ReservationsTable reservations={reservations}/>} | ||||||
|  |                 {limitReached && | ||||||
|  |                     <Alert severity="info"> | ||||||
|  |                         You reached your reserved topics limit. | ||||||
|  |                     </Alert> | ||||||
|  |                 } | ||||||
|             </CardContent> |             </CardContent> | ||||||
|             <CardActions> |             <CardActions> | ||||||
|                 <Button onClick={handleAddClick}>{t("prefs_reservations_add_button")}</Button> |                 <Button onClick={handleAddClick} disabled={limitReached}>{t("prefs_reservations_add_button")}</Button> | ||||||
|  | 
 | ||||||
|                 <ReservationsDialog |                 <ReservationsDialog | ||||||
|                     key={`reservationAddDialog${dialogKey}`} |                     key={`reservationAddDialog${dialogKey}`} | ||||||
|                     open={dialogOpen} |                     open={dialogOpen} | ||||||
|  | @ -559,7 +569,7 @@ const ReservationsTable = (props) => { | ||||||
|         } catch (e) { |         } catch (e) { | ||||||
|             console.log(`[Preferences] Error topic reservation.`, e); |             console.log(`[Preferences] Error topic reservation.`, e); | ||||||
|         } |         } | ||||||
|         // FIXME handle 401/403
 |         // FIXME handle 401/403/409
 | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const handleDeleteClick = async (reservation) => { |     const handleDeleteClick = async (reservation) => { | ||||||
|  | @ -670,6 +680,9 @@ const ReservationsDialog = (props) => { | ||||||
|         <Dialog open={props.open} onClose={props.onCancel} maxWidth="sm" fullWidth fullScreen={fullScreen}> |         <Dialog open={props.open} onClose={props.onCancel} maxWidth="sm" fullWidth fullScreen={fullScreen}> | ||||||
|             <DialogTitle>{editMode ? t("prefs_reservations_dialog_title_edit") : t("prefs_reservations_dialog_title_add")}</DialogTitle> |             <DialogTitle>{editMode ? t("prefs_reservations_dialog_title_edit") : t("prefs_reservations_dialog_title_add")}</DialogTitle> | ||||||
|             <DialogContent> |             <DialogContent> | ||||||
|  |                 <DialogContentText> | ||||||
|  |                     {t("prefs_reservations_dialog_description")} | ||||||
|  |                 </DialogContentText> | ||||||
|                 {!editMode && <TextField |                 {!editMode && <TextField | ||||||
|                     autoFocus |                     autoFocus | ||||||
|                     margin="dense" |                     margin="dense" | ||||||
|  | @ -682,37 +695,11 @@ const ReservationsDialog = (props) => { | ||||||
|                     fullWidth |                     fullWidth | ||||||
|                     variant="standard" |                     variant="standard" | ||||||
|                 />} |                 />} | ||||||
|                 <FormControl fullWidth variant="standard"> |                 <ReserveTopicSelect | ||||||
|                     <Select |                     value={everyone} | ||||||
|                         value={everyone} |                     onChange={setEveryone} | ||||||
|                         onChange={(ev) => setEveryone(ev.target.value)} |                     sx={{mt: 1}} | ||||||
|                         aria-label={t("prefs_reservations_dialog_access_label")} |                 /> | ||||||
|                         sx={{ |  | ||||||
|                             marginTop: 1, |  | ||||||
|                             "& .MuiSelect-select": { |  | ||||||
|                                 display: 'flex', |  | ||||||
|                                 alignItems: 'center' |  | ||||||
|                             } |  | ||||||
|                         }} |  | ||||||
|                     > |  | ||||||
|                         <MenuItem value="deny-all"> |  | ||||||
|                             <ListItemIcon><LockIcon /></ListItemIcon> |  | ||||||
|                             <ListItemText primary={t("prefs_reservations_table_everyone_deny_all")} /> |  | ||||||
|                         </MenuItem> |  | ||||||
|                         <MenuItem value="read-only"> |  | ||||||
|                             <ListItemIcon><PublicOff /></ListItemIcon> |  | ||||||
|                             <ListItemText primary={t("prefs_reservations_table_everyone_read_only")} /> |  | ||||||
|                         </MenuItem> |  | ||||||
|                         <MenuItem value="write-only"> |  | ||||||
|                             <ListItemIcon><PublicOff /></ListItemIcon> |  | ||||||
|                             <ListItemText primary={t("prefs_reservations_table_everyone_write_only")} /> |  | ||||||
|                         </MenuItem> |  | ||||||
|                         <MenuItem value="read-write"> |  | ||||||
|                             <ListItemIcon><Public /></ListItemIcon> |  | ||||||
|                             <ListItemText primary={t("prefs_reservations_table_everyone_read_write")} /> |  | ||||||
|                         </MenuItem> |  | ||||||
|                     </Select> |  | ||||||
|                 </FormControl> |  | ||||||
|             </DialogContent> |             </DialogContent> | ||||||
|             <DialogActions> |             <DialogActions> | ||||||
|                 <Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button> |                 <Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button> | ||||||
|  |  | ||||||
							
								
								
									
										62
									
								
								web/src/components/ReserveTopicSelect.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								web/src/components/ReserveTopicSelect.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,62 @@ | ||||||
|  | import * as React from 'react'; | ||||||
|  | import {useState} from 'react'; | ||||||
|  | import Button from '@mui/material/Button'; | ||||||
|  | import TextField from '@mui/material/TextField'; | ||||||
|  | import Dialog from '@mui/material/Dialog'; | ||||||
|  | import DialogContent from '@mui/material/DialogContent'; | ||||||
|  | import DialogContentText from '@mui/material/DialogContentText'; | ||||||
|  | import DialogTitle from '@mui/material/DialogTitle'; | ||||||
|  | import {Checkbox, FormControl, FormControlLabel, Select, useMediaQuery} from "@mui/material"; | ||||||
|  | import theme from "./theme"; | ||||||
|  | import subscriptionManager from "../app/SubscriptionManager"; | ||||||
|  | import DialogFooter from "./DialogFooter"; | ||||||
|  | import {useTranslation} from "react-i18next"; | ||||||
|  | import accountApi, {UnauthorizedError} from "../app/AccountApi"; | ||||||
|  | import session from "../app/Session"; | ||||||
|  | import routes from "./routes"; | ||||||
|  | import MenuItem from "@mui/material/MenuItem"; | ||||||
|  | import ListItemIcon from "@mui/material/ListItemIcon"; | ||||||
|  | import LockIcon from "@mui/icons-material/Lock"; | ||||||
|  | import ListItemText from "@mui/material/ListItemText"; | ||||||
|  | import {Public, PublicOff} from "@mui/icons-material"; | ||||||
|  | 
 | ||||||
|  | const ReserveTopicSelect = (props) => { | ||||||
|  |     const { t } = useTranslation(); | ||||||
|  |     const sx = props.sx || {}; | ||||||
|  |     return ( | ||||||
|  |         <FormControl fullWidth variant="standard" sx={sx}> | ||||||
|  |             <Select | ||||||
|  |                 value={props.value} | ||||||
|  |                 onChange={(ev) => props.onChange(ev.target.value)} | ||||||
|  |                 aria-label={t("prefs_reservations_dialog_access_label")} | ||||||
|  |                 sx={{ | ||||||
|  |                     "& .MuiSelect-select": { | ||||||
|  |                         display: 'flex', | ||||||
|  |                         alignItems: 'center', | ||||||
|  |                         paddingTop: "4px", | ||||||
|  |                         paddingBottom: "4px", | ||||||
|  |                     } | ||||||
|  |                 }} | ||||||
|  |             > | ||||||
|  |                 <MenuItem value="deny-all"> | ||||||
|  |                     <ListItemIcon><LockIcon/></ListItemIcon> | ||||||
|  |                     <ListItemText primary={t("prefs_reservations_table_everyone_deny_all")}/> | ||||||
|  |                 </MenuItem> | ||||||
|  |                 <MenuItem value="read-only"> | ||||||
|  |                     <ListItemIcon><PublicOff/></ListItemIcon> | ||||||
|  |                     <ListItemText primary={t("prefs_reservations_table_everyone_read_only")}/> | ||||||
|  |                 </MenuItem> | ||||||
|  |                 <MenuItem value="write-only"> | ||||||
|  |                     <ListItemIcon><PublicOff/></ListItemIcon> | ||||||
|  |                     <ListItemText primary={t("prefs_reservations_table_everyone_write_only")}/> | ||||||
|  |                 </MenuItem> | ||||||
|  |                 <MenuItem value="read-write"> | ||||||
|  |                     <ListItemIcon><Public/></ListItemIcon> | ||||||
|  |                     <ListItemText primary={t("prefs_reservations_table_everyone_read_write")}/> | ||||||
|  |                 </MenuItem> | ||||||
|  |             </Select> | ||||||
|  |         </FormControl> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default ReserveTopicSelect; | ||||||
|  | @ -6,7 +6,7 @@ import Dialog from '@mui/material/Dialog'; | ||||||
| import DialogContent from '@mui/material/DialogContent'; | import DialogContent from '@mui/material/DialogContent'; | ||||||
| import DialogContentText from '@mui/material/DialogContentText'; | import DialogContentText from '@mui/material/DialogContentText'; | ||||||
| import DialogTitle from '@mui/material/DialogTitle'; | import DialogTitle from '@mui/material/DialogTitle'; | ||||||
| import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material"; | import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material"; | ||||||
| import theme from "./theme"; | import theme from "./theme"; | ||||||
| import api from "../app/Api"; | import api from "../app/Api"; | ||||||
| import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; | import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; | ||||||
|  | @ -17,14 +17,14 @@ import DialogFooter from "./DialogFooter"; | ||||||
| import {useTranslation} from "react-i18next"; | import {useTranslation} from "react-i18next"; | ||||||
| import session from "../app/Session"; | import session from "../app/Session"; | ||||||
| import routes from "./routes"; | import routes from "./routes"; | ||||||
| import accountApi, {UnauthorizedError} from "../app/AccountApi"; | import accountApi, {TopicReservedError, UnauthorizedError} from "../app/AccountApi"; | ||||||
| import IconButton from "@mui/material/IconButton"; |  | ||||||
| import PublicIcon from '@mui/icons-material/Public'; | import PublicIcon from '@mui/icons-material/Public'; | ||||||
| import LockIcon from '@mui/icons-material/Lock'; | import LockIcon from '@mui/icons-material/Lock'; | ||||||
| import PublicOffIcon from '@mui/icons-material/PublicOff'; | import PublicOffIcon from '@mui/icons-material/PublicOff'; | ||||||
| import MenuItem from "@mui/material/MenuItem"; | import MenuItem from "@mui/material/MenuItem"; | ||||||
| import PopupMenu from "./PopupMenu"; | import PopupMenu from "./PopupMenu"; | ||||||
| import ListItemIcon from "@mui/material/ListItemIcon"; | import ListItemIcon from "@mui/material/ListItemIcon"; | ||||||
|  | import ReserveTopicSelect from "./ReserveTopicSelect"; | ||||||
| 
 | 
 | ||||||
| const publicBaseUrl = "https://ntfy.sh"; | const publicBaseUrl = "https://ntfy.sh"; | ||||||
| 
 | 
 | ||||||
|  | @ -33,6 +33,7 @@ const SubscribeDialog = (props) => { | ||||||
|     const [topic, setTopic] = useState(""); |     const [topic, setTopic] = useState(""); | ||||||
|     const [showLoginPage, setShowLoginPage] = useState(false); |     const [showLoginPage, setShowLoginPage] = useState(false); | ||||||
|     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); |     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||||
|  | 
 | ||||||
|     const handleSuccess = async () => { |     const handleSuccess = async () => { | ||||||
|         console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); |         console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); | ||||||
|         const actualBaseUrl = (baseUrl) ? baseUrl : config.baseUrl; |         const actualBaseUrl = (baseUrl) ? baseUrl : config.baseUrl; | ||||||
|  | @ -44,6 +45,7 @@ const SubscribeDialog = (props) => { | ||||||
|                     topic: topic |                     topic: topic | ||||||
|                 }); |                 }); | ||||||
|                 await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); |                 await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); | ||||||
|  |                 await accountApi.sync(); | ||||||
|             } catch (e) { |             } catch (e) { | ||||||
|                 console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); |                 console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); | ||||||
|                 if ((e instanceof UnauthorizedError)) { |                 if ((e instanceof UnauthorizedError)) { | ||||||
|  | @ -54,6 +56,7 @@ const SubscribeDialog = (props) => { | ||||||
|         poller.pollInBackground(subscription); // Dangle!
 |         poller.pollInBackground(subscription); // Dangle!
 | ||||||
|         props.onSuccess(subscription); |         props.onSuccess(subscription); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|         <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> |         <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> | ||||||
|             {!showLoginPage && <SubscribePage |             {!showLoginPage && <SubscribePage | ||||||
|  | @ -78,10 +81,11 @@ const SubscribeDialog = (props) => { | ||||||
| 
 | 
 | ||||||
| const SubscribePage = (props) => { | const SubscribePage = (props) => { | ||||||
|     const { t } = useTranslation(); |     const { t } = useTranslation(); | ||||||
|  |     const [reserveTopicVisible, setReserveTopicVisible] = useState(false); | ||||||
|     const [anotherServerVisible, setAnotherServerVisible] = useState(false); |     const [anotherServerVisible, setAnotherServerVisible] = useState(false); | ||||||
|     const [errorText, setErrorText] = useState(""); |     const [errorText, setErrorText] = useState(""); | ||||||
|     const [accessAnchorEl, setAccessAnchorEl] = useState(null); |     const [accessAnchorEl, setAccessAnchorEl] = useState(null); | ||||||
|     const [access, setAccess] = useState("public"); |     const [everyone, setEveryone] = useState("deny-all"); | ||||||
|     const baseUrl = (anotherServerVisible) ? props.baseUrl : config.baseUrl; |     const baseUrl = (anotherServerVisible) ? props.baseUrl : config.baseUrl; | ||||||
|     const topic = props.topic; |     const topic = props.topic; | ||||||
|     const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic)); |     const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic)); | ||||||
|  | @ -92,6 +96,8 @@ const SubscribePage = (props) => { | ||||||
|     const handleSubscribe = async () => { |     const handleSubscribe = async () => { | ||||||
|         const user = await userManager.get(baseUrl); // May be undefined
 |         const user = await userManager.get(baseUrl); // May be undefined
 | ||||||
|         const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); |         const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); | ||||||
|  | 
 | ||||||
|  |         // Check read access to topic
 | ||||||
|         const success = await api.topicAuth(baseUrl, topic, user); |         const success = await api.topicAuth(baseUrl, topic, user); | ||||||
|         if (!success) { |         if (!success) { | ||||||
|             console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); |             console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); | ||||||
|  | @ -103,6 +109,24 @@ const SubscribePage = (props) => { | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  | 
 | ||||||
|  |         // Reserve topic (if requested)
 | ||||||
|  |         if (session.exists() && baseUrl === config.baseUrl && reserveTopicVisible) { | ||||||
|  |             console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`); | ||||||
|  |             try { | ||||||
|  |                 await accountApi.upsertAccess(topic, everyone); | ||||||
|  |                 // Account sync later after it was added
 | ||||||
|  |             } catch (e) { | ||||||
|  |                 console.log(`[SubscribeDialog] Error reserving topic`, e); | ||||||
|  |                 if ((e instanceof UnauthorizedError)) { | ||||||
|  |                     session.resetAndRedirect(routes.login); | ||||||
|  |                 } else if ((e instanceof TopicReservedError)) { | ||||||
|  |                     setErrorText(t("subscribe_dialog_error_topic_already_reserved")); | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); |         console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); | ||||||
|         props.onSuccess(); |         props.onSuccess(); | ||||||
|     }; |     }; | ||||||
|  | @ -137,14 +161,7 @@ const SubscribePage = (props) => { | ||||||
|                 <DialogContentText> |                 <DialogContentText> | ||||||
|                     {t("subscribe_dialog_subscribe_description")} |                     {t("subscribe_dialog_subscribe_description")} | ||||||
|                 </DialogContentText> |                 </DialogContentText> | ||||||
|                 <div style={{display: 'flex'}} role="row"> |                 <div style={{display: 'flex', paddingBottom: "8px"}} role="row"> | ||||||
|                     {session.exists() && |  | ||||||
|                         <IconButton onClick={(ev) => setAccessAnchorEl(ev.currentTarget)} color="inherit" size="large" edge="start" sx={{height: "45px", marginTop: "5px", color: "grey"}}> |  | ||||||
|                             {access === "public" && <PublicIcon/>} |  | ||||||
|                             {access === "public-read" && <PublicOffIcon/>} |  | ||||||
|                             {access === "private" && <LockIcon/>} |  | ||||||
|                         </IconButton> |  | ||||||
|                     } |  | ||||||
|                     <TextField |                     <TextField | ||||||
|                         autoFocus |                         autoFocus | ||||||
|                         margin="dense" |                         margin="dense" | ||||||
|  | @ -168,19 +185,19 @@ const SubscribePage = (props) => { | ||||||
|                         open={!!accessAnchorEl} |                         open={!!accessAnchorEl} | ||||||
|                         onClose={() => setAccessAnchorEl(null)} |                         onClose={() => setAccessAnchorEl(null)} | ||||||
|                     > |                     > | ||||||
|                         <MenuItem onClick={() => setAccess("private")} selected={access === "private"}> |                         <MenuItem onClick={() => setEveryone("private")} selected={everyone === "private"}> | ||||||
|                             <ListItemIcon> |                             <ListItemIcon> | ||||||
|                                 <LockIcon fontSize="small" /> |                                 <LockIcon fontSize="small" /> | ||||||
|                             </ListItemIcon> |                             </ListItemIcon> | ||||||
|                             Only I can publish and subscribe |                             Only I can publish and subscribe | ||||||
|                         </MenuItem> |                         </MenuItem> | ||||||
|                         <MenuItem onClick={() => setAccess("public-read")} selected={access === "public-read"}> |                         <MenuItem onClick={() => setEveryone("public-read")} selected={everyone === "public-read"}> | ||||||
|                             <ListItemIcon> |                             <ListItemIcon> | ||||||
|                                 <PublicOffIcon fontSize="small" /> |                                 <PublicOffIcon fontSize="small" /> | ||||||
|                             </ListItemIcon> |                             </ListItemIcon> | ||||||
|                             I can publish, everyone can subscribe |                             I can publish, everyone can subscribe | ||||||
|                         </MenuItem> |                         </MenuItem> | ||||||
|                         <MenuItem onClick={() => setAccess("public")} selected={access === "public"}> |                         <MenuItem onClick={() => setEveryone("public")} selected={everyone === "public"}> | ||||||
|                             <ListItemIcon> |                             <ListItemIcon> | ||||||
|                                 <PublicIcon fontSize="small" /> |                                 <PublicIcon fontSize="small" /> | ||||||
|                             </ListItemIcon> |                             </ListItemIcon> | ||||||
|  | @ -188,32 +205,58 @@ const SubscribePage = (props) => { | ||||||
|                         </MenuItem> |                         </MenuItem> | ||||||
|                     </PopupMenu> |                     </PopupMenu> | ||||||
|                 </div> |                 </div> | ||||||
|                 <FormControlLabel |                 {session.exists() && !anotherServerVisible && | ||||||
|                     sx={{pt: 1}} |                     <FormGroup> | ||||||
|                     control={ |                         <FormControlLabel | ||||||
|                         <Checkbox |  | ||||||
|                             onChange={handleUseAnotherChanged} |  | ||||||
|                             inputProps={{ |  | ||||||
|                                 "aria-label": t("subscribe_dialog_subscribe_use_another_label") |  | ||||||
|                             }} |  | ||||||
|                         /> |  | ||||||
|                     } |  | ||||||
|                     label={t("subscribe_dialog_subscribe_use_another_label")} /> |  | ||||||
|                 {anotherServerVisible && <Autocomplete |  | ||||||
|                     freeSolo |  | ||||||
|                     options={existingBaseUrls} |  | ||||||
|                     sx={{ maxWidth: 400 }} |  | ||||||
|                     inputValue={props.baseUrl} |  | ||||||
|                     onInputChange={updateBaseUrl} |  | ||||||
|                     renderInput={ (params) => |  | ||||||
|                         <TextField |  | ||||||
|                             {...params} |  | ||||||
|                             placeholder={config.baseUrl} |  | ||||||
|                             variant="standard" |                             variant="standard" | ||||||
|                             aria-label={t("subscribe_dialog_subscribe_base_url_label")} |                             control={ | ||||||
|  |                                 <Checkbox | ||||||
|  |                                     fullWidth | ||||||
|  |                                     checked={reserveTopicVisible} | ||||||
|  |                                     onChange={(ev) => setReserveTopicVisible(ev.target.checked)} | ||||||
|  |                                     inputProps={{ | ||||||
|  |                                         "aria-label": t("subscription_settings_dialog_reserve_topic_label") | ||||||
|  |                                     }} | ||||||
|  |                                 /> | ||||||
|  |                             } | ||||||
|  |                             label={t("subscription_settings_dialog_reserve_topic_label")} | ||||||
|                         /> |                         /> | ||||||
|                     } |                         {reserveTopicVisible && | ||||||
|                 />} |                             <ReserveTopicSelect | ||||||
|  |                                 value={everyone} | ||||||
|  |                                 onChange={setEveryone} | ||||||
|  |                             /> | ||||||
|  |                         } | ||||||
|  |                     </FormGroup> | ||||||
|  |                 } | ||||||
|  |                 {!reserveTopicVisible && | ||||||
|  |                     <FormGroup> | ||||||
|  |                         <FormControlLabel | ||||||
|  |                             control={ | ||||||
|  |                                 <Checkbox | ||||||
|  |                                     onChange={handleUseAnotherChanged} | ||||||
|  |                                     inputProps={{ | ||||||
|  |                                         "aria-label": t("subscribe_dialog_subscribe_use_another_label") | ||||||
|  |                                     }} | ||||||
|  |                                 /> | ||||||
|  |                             } | ||||||
|  |                             label={t("subscribe_dialog_subscribe_use_another_label")}/> | ||||||
|  |                         {anotherServerVisible && <Autocomplete | ||||||
|  |                             freeSolo | ||||||
|  |                             options={existingBaseUrls} | ||||||
|  |                             inputValue={props.baseUrl} | ||||||
|  |                             onInputChange={updateBaseUrl} | ||||||
|  |                             renderInput={(params) => | ||||||
|  |                                 <TextField | ||||||
|  |                                     {...params} | ||||||
|  |                                     placeholder={config.baseUrl} | ||||||
|  |                                     variant="standard" | ||||||
|  |                                     aria-label={t("subscribe_dialog_subscribe_base_url_label")} | ||||||
|  |                                 /> | ||||||
|  |                             } | ||||||
|  |                         />} | ||||||
|  |                     </FormGroup> | ||||||
|  |                 } | ||||||
|             </DialogContent> |             </DialogContent> | ||||||
|             <DialogFooter status={errorText}> |             <DialogFooter status={errorText}> | ||||||
|                 <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button> |                 <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button> | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ import Dialog from '@mui/material/Dialog'; | ||||||
| import DialogContent from '@mui/material/DialogContent'; | import DialogContent from '@mui/material/DialogContent'; | ||||||
| import DialogContentText from '@mui/material/DialogContentText'; | import DialogContentText from '@mui/material/DialogContentText'; | ||||||
| import DialogTitle from '@mui/material/DialogTitle'; | import DialogTitle from '@mui/material/DialogTitle'; | ||||||
| import {Checkbox, FormControl, FormControlLabel, Select, useMediaQuery} from "@mui/material"; | import {Checkbox, FormControlLabel, useMediaQuery} from "@mui/material"; | ||||||
| import theme from "./theme"; | import theme from "./theme"; | ||||||
| import subscriptionManager from "../app/SubscriptionManager"; | import subscriptionManager from "../app/SubscriptionManager"; | ||||||
| import DialogFooter from "./DialogFooter"; | import DialogFooter from "./DialogFooter"; | ||||||
|  | @ -14,11 +14,7 @@ import {useTranslation} from "react-i18next"; | ||||||
| import accountApi, {UnauthorizedError} from "../app/AccountApi"; | import accountApi, {UnauthorizedError} from "../app/AccountApi"; | ||||||
| import session from "../app/Session"; | import session from "../app/Session"; | ||||||
| import routes from "./routes"; | import routes from "./routes"; | ||||||
| import MenuItem from "@mui/material/MenuItem"; | import ReserveTopicSelect from "./ReserveTopicSelect"; | ||||||
| import ListItemIcon from "@mui/material/ListItemIcon"; |  | ||||||
| import LockIcon from "@mui/icons-material/Lock"; |  | ||||||
| import ListItemText from "@mui/material/ListItemText"; |  | ||||||
| import {Public, PublicOff} from "@mui/icons-material"; |  | ||||||
| 
 | 
 | ||||||
| const SubscriptionSettingsDialog = (props) => { | const SubscriptionSettingsDialog = (props) => { | ||||||
|     const { t } = useTranslation(); |     const { t } = useTranslation(); | ||||||
|  | @ -53,6 +49,8 @@ const SubscriptionSettingsDialog = (props) => { | ||||||
|                 if ((e instanceof UnauthorizedError)) { |                 if ((e instanceof UnauthorizedError)) { | ||||||
|                     session.resetAndRedirect(routes.login); |                     session.resetAndRedirect(routes.login); | ||||||
|                 } |                 } | ||||||
|  | 
 | ||||||
|  |                 // FIXME handle 409
 | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         props.onClose(); |         props.onClose(); | ||||||
|  | @ -80,7 +78,6 @@ const SubscriptionSettingsDialog = (props) => { | ||||||
|                         "aria-label": t("subscription_settings_dialog_display_name_placeholder") |                         "aria-label": t("subscription_settings_dialog_display_name_placeholder") | ||||||
|                     }} |                     }} | ||||||
|                 /> |                 /> | ||||||
| 
 |  | ||||||
|                 <FormControlLabel |                 <FormControlLabel | ||||||
|                     fullWidth |                     fullWidth | ||||||
|                     variant="standard" |                     variant="standard" | ||||||
|  | @ -90,45 +87,17 @@ const SubscriptionSettingsDialog = (props) => { | ||||||
|                             checked={reserveTopicVisible} |                             checked={reserveTopicVisible} | ||||||
|                             onChange={(ev) => setReserveTopicVisible(ev.target.checked)} |                             onChange={(ev) => setReserveTopicVisible(ev.target.checked)} | ||||||
|                             inputProps={{ |                             inputProps={{ | ||||||
|                                 "aria-label": t("xxxxxxxxxxxxxxxxxx") |                                 "aria-label": t("subscription_settings_dialog_reserve_topic_label") | ||||||
|                             }} |                             }} | ||||||
|                         /> |                         /> | ||||||
|                     } |                     } | ||||||
|                     label={t("Reserve topic and configure custom access:")} |                     label={t("subscription_settings_dialog_reserve_topic_label")} | ||||||
|                 /> |                 /> | ||||||
|                 {reserveTopicVisible && |                 {reserveTopicVisible && | ||||||
|                     <FormControl variant="standard"> |                     <ReserveTopicSelect | ||||||
|                         <Select |                         value={everyone} | ||||||
|                             value={everyone} |                         onChange={setEveryone} | ||||||
|                             onChange={(ev) => setEveryone(ev.target.value)} |                     /> | ||||||
|                             aria-label={t("prefs_reservations_dialog_access_label")} |  | ||||||
|                             sx={{ |  | ||||||
|                                 "& .MuiSelect-select": { |  | ||||||
|                                     display: 'flex', |  | ||||||
|                                     alignItems: 'center', |  | ||||||
|                                     paddingTop: "4px", |  | ||||||
|                                     paddingBottom: "4px", |  | ||||||
|                                 } |  | ||||||
|                             }} |  | ||||||
|                         > |  | ||||||
|                             <MenuItem value="deny-all"> |  | ||||||
|                                 <ListItemIcon><LockIcon/></ListItemIcon> |  | ||||||
|                                 <ListItemText primary={t("prefs_reservations_table_everyone_deny_all")}/> |  | ||||||
|                             </MenuItem> |  | ||||||
|                             <MenuItem value="read-only"> |  | ||||||
|                                 <ListItemIcon><PublicOff/></ListItemIcon> |  | ||||||
|                                 <ListItemText primary={t("prefs_reservations_table_everyone_read_only")}/> |  | ||||||
|                             </MenuItem> |  | ||||||
|                             <MenuItem value="write-only"> |  | ||||||
|                                 <ListItemIcon><PublicOff/></ListItemIcon> |  | ||||||
|                                 <ListItemText primary={t("prefs_reservations_table_everyone_write_only")}/> |  | ||||||
|                             </MenuItem> |  | ||||||
|                             <MenuItem value="read-write"> |  | ||||||
|                                 <ListItemIcon><Public/></ListItemIcon> |  | ||||||
|                                 <ListItemText primary={t("prefs_reservations_table_everyone_read_write")}/> |  | ||||||
|                             </MenuItem> |  | ||||||
|                         </Select> |  | ||||||
|                     </FormControl> |  | ||||||
|                 } |                 } | ||||||
|             </DialogContent> |             </DialogContent> | ||||||
|             <DialogFooter> |             <DialogFooter> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue