mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d27123bd7 | ||
|
|
79ca374bb6 |
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { notifierApi } from '../../../entity/notifiers';
|
||||
import type { Notifier } from '../../../entity/notifiers';
|
||||
import type { WorkspaceResponse } from '../../../entity/workspaces';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { NotifierCardComponent } from './NotifierCardComponent';
|
||||
import { NotifierComponent } from './NotifierComponent';
|
||||
import { EditNotifierComponent } from './edit/EditNotifierComponent';
|
||||
@@ -14,21 +15,47 @@ interface Props {
|
||||
isCanManageNotifiers: boolean;
|
||||
}
|
||||
|
||||
const SELECTED_NOTIFIER_STORAGE_KEY = 'selectedNotifierId';
|
||||
|
||||
export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifiers }: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [notifiers, setNotifiers] = useState<Notifier[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const [isShowAddNotifier, setIsShowAddNotifier] = useState(false);
|
||||
const [selectedNotifierId, setSelectedNotifierId] = useState<string | undefined>(undefined);
|
||||
const loadNotifiers = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const updateSelectedNotifierId = (notifierId: string | undefined) => {
|
||||
setSelectedNotifierId(notifierId);
|
||||
if (notifierId) {
|
||||
localStorage.setItem(`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`, notifierId);
|
||||
} else {
|
||||
localStorage.removeItem(`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadNotifiers = (isSilent = false, selectNotifierId?: string) => {
|
||||
if (!isSilent) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
notifierApi
|
||||
.getNotifiers(workspace.id)
|
||||
.then((notifiers) => {
|
||||
setNotifiers(notifiers);
|
||||
if (!selectedNotifierId) {
|
||||
setSelectedNotifierId(notifiers[0]?.id);
|
||||
if (selectNotifierId) {
|
||||
updateSelectedNotifierId(selectNotifierId);
|
||||
} else if (!selectedNotifierId && !isSilent && !isMobile) {
|
||||
// On desktop, auto-select a notifier; on mobile, keep it unselected
|
||||
const savedNotifierId = localStorage.getItem(
|
||||
`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`,
|
||||
);
|
||||
const notifierToSelect =
|
||||
savedNotifierId && notifiers.some((n) => n.id === savedNotifierId)
|
||||
? savedNotifierId
|
||||
: notifiers[0]?.id;
|
||||
updateSelectedNotifierId(notifierToSelect);
|
||||
}
|
||||
})
|
||||
.catch((e) => alert(e.message))
|
||||
@@ -37,6 +64,12 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifiers();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadNotifiers(true);
|
||||
}, 5 * 60_000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -53,45 +86,89 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
</Button>
|
||||
);
|
||||
|
||||
const filteredNotifiers = notifiers.filter((notifier) =>
|
||||
notifier.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
// On mobile, show either the list or the notifier details
|
||||
const showNotifierList = !isMobile || !selectedNotifierId;
|
||||
const showNotifierDetails = selectedNotifierId && (!isMobile || selectedNotifierId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex grow">
|
||||
<div
|
||||
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
{notifiers.length >= 5 && isCanManageNotifiers && addNotifierButton}
|
||||
{showNotifierList && (
|
||||
<div
|
||||
className="w-full overflow-y-auto md:mx-3 md:w-[250px] md:min-w-[250px] md:pr-2"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
{notifiers.length >= 5 && (
|
||||
<>
|
||||
{isCanManageNotifiers && addNotifierButton}
|
||||
|
||||
{notifiers.map((notifier) => (
|
||||
<NotifierCardComponent
|
||||
key={notifier.id}
|
||||
notifier={notifier}
|
||||
selectedNotifierId={selectedNotifierId}
|
||||
setSelectedNotifierId={setSelectedNotifierId}
|
||||
/>
|
||||
))}
|
||||
<div className="mb-2">
|
||||
<input
|
||||
placeholder="Search notifier"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{notifiers.length < 5 && isCanManageNotifiers && addNotifierButton}
|
||||
{filteredNotifiers.length > 0
|
||||
? filteredNotifiers.map((notifier) => (
|
||||
<NotifierCardComponent
|
||||
key={notifier.id}
|
||||
notifier={notifier}
|
||||
selectedNotifierId={selectedNotifierId}
|
||||
setSelectedNotifierId={updateSelectedNotifierId}
|
||||
/>
|
||||
))
|
||||
: searchQuery && (
|
||||
<div className="mb-4 text-center text-sm text-gray-500">
|
||||
No notifiers found matching "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-3 text-center text-xs text-gray-500">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
{notifiers.length < 5 && isCanManageNotifiers && addNotifierButton}
|
||||
|
||||
<div className="mx-3 text-center text-xs text-gray-500">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNotifierId && (
|
||||
<NotifierComponent
|
||||
notifierId={selectedNotifierId}
|
||||
onNotifierChanged={() => {
|
||||
loadNotifiers();
|
||||
}}
|
||||
onNotifierDeleted={() => {
|
||||
loadNotifiers();
|
||||
setSelectedNotifierId(
|
||||
notifiers.filter((notifier) => notifier.id !== selectedNotifierId)[0]?.id,
|
||||
);
|
||||
}}
|
||||
isCanManageNotifiers={isCanManageNotifiers}
|
||||
/>
|
||||
{showNotifierDetails && (
|
||||
<div className="flex w-full flex-col md:flex-1">
|
||||
{isMobile && (
|
||||
<div className="mb-2">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => updateSelectedNotifierId(undefined)}
|
||||
className="w-full"
|
||||
>
|
||||
← Back to notifiers
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NotifierComponent
|
||||
notifierId={selectedNotifierId}
|
||||
onNotifierChanged={() => {
|
||||
loadNotifiers();
|
||||
}}
|
||||
onNotifierDeleted={() => {
|
||||
const remainingNotifiers = notifiers.filter(
|
||||
(notifier) => notifier.id !== selectedNotifierId,
|
||||
);
|
||||
updateSelectedNotifierId(remainingNotifiers[0]?.id);
|
||||
loadNotifiers();
|
||||
}}
|
||||
isCanManageNotifiers={isCanManageNotifiers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -111,8 +188,8 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
isShowName
|
||||
isShowClose={false}
|
||||
onClose={() => setIsShowAddNotifier(false)}
|
||||
onChanged={() => {
|
||||
loadNotifiers();
|
||||
onChanged={(notifier) => {
|
||||
loadNotifiers(false, notifier.id);
|
||||
setIsShowAddNotifier(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -208,8 +208,8 @@ export function EditNotifierComponent({
|
||||
return (
|
||||
<div>
|
||||
{isShowName && (
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[130px]">Name</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Name</div>
|
||||
|
||||
<Input
|
||||
value={notifier?.name || ''}
|
||||
@@ -224,28 +224,30 @@ export function EditNotifierComponent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Type</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Type</div>
|
||||
|
||||
<Select
|
||||
value={notifier?.notifierType}
|
||||
options={[
|
||||
{ label: 'Telegram', value: NotifierType.TELEGRAM },
|
||||
{ label: 'Email', value: NotifierType.EMAIL },
|
||||
{ label: 'Webhook', value: NotifierType.WEBHOOK },
|
||||
{ label: 'Slack', value: NotifierType.SLACK },
|
||||
{ label: 'Discord', value: NotifierType.DISCORD },
|
||||
{ label: 'Teams', value: NotifierType.TEAMS },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setNotifierType(value);
|
||||
setIsUnsaved(true);
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={notifier?.notifierType}
|
||||
options={[
|
||||
{ label: 'Telegram', value: NotifierType.TELEGRAM },
|
||||
{ label: 'Email', value: NotifierType.EMAIL },
|
||||
{ label: 'Webhook', value: NotifierType.WEBHOOK },
|
||||
{ label: 'Slack', value: NotifierType.SLACK },
|
||||
{ label: 'Discord', value: NotifierType.DISCORD },
|
||||
{ label: 'Teams', value: NotifierType.TEAMS },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setNotifierType(value);
|
||||
setIsUnsaved(true);
|
||||
}}
|
||||
size="small"
|
||||
className="w-[250px] max-w-[250px]"
|
||||
/>
|
||||
|
||||
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-2 h-4 w-4" />
|
||||
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5" />
|
||||
|
||||
@@ -11,31 +11,28 @@ interface Props {
|
||||
export function EditDiscordNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex">
|
||||
<div className="w-[130px] max-w-[130px] min-w-[130px] pr-3">Channel webhook URL</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={notifier?.discordNotifier?.channelWebhookUrl || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.discordNotifier) return;
|
||||
setNotifier({
|
||||
...notifier,
|
||||
discordNotifier: {
|
||||
...notifier.discordNotifier,
|
||||
channelWebhookUrl: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Channel webhook URL</div>
|
||||
<Input
|
||||
value={notifier?.discordNotifier?.channelWebhookUrl || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.discordNotifier) return;
|
||||
setNotifier({
|
||||
...notifier,
|
||||
discordNotifier: {
|
||||
...notifier.discordNotifier,
|
||||
channelWebhookUrl: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-[130px] max-w-[250px]">
|
||||
<div className="max-w-[250px] sm:ml-[150px]">
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<strong>How to get Discord webhook URL:</strong>
|
||||
<br />
|
||||
|
||||
@@ -12,34 +12,39 @@ interface Props {
|
||||
export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Target email</div>
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.targetEmail || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.emailNotifier) return;
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Target email</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.targetEmail || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.emailNotifier) return;
|
||||
|
||||
setNotifier({
|
||||
...notifier,
|
||||
emailNotifier: {
|
||||
...notifier.emailNotifier,
|
||||
targetEmail: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="example@gmail.com"
|
||||
/>
|
||||
setNotifier({
|
||||
...notifier,
|
||||
emailNotifier: {
|
||||
...notifier.emailNotifier,
|
||||
targetEmail: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="example@gmail.com"
|
||||
/>
|
||||
|
||||
<Tooltip className="cursor-pointer" title="The email where you want to receive the message">
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The email where you want to receive the message"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">SMTP host</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP host</div>
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.smtpHost || ''}
|
||||
onChange={(e) => {
|
||||
@@ -60,8 +65,8 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">SMTP port</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP port</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={notifier?.emailNotifier?.smtpPort || ''}
|
||||
@@ -83,8 +88,8 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">SMTP user</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP user</div>
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.smtpUser || ''}
|
||||
onChange={(e) => {
|
||||
@@ -105,8 +110,8 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">SMTP password</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP password</div>
|
||||
<Input
|
||||
type="password"
|
||||
value={notifier?.emailNotifier?.smtpPassword || ''}
|
||||
@@ -128,33 +133,35 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">From</div>
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.from || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.emailNotifier) return;
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">From</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={notifier?.emailNotifier?.from || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.emailNotifier) return;
|
||||
|
||||
setNotifier({
|
||||
...notifier,
|
||||
emailNotifier: {
|
||||
...notifier.emailNotifier,
|
||||
from: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="example@example.com"
|
||||
/>
|
||||
setNotifier({
|
||||
...notifier,
|
||||
emailNotifier: {
|
||||
...notifier.emailNotifier,
|
||||
from: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="example@example.com"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Optional. Email address to use as sender. If empty, will use SMTP user or auto-generate from host"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Optional. Email address to use as sender. If empty, will use SMTP user or auto-generate from host"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Props {
|
||||
export function EditSlackNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 ml-[130px] max-w-[200px]" style={{ lineHeight: 1 }}>
|
||||
<div className="mb-1 max-w-[250px] sm:ml-[150px]" style={{ lineHeight: 1 }}>
|
||||
<a
|
||||
className="text-xs !text-blue-600"
|
||||
href="https://postgresus.com/notifiers/slack"
|
||||
@@ -22,54 +22,48 @@ export function EditSlackNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Bot token</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Bot token</div>
|
||||
<Input
|
||||
value={notifier?.slackNotifier?.botToken || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.slackNotifier) return;
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={notifier?.slackNotifier?.botToken || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.slackNotifier) return;
|
||||
|
||||
setNotifier({
|
||||
...notifier,
|
||||
slackNotifier: {
|
||||
...notifier.slackNotifier,
|
||||
botToken: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="xoxb-..."
|
||||
/>
|
||||
</div>
|
||||
setNotifier({
|
||||
...notifier,
|
||||
slackNotifier: {
|
||||
...notifier.slackNotifier,
|
||||
botToken: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="xoxb-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Target chat ID</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Target chat ID</div>
|
||||
<Input
|
||||
value={notifier?.slackNotifier?.targetChatId || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.slackNotifier) return;
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={notifier?.slackNotifier?.targetChatId || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.slackNotifier) return;
|
||||
|
||||
setNotifier({
|
||||
...notifier,
|
||||
slackNotifier: {
|
||||
...notifier.slackNotifier,
|
||||
targetChatId: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="C1234567890"
|
||||
/>
|
||||
</div>
|
||||
setNotifier({
|
||||
...notifier,
|
||||
slackNotifier: {
|
||||
...notifier.slackNotifier,
|
||||
targetChatId: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="C1234567890"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ export function EditTeamsNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 ml-[130px] max-w-[200px]" style={{ lineHeight: 1 }}>
|
||||
<div className="mb-1 max-w-[250px] sm:ml-[150px]" style={{ lineHeight: 1 }}>
|
||||
<a
|
||||
className="text-xs !text-blue-600"
|
||||
href="https://postgresus.com/notifiers/teams"
|
||||
@@ -38,25 +38,24 @@ export function EditTeamsNotifierComponent({ notifier, setNotifier, setUnsaved }
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Power Automate URL</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Power Automate URL</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
size="small"
|
||||
className="w-full"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="https://prod-00.westeurope.logic.azure.com:443/workflows/....."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="HTTP endpoint from your Power Automate flow (When an HTTP request is received)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="HTTP endpoint from your Power Automate flow (When an HTTP request is received)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,31 +27,28 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Bot token</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={notifier?.telegramNotifier?.botToken || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.telegramNotifier) return;
|
||||
setNotifier({
|
||||
...notifier,
|
||||
telegramNotifier: {
|
||||
...notifier.telegramNotifier,
|
||||
botToken: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Bot token</div>
|
||||
<Input
|
||||
value={notifier?.telegramNotifier?.botToken || ''}
|
||||
onChange={(e) => {
|
||||
if (!notifier?.telegramNotifier) return;
|
||||
setNotifier({
|
||||
...notifier,
|
||||
telegramNotifier: {
|
||||
...notifier.telegramNotifier,
|
||||
botToken: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 ml-[130px]">
|
||||
<div className="mb-1 sm:ml-[150px]">
|
||||
<a
|
||||
className="text-xs !text-blue-600"
|
||||
href="https://www.siteguarding.com/en/how-to-get-telegram-bot-api-token"
|
||||
@@ -62,10 +59,9 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Target chat ID</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Target chat ID</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={notifier?.telegramNotifier?.targetChatId || ''}
|
||||
onChange={(e) => {
|
||||
@@ -81,20 +77,20 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="-1001234567890"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The chat where you want to receive the message (it can be your private chat or a group)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The chat where you want to receive the message (it can be your private chat or a group)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-[130px] max-w-[250px]">
|
||||
<div className="max-w-[250px] sm:ml-[150px]">
|
||||
{!isShowHowToGetChatId ? (
|
||||
<div
|
||||
className="mt-1 cursor-pointer text-xs text-blue-600"
|
||||
@@ -120,42 +116,42 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px] break-all">Send to group topic</div>
|
||||
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Send to group topic</div>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={notifier?.telegramNotifier?.isSendToThreadEnabled || false}
|
||||
onChange={(checked) => {
|
||||
if (!notifier?.telegramNotifier) return;
|
||||
|
||||
<Switch
|
||||
checked={notifier?.telegramNotifier?.isSendToThreadEnabled || false}
|
||||
onChange={(checked) => {
|
||||
if (!notifier?.telegramNotifier) return;
|
||||
setNotifier({
|
||||
...notifier,
|
||||
telegramNotifier: {
|
||||
...notifier.telegramNotifier,
|
||||
isSendToThreadEnabled: checked,
|
||||
// Clear thread ID if disabling
|
||||
threadId: checked ? notifier.telegramNotifier.threadId : undefined,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
setNotifier({
|
||||
...notifier,
|
||||
telegramNotifier: {
|
||||
...notifier.telegramNotifier,
|
||||
isSendToThreadEnabled: checked,
|
||||
// Clear thread ID if disabling
|
||||
threadId: checked ? notifier.telegramNotifier.threadId : undefined,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Enable this to send messages to a specific thread in a group chat"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Enable this to send messages to a specific thread in a group chat"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notifier?.telegramNotifier?.isSendToThreadEnabled && (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Thread ID</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Thread ID</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={notifier?.telegramNotifier?.threadId?.toString() || ''}
|
||||
onChange={(e) => {
|
||||
@@ -174,22 +170,22 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="3"
|
||||
type="number"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The ID of the thread where messages should be sent"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The ID of the thread where messages should be sent"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-[130px] max-w-[250px]">
|
||||
<div className="max-w-[250px] sm:ml-[150px]">
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
To get the thread ID, go to the thread in your Telegram group, tap on the thread name
|
||||
at the top, then tap “Thread Info”. Copy the thread link and take the last
|
||||
|
||||
@@ -13,33 +13,29 @@ interface Props {
|
||||
export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Webhook URL</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={notifier?.webhookNotifier?.webhookUrl || ''}
|
||||
onChange={(e) => {
|
||||
setNotifier({
|
||||
...notifier,
|
||||
webhookNotifier: {
|
||||
...(notifier.webhookNotifier || { webhookMethod: WebhookMethod.POST }),
|
||||
webhookUrl: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="https://example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Webhook URL</div>
|
||||
<Input
|
||||
value={notifier?.webhookNotifier?.webhookUrl || ''}
|
||||
onChange={(e) => {
|
||||
setNotifier({
|
||||
...notifier,
|
||||
webhookNotifier: {
|
||||
...(notifier.webhookNotifier || { webhookMethod: WebhookMethod.POST }),
|
||||
webhookUrl: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="https://example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Method</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<div className="mt-1 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[150px] sm:mb-0">Method</div>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={notifier?.webhookNotifier?.webhookMethod || WebhookMethod.POST}
|
||||
onChange={(value) => {
|
||||
@@ -53,20 +49,20 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full"
|
||||
className="w-full max-w-[250px]"
|
||||
options={[
|
||||
{ value: WebhookMethod.POST, label: 'POST' },
|
||||
{ value: WebhookMethod.GET, label: 'GET' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The HTTP method that will be used to call the webhook"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The HTTP method that will be used to call the webhook"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notifier?.webhookNotifier?.webhookUrl && (
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ShowDiscordNotifierComponent({ notifier }: Props) {
|
||||
<div className="flex">
|
||||
<div className="max-w-[110px] min-w-[110px] pr-3">Channel webhook URL</div>
|
||||
|
||||
<div className="w-[250px]">{notifier.webhookNotifier?.webhookUrl.slice(0, 10)}*******</div>
|
||||
<div>{notifier.webhookNotifier?.webhookUrl.slice(0, 10)}*******</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ShowSlackNotifierComponent({ notifier }: Props) {
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-[110px]">Bot token</div>
|
||||
|
||||
<div className="w-[250px]">*********</div>
|
||||
<div>*********</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ShowTeamsNotifierComponent({ notifier }: Props) {
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-[110px]">Power Automate URL: </div>
|
||||
<div className="w-[250px] break-all">
|
||||
<div className="w-[50px] break-all md:w-[250px]">
|
||||
{url ? (
|
||||
<>
|
||||
<span title={url}>{display}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ShowTelegramNotifierComponent({ notifier }: Props) {
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-[110px]">Bot token</div>
|
||||
|
||||
<div className="w-[250px]">*********</div>
|
||||
<div>*********</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { auditLogApi } from '../../../entity/audit-logs/api/auditLogApi';
|
||||
import type { AuditLog } from '../../../entity/audit-logs/model/AuditLog';
|
||||
import type { GetAuditLogsRequest } from '../../../entity/audit-logs/model/GetAuditLogsRequest';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { getUserTimeFormat } from '../../../shared/time';
|
||||
|
||||
interface Props {
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
|
||||
export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Props) {
|
||||
const { message } = App.useApp();
|
||||
const isMobile = useIsMobile();
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
@@ -158,6 +160,49 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
},
|
||||
];
|
||||
|
||||
const renderAuditLogCard = (log: AuditLog) => {
|
||||
const date = dayjs(log.createdAt);
|
||||
const timeFormat = getUserTimeFormat();
|
||||
|
||||
const getUserDisplay = () => {
|
||||
if (!log.userEmail && !log.userName) {
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
|
||||
System
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const displayText = log.userName ? `${log.userName} (${log.userEmail})` : log.userEmail;
|
||||
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={log.id} className="mb-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">{getUserDisplay()}</div>
|
||||
<div className="text-right text-xs text-gray-500">
|
||||
<div>{date.format(timeFormat.format)}</div>
|
||||
<div className="text-gray-400">{date.fromNow()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-900">{log.message}</div>
|
||||
{log.workspaceName && (
|
||||
<div className="mt-2">
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
{log.workspaceName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px]">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
@@ -175,16 +220,24 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
</div>
|
||||
) : auditLogs.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||
No audit logs found.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={auditLogs}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
className="mb-4"
|
||||
/>
|
||||
{isMobile ? (
|
||||
<div>{auditLogs.map(renderAuditLogCard)}</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={auditLogs}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
@@ -195,7 +248,7 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
|
||||
{!hasMore && auditLogs.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
All logs loaded ({total} total)
|
||||
All logs loaded ({auditLogs.length} total)
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -91,14 +91,14 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
console.log(`isCloud = ${IS_CLOUD}`);
|
||||
|
||||
return (
|
||||
<div className="flex grow pl-3">
|
||||
<div className="flex grow sm:pl-5">
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">Postgresus Settings</h1>
|
||||
<h1 className="text-2xl font-bold">Postgresus settings</h1>
|
||||
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
@@ -228,7 +228,7 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
<div className="group relative">
|
||||
<div className="flex items-center rounded-md border border-gray-300 bg-gray-50 px-3 py-2 !font-mono text-sm text-gray-700">
|
||||
<code
|
||||
className="flex-1 cursor-pointer transition-colors select-all hover:text-blue-600"
|
||||
className="flex-1 cursor-pointer break-all transition-colors select-all hover:text-blue-600"
|
||||
onClick={() => {
|
||||
window.open(`${getApplicationServer()}/api/v1/system/health`, '_blank');
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { storageApi } from '../../../entity/storages';
|
||||
import type { Storage } from '../../../entity/storages';
|
||||
import type { WorkspaceResponse } from '../../../entity/workspaces';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { StorageCardComponent } from './StorageCardComponent';
|
||||
import { StorageComponent } from './StorageComponent';
|
||||
import { EditStorageComponent } from './edit/EditStorageComponent';
|
||||
@@ -14,13 +15,25 @@ interface Props {
|
||||
isCanManageStorages: boolean;
|
||||
}
|
||||
|
||||
const SELECTED_STORAGE_STORAGE_KEY = 'selectedStorageId';
|
||||
|
||||
export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorages }: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [storages, setStorages] = useState<Storage[]>([]);
|
||||
|
||||
const [isShowAddStorage, setIsShowAddStorage] = useState(false);
|
||||
const [selectedStorageId, setSelectedStorageId] = useState<string | undefined>(undefined);
|
||||
|
||||
const updateSelectedStorageId = (storageId: string | undefined) => {
|
||||
setSelectedStorageId(storageId);
|
||||
if (storageId) {
|
||||
localStorage.setItem(`${SELECTED_STORAGE_STORAGE_KEY}_${workspace.id}`, storageId);
|
||||
} else {
|
||||
localStorage.removeItem(`${SELECTED_STORAGE_STORAGE_KEY}_${workspace.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStorages = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -28,8 +41,16 @@ export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorage
|
||||
.getStorages(workspace.id)
|
||||
.then((storages: Storage[]) => {
|
||||
setStorages(storages);
|
||||
if (!selectedStorageId) {
|
||||
setSelectedStorageId(storages[0]?.id);
|
||||
if (!selectedStorageId && !isMobile) {
|
||||
// On desktop, auto-select a storage; on mobile, keep it unselected
|
||||
const savedStorageId = localStorage.getItem(
|
||||
`${SELECTED_STORAGE_STORAGE_KEY}_${workspace.id}`,
|
||||
);
|
||||
const storageToSelect =
|
||||
savedStorageId && storages.some((s) => s.id === savedStorageId)
|
||||
? savedStorageId
|
||||
: storages[0]?.id;
|
||||
updateSelectedStorageId(storageToSelect);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => alert(e.message))
|
||||
@@ -54,45 +75,66 @@ export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorage
|
||||
</Button>
|
||||
);
|
||||
|
||||
// On mobile, show either the list or the storage details
|
||||
const showStorageList = !isMobile || !selectedStorageId;
|
||||
const showStorageDetails = selectedStorageId && (!isMobile || selectedStorageId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex grow">
|
||||
<div
|
||||
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
{storages.length >= 5 && isCanManageStorages && addStorageButton}
|
||||
{showStorageList && (
|
||||
<div
|
||||
className="w-full overflow-y-auto md:mx-3 md:w-[250px] md:min-w-[250px] md:pr-2"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
{storages.length >= 5 && isCanManageStorages && addStorageButton}
|
||||
|
||||
{storages.map((storage) => (
|
||||
<StorageCardComponent
|
||||
key={storage.id}
|
||||
storage={storage}
|
||||
selectedStorageId={selectedStorageId}
|
||||
setSelectedStorageId={setSelectedStorageId}
|
||||
/>
|
||||
))}
|
||||
{storages.map((storage) => (
|
||||
<StorageCardComponent
|
||||
key={storage.id}
|
||||
storage={storage}
|
||||
selectedStorageId={selectedStorageId}
|
||||
setSelectedStorageId={updateSelectedStorageId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{storages.length < 5 && isCanManageStorages && addStorageButton}
|
||||
{storages.length < 5 && isCanManageStorages && addStorageButton}
|
||||
|
||||
<div className="mx-3 text-center text-xs text-gray-500">
|
||||
Storage - is a place where backups will be stored (local disk, S3, etc.)
|
||||
<div className="mx-3 text-center text-xs text-gray-500">
|
||||
Storage - is a place where backups will be stored (local disk, S3, etc.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedStorageId && (
|
||||
<StorageComponent
|
||||
storageId={selectedStorageId}
|
||||
onStorageChanged={() => {
|
||||
loadStorages();
|
||||
}}
|
||||
onStorageDeleted={() => {
|
||||
loadStorages();
|
||||
setSelectedStorageId(
|
||||
storages.filter((storage) => storage.id !== selectedStorageId)[0]?.id,
|
||||
);
|
||||
}}
|
||||
isCanManageStorages={isCanManageStorages}
|
||||
/>
|
||||
{showStorageDetails && (
|
||||
<div className="flex w-full flex-col md:flex-1">
|
||||
{isMobile && (
|
||||
<div className="mb-2">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => updateSelectedStorageId(undefined)}
|
||||
className="w-full"
|
||||
>
|
||||
← Back to storages
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StorageComponent
|
||||
storageId={selectedStorageId}
|
||||
onStorageChanged={() => {
|
||||
loadStorages();
|
||||
}}
|
||||
onStorageDeleted={() => {
|
||||
const remainingStorages = storages.filter(
|
||||
(storage) => storage.id !== selectedStorageId,
|
||||
);
|
||||
updateSelectedStorageId(remainingStorages[0]?.id);
|
||||
loadStorages();
|
||||
}}
|
||||
isCanManageStorages={isCanManageStorages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -239,8 +239,8 @@ export function EditStorageComponent({
|
||||
return (
|
||||
<div>
|
||||
{isShowName && (
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Name</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Name</div>
|
||||
|
||||
<Input
|
||||
value={storage?.name || ''}
|
||||
@@ -255,27 +255,29 @@ export function EditStorageComponent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Type</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Type</div>
|
||||
|
||||
<Select
|
||||
value={storage?.type}
|
||||
options={[
|
||||
{ label: 'Local storage', value: StorageType.LOCAL },
|
||||
{ label: 'S3', value: StorageType.S3 },
|
||||
{ label: 'Google Drive', value: StorageType.GOOGLE_DRIVE },
|
||||
{ label: 'NAS', value: StorageType.NAS },
|
||||
{ label: 'Azure Blob Storage', value: StorageType.AZURE_BLOB },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setStorageType(value);
|
||||
setIsUnsaved(true);
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={storage?.type}
|
||||
options={[
|
||||
{ label: 'Local storage', value: StorageType.LOCAL },
|
||||
{ label: 'S3', value: StorageType.S3 },
|
||||
{ label: 'Google Drive', value: StorageType.GOOGLE_DRIVE },
|
||||
{ label: 'NAS', value: StorageType.NAS },
|
||||
{ label: 'Azure Blob Storage', value: StorageType.AZURE_BLOB },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setStorageType(value);
|
||||
setIsUnsaved(true);
|
||||
}}
|
||||
size="small"
|
||||
className="w-[250px] max-w-[250px]"
|
||||
/>
|
||||
|
||||
<img src={getStorageLogoFromType(storage?.type)} className="ml-2 h-4 w-4" />
|
||||
<img src={getStorageLogoFromType(storage?.type)} className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5" />
|
||||
|
||||
@@ -17,8 +17,8 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Auth method</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Auth method</div>
|
||||
<Radio.Group
|
||||
value={storage?.azureBlobStorage?.authMethod || 'ACCOUNT_KEY'}
|
||||
onChange={(e) => {
|
||||
@@ -41,40 +41,42 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
|
||||
</div>
|
||||
|
||||
{storage?.azureBlobStorage?.authMethod === 'CONNECTION_STRING' && (
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Connection</div>
|
||||
<Input.Password
|
||||
value={storage?.azureBlobStorage?.connectionString || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.azureBlobStorage) return;
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Connection</div>
|
||||
<div className="flex items-center">
|
||||
<Input.Password
|
||||
value={storage?.azureBlobStorage?.connectionString || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.azureBlobStorage) return;
|
||||
|
||||
setStorage({
|
||||
...storage,
|
||||
azureBlobStorage: {
|
||||
...storage.azureBlobStorage,
|
||||
connectionString: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="DefaultEndpointsProtocol=https;AccountName=..."
|
||||
/>
|
||||
setStorage({
|
||||
...storage,
|
||||
azureBlobStorage: {
|
||||
...storage.azureBlobStorage,
|
||||
connectionString: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="DefaultEndpointsProtocol=https;AccountName=..."
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Azure Storage connection string from Azure Portal"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Azure Storage connection string from Azure Portal"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storage?.azureBlobStorage?.authMethod === 'ACCOUNT_KEY' && (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Account name</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Account name</div>
|
||||
<Input
|
||||
value={storage?.azureBlobStorage?.accountName || ''}
|
||||
onChange={(e) => {
|
||||
@@ -95,8 +97,8 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Account key</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Account key</div>
|
||||
<Input.Password
|
||||
value={storage?.azureBlobStorage?.accountKey || ''}
|
||||
onChange={(e) => {
|
||||
@@ -119,8 +121,8 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Container name</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Container name</div>
|
||||
<Input
|
||||
value={storage?.azureBlobStorage?.containerName || ''}
|
||||
onChange={(e) => {
|
||||
@@ -159,10 +161,43 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
|
||||
{showAdvanced && (
|
||||
<>
|
||||
{storage?.azureBlobStorage?.authMethod === 'ACCOUNT_KEY' && (
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Endpoint</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Endpoint</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={storage?.azureBlobStorage?.endpoint || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.azureBlobStorage) return;
|
||||
|
||||
setStorage({
|
||||
...storage,
|
||||
azureBlobStorage: {
|
||||
...storage.azureBlobStorage,
|
||||
endpoint: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="https://myaccount.blob.core.windows.net (optional)"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Custom endpoint URL (optional, leave empty for standard Azure)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Blob prefix</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={storage?.azureBlobStorage?.endpoint || ''}
|
||||
value={storage?.azureBlobStorage?.prefix || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.azureBlobStorage) return;
|
||||
|
||||
@@ -170,52 +205,23 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
|
||||
...storage,
|
||||
azureBlobStorage: {
|
||||
...storage.azureBlobStorage,
|
||||
endpoint: e.target.value.trim(),
|
||||
prefix: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="https://myaccount.blob.core.windows.net (optional)"
|
||||
placeholder="my-prefix/ (optional)"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Custom endpoint URL (optional, leave empty for standard Azure)"
|
||||
title="Optional prefix for all blob names (e.g., 'backups/' or 'my_team/')"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Blob prefix</div>
|
||||
<Input
|
||||
value={storage?.azureBlobStorage?.prefix || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.azureBlobStorage) return;
|
||||
|
||||
setStorage({
|
||||
...storage,
|
||||
azureBlobStorage: {
|
||||
...storage.azureBlobStorage,
|
||||
prefix: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="my-prefix/ (optional)"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Optional prefix for all blob names (e.g., 'backups/' or 'my_team/')"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function EditGoogleDriveStorageComponent({ storage, setStorage, setUnsave
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="min-w-[110px]" />
|
||||
<div className="hidden min-w-[110px] sm:block" />
|
||||
|
||||
<div className="text-xs text-blue-600">
|
||||
<a href="https://postgresus.com/storages/google-drive" target="_blank" rel="noreferrer">
|
||||
@@ -46,8 +46,8 @@ export function EditGoogleDriveStorageComponent({ storage, setStorage, setUnsave
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Client ID</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Client ID</div>
|
||||
<Input
|
||||
value={storage?.googleDriveStorage?.clientId || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -69,8 +69,8 @@ export function EditGoogleDriveStorageComponent({ storage, setStorage, setUnsave
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Client Secret</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Client Secret</div>
|
||||
<Input
|
||||
value={storage?.googleDriveStorage?.clientSecret || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -94,8 +94,8 @@ export function EditGoogleDriveStorageComponent({ storage, setStorage, setUnsave
|
||||
|
||||
{storage?.googleDriveStorage?.tokenJson && (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">User Token</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">User Token</div>
|
||||
<Input
|
||||
value={storage?.googleDriveStorage?.tokenJson || ''}
|
||||
disabled
|
||||
|
||||
@@ -12,8 +12,8 @@ interface Props {
|
||||
export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Host</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Host</div>
|
||||
<Input
|
||||
value={storage?.nasStorage?.host || ''}
|
||||
onChange={(e) => {
|
||||
@@ -34,8 +34,8 @@ export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Pro
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Port</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Port</div>
|
||||
<InputNumber
|
||||
value={storage?.nasStorage?.port}
|
||||
onChange={(value) => {
|
||||
@@ -58,8 +58,8 @@ export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Pro
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Share</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Share</div>
|
||||
<Input
|
||||
value={storage?.nasStorage?.share || ''}
|
||||
onChange={(e) => {
|
||||
@@ -80,8 +80,8 @@ export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Pro
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Username</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Username</div>
|
||||
<Input
|
||||
value={storage?.nasStorage?.username || ''}
|
||||
onChange={(e) => {
|
||||
@@ -102,8 +102,8 @@ export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Pro
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Password</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Password</div>
|
||||
<Input.Password
|
||||
value={storage?.nasStorage?.password || ''}
|
||||
onChange={(e) => {
|
||||
@@ -124,89 +124,98 @@ export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Pro
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Use SSL</div>
|
||||
<Switch
|
||||
checked={storage?.nasStorage?.useSsl || false}
|
||||
onChange={(checked) => {
|
||||
if (!storage?.nasStorage) return;
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Use SSL</div>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={storage?.nasStorage?.useSsl || false}
|
||||
onChange={(checked) => {
|
||||
if (!storage?.nasStorage) return;
|
||||
|
||||
setStorage({
|
||||
...storage,
|
||||
nasStorage: {
|
||||
...storage.nasStorage,
|
||||
useSsl: checked,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
setStorage({
|
||||
...storage,
|
||||
nasStorage: {
|
||||
...storage.nasStorage,
|
||||
useSsl: checked,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Tooltip className="cursor-pointer" title="Enable SSL/TLS encryption for secure connection">
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Enable SSL/TLS encryption for secure connection"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Domain</div>
|
||||
<Input
|
||||
value={storage?.nasStorage?.domain || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.nasStorage) return;
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Domain</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={storage?.nasStorage?.domain || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.nasStorage) return;
|
||||
|
||||
setStorage({
|
||||
...storage,
|
||||
nasStorage: {
|
||||
...storage.nasStorage,
|
||||
domain: e.target.value.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="WORKGROUP (optional)"
|
||||
/>
|
||||
setStorage({
|
||||
...storage,
|
||||
nasStorage: {
|
||||
...storage.nasStorage,
|
||||
domain: e.target.value.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="WORKGROUP (optional)"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Windows domain name (optional, leave empty if not using domain authentication)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Windows domain name (optional, leave empty if not using domain authentication)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Path</div>
|
||||
<Input
|
||||
value={storage?.nasStorage?.path || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.nasStorage) return;
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Path</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={storage?.nasStorage?.path || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.nasStorage) return;
|
||||
|
||||
let pathValue = e.target.value.trim();
|
||||
// Remove leading slash if present
|
||||
if (pathValue.startsWith('/')) {
|
||||
pathValue = pathValue.substring(1);
|
||||
}
|
||||
let pathValue = e.target.value.trim();
|
||||
// Remove leading slash if present
|
||||
if (pathValue.startsWith('/')) {
|
||||
pathValue = pathValue.substring(1);
|
||||
}
|
||||
|
||||
setStorage({
|
||||
...storage,
|
||||
nasStorage: {
|
||||
...storage.nasStorage,
|
||||
path: pathValue || undefined,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="backups (optional, no leading slash)"
|
||||
/>
|
||||
setStorage({
|
||||
...storage,
|
||||
nasStorage: {
|
||||
...storage.nasStorage,
|
||||
path: pathValue || undefined,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="backups (optional, no leading slash)"
|
||||
/>
|
||||
|
||||
<Tooltip className="cursor-pointer" title="Subdirectory path within the share (optional)">
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip className="cursor-pointer" title="Subdirectory path within the share (optional)">
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="min-w-[110px]" />
|
||||
<div className="hidden min-w-[110px] sm:block" />
|
||||
|
||||
<div className="text-xs text-blue-600">
|
||||
<a href="https://postgresus.com/storages/cloudflare-r2" target="_blank" rel="noreferrer">
|
||||
@@ -27,8 +27,8 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">S3 Bucket</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">S3 Bucket</div>
|
||||
<Input
|
||||
value={storage?.s3Storage?.s3Bucket || ''}
|
||||
onChange={(e) => {
|
||||
@@ -49,8 +49,8 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Region</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Region</div>
|
||||
<Input
|
||||
value={storage?.s3Storage?.s3Region || ''}
|
||||
onChange={(e) => {
|
||||
@@ -71,8 +71,8 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Access key</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Access key</div>
|
||||
<Input.Password
|
||||
value={storage?.s3Storage?.s3AccessKey || ''}
|
||||
onChange={(e) => {
|
||||
@@ -93,8 +93,8 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Secret key</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Secret key</div>
|
||||
<Input.Password
|
||||
value={storage?.s3Storage?.s3SecretKey || ''}
|
||||
onChange={(e) => {
|
||||
@@ -115,33 +115,35 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Endpoint</div>
|
||||
<Input
|
||||
value={storage?.s3Storage?.s3Endpoint || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.s3Storage) return;
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Endpoint</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={storage?.s3Storage?.s3Endpoint || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.s3Storage) return;
|
||||
|
||||
setStorage({
|
||||
...storage,
|
||||
s3Storage: {
|
||||
...storage.s3Storage,
|
||||
s3Endpoint: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="https://s3.example.com (optional)"
|
||||
/>
|
||||
setStorage({
|
||||
...storage,
|
||||
s3Storage: {
|
||||
...storage.s3Storage,
|
||||
s3Endpoint: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="https://s3.example.com (optional)"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Custom S3-compatible endpoint URL (optional, leave empty for AWS S3)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Custom S3-compatible endpoint URL (optional, leave empty for AWS S3)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 mb-3 flex items-center">
|
||||
@@ -161,62 +163,65 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
|
||||
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Folder prefix</div>
|
||||
<Input
|
||||
value={storage?.s3Storage?.s3Prefix || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.s3Storage) return;
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Folder prefix</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
value={storage?.s3Storage?.s3Prefix || ''}
|
||||
onChange={(e) => {
|
||||
if (!storage?.s3Storage) return;
|
||||
|
||||
setStorage({
|
||||
...storage,
|
||||
s3Storage: {
|
||||
...storage.s3Storage,
|
||||
s3Prefix: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="my-prefix/ (optional)"
|
||||
/>
|
||||
setStorage({
|
||||
...storage,
|
||||
s3Storage: {
|
||||
...storage.s3Storage,
|
||||
s3Prefix: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="my-prefix/ (optional)"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Optional prefix for all object keys (e.g., 'backups/' or 'my_team/'). May not work with some S3-compatible storages."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Optional prefix for all object keys (e.g., 'backups/' or 'my_team/'). May not work with some S3-compatible storages."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Virtual host</div>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
<div className="mb-1 min-w-[110px] sm:mb-0">Virtual host</div>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={storage?.s3Storage?.s3UseVirtualHostedStyle || false}
|
||||
onChange={(e) => {
|
||||
if (!storage?.s3Storage) return;
|
||||
|
||||
<Checkbox
|
||||
checked={storage?.s3Storage?.s3UseVirtualHostedStyle || false}
|
||||
onChange={(e) => {
|
||||
if (!storage?.s3Storage) return;
|
||||
setStorage({
|
||||
...storage,
|
||||
s3Storage: {
|
||||
...storage.s3Storage,
|
||||
s3UseVirtualHostedStyle: e.target.checked,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
>
|
||||
Use virtual-styled domains
|
||||
</Checkbox>
|
||||
|
||||
setStorage({
|
||||
...storage,
|
||||
s3Storage: {
|
||||
...storage.s3Storage,
|
||||
s3UseVirtualHostedStyle: e.target.checked,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
}}
|
||||
>
|
||||
Use virtual-styled domains
|
||||
</Checkbox>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Use virtual-hosted-style URLs (bucket.s3.region.amazonaws.com) instead of path-style (s3.region.amazonaws.com/bucket). May be required if you see COS errors."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Use virtual-hosted-style URLs (bucket.s3.region.amazonaws.com) instead of path-style (s3.region.amazonaws.com/bucket). May be required if you see COS errors."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -197,7 +197,7 @@ export function ProfileComponent({ contentHeight }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex grow pl-3">
|
||||
<div className="flex grow sm:pl-5">
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow"
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ChangeUserRoleRequest } from '../../../entity/users/model/ChangeUs
|
||||
import type { ListUsersRequest } from '../../../entity/users/model/ListUsersRequest';
|
||||
import type { UserProfile } from '../../../entity/users/model/UserProfile';
|
||||
import { UserRole } from '../../../entity/users/model/UserRole';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { getUserTimeFormat } from '../../../shared/time';
|
||||
import { UserAuditLogsSidebarComponent } from './UserAuditLogsSidebarComponent';
|
||||
|
||||
@@ -29,6 +30,7 @@ const getRoleColor = (role: UserRole): string => {
|
||||
|
||||
export function UsersComponent({ contentHeight }: Props) {
|
||||
const { message } = App.useApp();
|
||||
const isMobile = useIsMobile();
|
||||
const [users, setUsers] = useState<UserProfile[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
@@ -290,8 +292,78 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
},
|
||||
];
|
||||
|
||||
const renderUserCard = (user: UserProfile) => {
|
||||
const date = dayjs(user.createdAt);
|
||||
const timeFormat = getUserTimeFormat();
|
||||
|
||||
return (
|
||||
<div key={user.id} className="mb-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900">{user.name}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-gray-500">
|
||||
<div>{date.format(timeFormat.format)}</div>
|
||||
<div className="text-gray-400">{date.fromNow()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Role:</span>
|
||||
<Select
|
||||
value={user.role}
|
||||
onChange={(value) => handleRoleChange(user.id, value)}
|
||||
loading={changingRoleUsers.has(user.id)}
|
||||
disabled={changingRoleUsers.has(user.id)}
|
||||
size="small"
|
||||
className="w-24"
|
||||
style={{
|
||||
color: getRoleColor(user.role),
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
label: <span style={{ color: getRoleColor(UserRole.ADMIN) }}>Admin</span>,
|
||||
value: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
label: <span style={{ color: getRoleColor(UserRole.MEMBER) }}>Member</span>,
|
||||
value: UserRole.MEMBER,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Active:</span>
|
||||
<Switch
|
||||
checked={user.isActive}
|
||||
onChange={() => handleActivationToggle(user.id, user.isActive)}
|
||||
loading={processingUsers.has(user.id)}
|
||||
disabled={processingUsers.has(user.id)}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: user.isActive ? '#155dfc' : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
size="small"
|
||||
onClick={() => handleRowClick(user)}
|
||||
className="w-full"
|
||||
>
|
||||
View audit logs
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex grow pl-3">
|
||||
<div className="flex grow sm:pl-5">
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
@@ -311,7 +383,7 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
allowClear
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
style={{ width: 400 }}
|
||||
style={{ width: isMobile ? '100%' : 400 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -319,16 +391,24 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||
No users found.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
className="mb-4"
|
||||
/>
|
||||
{isMobile ? (
|
||||
<div>{users.map(renderUserCard)}</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
@@ -355,7 +435,7 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
width={900}
|
||||
width={isMobile ? '100%' : 900}
|
||||
onClose={handleDrawerClose}
|
||||
open={isDrawerOpen}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AuditLog } from '../../../entity/audit-logs/model/AuditLog';
|
||||
import { workspaceApi } from '../../../entity/workspaces/api/workspaceApi';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { getUserShortTimeFormat } from '../../../shared/time';
|
||||
|
||||
interface Props {
|
||||
@@ -18,13 +19,14 @@ export function WorkspaceAuditLogsComponent({
|
||||
scrollContainerRef: externalScrollRef,
|
||||
}: Props) {
|
||||
const { message } = App.useApp();
|
||||
const isMobile = useIsMobile();
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const pageSize = 50;
|
||||
const pageSize = 10;
|
||||
|
||||
const internalScrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = externalScrollRef || internalScrollRef;
|
||||
@@ -149,6 +151,42 @@ export function WorkspaceAuditLogsComponent({
|
||||
},
|
||||
];
|
||||
|
||||
const renderAuditLogCard = (log: AuditLog) => {
|
||||
const date = dayjs(log.createdAt);
|
||||
const timeFormat = getUserShortTimeFormat();
|
||||
|
||||
const getUserDisplay = () => {
|
||||
if (!log.userEmail && !log.userName) {
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
|
||||
System
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const displayText = log.userName ? `${log.userName} (${log.userEmail})` : log.userEmail;
|
||||
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={log.id} className="mb-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">{getUserDisplay()}</div>
|
||||
<div className="text-right text-xs text-gray-500">
|
||||
<div>{date.format(timeFormat.format)}</div>
|
||||
<div className="text-gray-400">{date.fromNow()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-900">{log.message}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
@@ -176,14 +214,18 @@ export function WorkspaceAuditLogsComponent({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={auditLogs}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
className="mb-4"
|
||||
/>
|
||||
{isMobile ? (
|
||||
<div>{auditLogs.map(renderAuditLogCard)}</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={auditLogs}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
@@ -194,7 +236,7 @@ export function WorkspaceAuditLogsComponent({
|
||||
|
||||
{!hasMore && auditLogs.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
All logs loaded ({total} total)
|
||||
All logs loaded ({auditLogs.length} total)
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
WorkspaceResponse,
|
||||
} from '../../../entity/workspaces';
|
||||
import { AddMemberStatusEnum, workspaceMembershipApi } from '../../../entity/workspaces';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { StringUtils } from '../../../shared/lib';
|
||||
import { getUserShortTimeFormat } from '../../../shared/time';
|
||||
|
||||
@@ -47,6 +48,7 @@ interface Props {
|
||||
|
||||
export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props) {
|
||||
const { message } = App.useApp();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [members, setMembers] = useState<WorkspaceMemberResponse[]>([]);
|
||||
const [isLoadingMembers, setIsLoadingMembers] = useState(true);
|
||||
@@ -401,17 +403,88 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
},
|
||||
];
|
||||
|
||||
const renderMemberCard = (member: WorkspaceMemberResponse) => {
|
||||
const isCurrentUser = member.userId === user.id || member.email === user.email;
|
||||
const date = dayjs(member.createdAt);
|
||||
const timeFormat = getUserShortTimeFormat();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="mb-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center">
|
||||
<UserOutlined className="mr-2 text-gray-400" />
|
||||
<div>
|
||||
<div className="font-medium">{member.name}</div>
|
||||
<div className="text-xs text-gray-500">{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManageMembers && member.role !== WorkspaceRole.OWNER && !isCurrentUser && (
|
||||
<Popconfirm
|
||||
title="Remove member"
|
||||
description={`Are you sure you want to remove "${member.email}" from this workspace?`}
|
||||
onConfirm={() => handleRemoveMember(member.userId, member.email)}
|
||||
okText="Remove"
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
loading={removingMembers.has(member.userId)}
|
||||
disabled={removingMembers.has(member.userId)}
|
||||
/>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Role</div>
|
||||
{canManageMembers && member.role !== WorkspaceRole.OWNER && !isCurrentUser ? (
|
||||
<Select
|
||||
value={member.role}
|
||||
onChange={(newRole) => handleChangeRole(member.userId, newRole)}
|
||||
loading={changingRoleFor === member.userId && isChangingRole}
|
||||
disabled={changingRoleFor === member.userId && isChangingRole}
|
||||
size="small"
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ label: 'Admin', value: WorkspaceRole.ADMIN },
|
||||
{ label: 'Member', value: WorkspaceRole.MEMBER },
|
||||
{ label: 'Viewer', value: WorkspaceRole.VIEWER },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Tag color={getRoleColor(member.role)}>{getRoleDisplayText(member.role)}</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-gray-500">Joined</div>
|
||||
<div className="text-sm text-gray-600">{date.format(timeFormat.format)}</div>
|
||||
<div className="text-xs text-gray-400">{date.fromNow()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[850px]">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="mb-4 text-xl font-bold text-gray-900">Users</h2>
|
||||
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Users</h2>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:space-x-2">
|
||||
{canTransferOwnership && (
|
||||
<Button
|
||||
icon={<SwapOutlined />}
|
||||
onClick={() => setIsTransferOwnershipModalOpen(true)}
|
||||
disabled={isLoadingMembers || eligibleMembers.length === 0}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Transfer ownership
|
||||
</Button>
|
||||
@@ -422,7 +495,7 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsAddMemberModalOpen(true)}
|
||||
disabled={isLoadingMembers}
|
||||
className="border-blue-600 bg-blue-600 hover:border-blue-700 hover:bg-blue-700"
|
||||
className="w-full border-blue-600 bg-blue-600 hover:border-blue-700 hover:bg-blue-700 md:w-auto"
|
||||
>
|
||||
Add member
|
||||
</Button>
|
||||
@@ -442,23 +515,36 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
: `${members.length} member${members.length !== 1 ? 's' : ''}`}
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={members}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<div className="mb-2">No members found</div>
|
||||
{canManageMembers && (
|
||||
<div className="text-sm">Click "Add member" to get started</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{isMobile ? (
|
||||
members.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<div className="mb-2">No members found</div>
|
||||
{canManageMembers && (
|
||||
<div className="text-sm">Click "Add member" to get started</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>{members.map(renderMemberCard)}</div>
|
||||
)
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={members}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<div className="mb-2">No members found</div>
|
||||
{canManageMembers && (
|
||||
<div className="text-sm">Click "Add member" to get started</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { WorkspaceRole } from '../../../entity/users/model/WorkspaceRole';
|
||||
import { workspaceApi } from '../../../entity/workspaces/api/workspaceApi';
|
||||
import type { Workspace } from '../../../entity/workspaces/model/Workspace';
|
||||
import type { WorkspaceResponse } from '../../../entity/workspaces/model/WorkspaceResponse';
|
||||
import { useIsMobile } from '../../../shared/hooks';
|
||||
import { WorkspaceAuditLogsComponent } from './WorkspaceAuditLogsComponent';
|
||||
import { WorkspaceMembershipComponent } from './WorkspaceMembershipComponent';
|
||||
|
||||
@@ -20,6 +21,7 @@ interface Props {
|
||||
|
||||
export function WorkspaceSettingsComponent({ workspaceResponse, user, contentHeight }: Props) {
|
||||
const { message, modal } = App.useApp();
|
||||
const isMobile = useIsMobile();
|
||||
const [workspace, setWorkspace] = useState<Workspace | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -163,14 +165,14 @@ export function WorkspaceSettingsComponent({ workspaceResponse, user, contentHei
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex grow pl-3">
|
||||
<div className="flex grow sm:pl-2">
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow"
|
||||
className={`grow overflow-y-auto rounded bg-white shadow ${isMobile ? 'p-3' : 'p-5'}`}
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
<h1 className="mb-6 text-2xl font-bold">Workspace Settings</h1>
|
||||
<h1 className="mb-6 text-2xl font-bold">Workspace settings</h1>
|
||||
|
||||
{isLoading || !workspace ? (
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
@@ -240,7 +242,9 @@ export function WorkspaceSettingsComponent({ workspaceResponse, user, contentHei
|
||||
<h2 className="mb-4 text-xl font-bold text-gray-900">Danger Zone</h2>
|
||||
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div
|
||||
className={`flex ${isMobile ? 'flex-col gap-3' : 'items-start justify-between'}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-red-900">Delete this workspace</div>
|
||||
<div className="mt-1 text-sm text-red-700">
|
||||
@@ -249,14 +253,14 @@ export function WorkspaceSettingsComponent({ workspaceResponse, user, contentHei
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-4">
|
||||
<div className={isMobile ? '' : 'ml-4'}>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={handleDeleteWorkspace}
|
||||
disabled={!canEdit || isDeleting || isSaving}
|
||||
loading={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
className={`bg-red-600 hover:bg-red-700 ${isMobile ? 'w-full' : ''}`}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete workspace'}
|
||||
</Button>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const MainScreenComponent = () => {
|
||||
const { message } = App.useApp();
|
||||
const screenHeight = useScreenHeight();
|
||||
const isMobile = useIsMobile();
|
||||
const contentHeight = screenHeight - (isMobile ? 65 : 95);
|
||||
const contentHeight = screenHeight - (isMobile ? 70 : 95);
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<
|
||||
| 'notifiers'
|
||||
@@ -267,12 +267,13 @@ export const MainScreenComponent = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined style={{ fontSize: '20px' }} />}
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="mt-1 ml-auto md:hidden"
|
||||
/>
|
||||
<div className="mt-1 ml-auto md:hidden">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined style={{ fontSize: '20px' }} />}
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-2" style={{ height: contentHeight }}>
|
||||
@@ -298,62 +299,67 @@ export const MainScreenComponent = () => {
|
||||
|
||||
{selectedTab === 'users' && <UsersComponent contentHeight={contentHeight} />}
|
||||
|
||||
<div className="flex-1 md:pl-3">
|
||||
{workspaces.length === 0 &&
|
||||
(selectedTab === 'databases' ||
|
||||
selectedTab === 'storages' ||
|
||||
selectedTab === 'notifiers' ||
|
||||
selectedTab === 'settings') ? (
|
||||
<div
|
||||
className="flex grow items-center justify-center rounded"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleCreateWorkspace}
|
||||
className="border-blue-600 bg-blue-600 hover:border-blue-700 hover:bg-blue-700"
|
||||
>
|
||||
Create workspace
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selectedTab === 'notifiers' && selectedWorkspace && (
|
||||
<NotifiersComponent
|
||||
contentHeight={contentHeight}
|
||||
workspace={selectedWorkspace}
|
||||
isCanManageNotifiers={isCanManageDBs}
|
||||
key={`notifiers-${selectedWorkspace.id}`}
|
||||
/>
|
||||
)}
|
||||
{selectedTab === 'storages' && selectedWorkspace && (
|
||||
<StoragesComponent
|
||||
contentHeight={contentHeight}
|
||||
workspace={selectedWorkspace}
|
||||
isCanManageStorages={isCanManageDBs}
|
||||
key={`storages-${selectedWorkspace.id}`}
|
||||
/>
|
||||
)}
|
||||
{selectedTab === 'databases' && selectedWorkspace && (
|
||||
<DatabasesComponent
|
||||
contentHeight={contentHeight}
|
||||
workspace={selectedWorkspace}
|
||||
isCanManageDBs={isCanManageDBs}
|
||||
key={`databases-${selectedWorkspace.id}`}
|
||||
/>
|
||||
)}
|
||||
{selectedTab === 'settings' && selectedWorkspace && user && (
|
||||
<WorkspaceSettingsComponent
|
||||
workspaceResponse={selectedWorkspace}
|
||||
contentHeight={contentHeight}
|
||||
user={user}
|
||||
key={`settings-${selectedWorkspace.id}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(selectedTab === 'databases' ||
|
||||
selectedTab === 'storages' ||
|
||||
selectedTab === 'notifiers' ||
|
||||
selectedTab === 'settings') && (
|
||||
<>
|
||||
{workspaces.length === 0 ? (
|
||||
<div className="flex-1 md:pl-3">
|
||||
<div
|
||||
className="flex grow items-center justify-center rounded"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleCreateWorkspace}
|
||||
className="border-blue-600 bg-blue-600 hover:border-blue-700 hover:bg-blue-700"
|
||||
>
|
||||
Create workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 md:pl-3">
|
||||
{selectedTab === 'notifiers' && selectedWorkspace && (
|
||||
<NotifiersComponent
|
||||
contentHeight={contentHeight}
|
||||
workspace={selectedWorkspace}
|
||||
isCanManageNotifiers={isCanManageDBs}
|
||||
key={`notifiers-${selectedWorkspace.id}`}
|
||||
/>
|
||||
)}
|
||||
{selectedTab === 'storages' && selectedWorkspace && (
|
||||
<StoragesComponent
|
||||
contentHeight={contentHeight}
|
||||
workspace={selectedWorkspace}
|
||||
isCanManageStorages={isCanManageDBs}
|
||||
key={`storages-${selectedWorkspace.id}`}
|
||||
/>
|
||||
)}
|
||||
{selectedTab === 'databases' && selectedWorkspace && (
|
||||
<DatabasesComponent
|
||||
contentHeight={contentHeight}
|
||||
workspace={selectedWorkspace}
|
||||
isCanManageDBs={isCanManageDBs}
|
||||
key={`databases-${selectedWorkspace.id}`}
|
||||
/>
|
||||
)}
|
||||
{selectedTab === 'settings' && selectedWorkspace && user && (
|
||||
<WorkspaceSettingsComponent
|
||||
workspaceResponse={selectedWorkspace}
|
||||
contentHeight={contentHeight}
|
||||
user={user}
|
||||
key={`settings-${selectedWorkspace.id}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-1 left-2 mb-[0px] hidden text-sm text-gray-400 md:block">
|
||||
v{APP_VERSION}
|
||||
|
||||
Reference in New Issue
Block a user