mirror of
https://github.com/kremalicious/umami.git
synced 2025-02-14 21:10:34 +01:00
Feat/um 197 hook up teams (#1825)
* Link up teams UI. * Fix auth order. * PR touchups.
This commit is contained in:
parent
f908476e71
commit
8a9532f213
@ -46,6 +46,7 @@ export const labels = defineMessages({
|
|||||||
deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
|
deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
|
||||||
reset: { id: 'label.reset', defaultMessage: 'Reset' },
|
reset: { id: 'label.reset', defaultMessage: 'Reset' },
|
||||||
addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
|
addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
|
||||||
|
addWebsites: { id: 'label.add-websites', defaultMessage: 'Add websites' },
|
||||||
changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
|
changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
|
||||||
currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
|
currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
|
||||||
newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
|
newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
|
||||||
@ -145,6 +146,10 @@ export const messages = defineMessages({
|
|||||||
id: 'message.reset-website',
|
id: 'message.reset-website',
|
||||||
defaultMessage: 'To reset this website, type {confirmation} in the box below to confirm.',
|
defaultMessage: 'To reset this website, type {confirmation} in the box below to confirm.',
|
||||||
},
|
},
|
||||||
|
websitesShared: {
|
||||||
|
id: 'message.shared-website',
|
||||||
|
defaultMessage: 'Websites can be viewed by the entire team.',
|
||||||
|
},
|
||||||
invalidDomain: {
|
invalidDomain: {
|
||||||
id: 'message.invalid-domain',
|
id: 'message.invalid-domain',
|
||||||
defaultMessage: 'Invalid domain. Do not include http/https.',
|
defaultMessage: 'Invalid domain. Do not include http/https.',
|
||||||
@ -162,6 +167,14 @@ export const messages = defineMessages({
|
|||||||
id: 'messages.no-websites',
|
id: 'messages.no-websites',
|
||||||
defaultMessage: 'You do not have any websites configured.',
|
defaultMessage: 'You do not have any websites configured.',
|
||||||
},
|
},
|
||||||
|
noTeamWebsites: {
|
||||||
|
id: 'messages.no-team-websites',
|
||||||
|
defaultMessage: 'This team does not have any websites.',
|
||||||
|
},
|
||||||
|
websitesAreShared: {
|
||||||
|
id: 'messages.websites-are-shared',
|
||||||
|
defaultMessage: 'Websites can be viewed by anyone on the team.',
|
||||||
|
},
|
||||||
noMatchPassword: { id: 'message.no-match-password', defaultMessage: 'Passwords do not match.' },
|
noMatchPassword: { id: 'message.no-match-password', defaultMessage: 'Passwords do not match.' },
|
||||||
goToSettings: {
|
goToSettings: {
|
||||||
id: 'message.go-to-settings',
|
id: 'message.go-to-settings',
|
||||||
@ -183,17 +196,6 @@ export const messages = defineMessages({
|
|||||||
id: 'message.event-log',
|
id: 'message.event-log',
|
||||||
defaultMessage: '{event} on {url}',
|
defaultMessage: '{event} on {url}',
|
||||||
},
|
},
|
||||||
newVersionAvailable: {
|
|
||||||
id: 'new-version-available',
|
|
||||||
defaultMessage: 'A new version of Umami {version} is available!',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const devices = defineMessages({
|
|
||||||
desktop: { id: 'metrics.device.desktop', defaultMessage: 'Desktop' },
|
|
||||||
laptop: { id: 'metrics.device.laptop', defaultMessage: 'Laptop' },
|
|
||||||
tablet: { id: 'metrics.device.tablet', defaultMessage: 'Tablet' },
|
|
||||||
mobile: { id: 'metrics.device.mobile', defaultMessage: 'Mobile' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export function getMessage(id, formatMessage) {
|
export function getMessage(id, formatMessage) {
|
||||||
@ -201,7 +203,3 @@ export function getMessage(id, formatMessage) {
|
|||||||
|
|
||||||
return message ? formatMessage(message) : id;
|
return message ? formatMessage(message) : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDeviceMessage(device) {
|
|
||||||
return devices[device] || labels.unknown;
|
|
||||||
}
|
|
||||||
|
@ -1,13 +1,26 @@
|
|||||||
import { Loading } from 'react-basics';
|
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
||||||
import { useIntl } from 'react-intl';
|
import { labels, messages } from 'components/messages';
|
||||||
|
import TeamWebsitesTable from 'components/pages/settings/teams/TeamWebsitesTable';
|
||||||
import useApi from 'hooks/useApi';
|
import useApi from 'hooks/useApi';
|
||||||
import WebsitesTable from 'components/pages/settings/websites/WebsitesTable';
|
import {
|
||||||
import { messages } from 'components/messages';
|
ActionForm,
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Loading,
|
||||||
|
Modal,
|
||||||
|
ModalTrigger,
|
||||||
|
Text,
|
||||||
|
useToast,
|
||||||
|
} from 'react-basics';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import WebsiteAddTeamForm from 'components/pages/settings/teams/WebsiteAddTeamForm';
|
||||||
|
|
||||||
export default function TeamWebsites({ teamId }) {
|
export default function TeamWebsites({ teamId }) {
|
||||||
|
const { toast, showToast } = useToast();
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { data, isLoading } = useQuery(['teams:websites', teamId], () =>
|
const { data, isLoading, refetch } = useQuery(['teams:websites', teamId], () =>
|
||||||
get(`/teams/${teamId}/websites`),
|
get(`/teams/${teamId}/websites`),
|
||||||
);
|
);
|
||||||
const hasData = data && data.length !== 0;
|
const hasData = data && data.length !== 0;
|
||||||
@ -16,10 +29,37 @@ export default function TeamWebsites({ teamId }) {
|
|||||||
return <Loading icon="dots" position="block" />;
|
return <Loading icon="dots" position="block" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await refetch();
|
||||||
|
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addButton = (
|
||||||
|
<ModalTrigger>
|
||||||
|
<Button variant="primary">
|
||||||
|
<Icon>
|
||||||
|
<Icons.Plus />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.addWebsite)}</Text>
|
||||||
|
</Button>
|
||||||
|
<Modal title={formatMessage(labels.addWebsite)}>
|
||||||
|
{close => <WebsiteAddTeamForm teamId={teamId} onSave={handleSave} onClose={close} />}
|
||||||
|
</Modal>
|
||||||
|
</ModalTrigger>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{hasData && <WebsitesTable data={data} />}
|
{toast}
|
||||||
{!hasData && formatMessage(messages.noData)}
|
{hasData && (
|
||||||
|
<ActionForm description={formatMessage(messages.websitesAreShared)}>{addButton}</ActionForm>
|
||||||
|
)}
|
||||||
|
{hasData && <TeamWebsitesTable teamId={teamId} data={data} onSave={handleSave} />}
|
||||||
|
{!hasData && (
|
||||||
|
<EmptyPlaceholder message={formatMessage(messages.noTeamWebsites)}>
|
||||||
|
{addButton}
|
||||||
|
</EmptyPlaceholder>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
103
components/pages/settings/teams/TeamWebsitesTable.js
Normal file
103
components/pages/settings/teams/TeamWebsitesTable.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Flexbox,
|
||||||
|
} from 'react-basics';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { labels } from 'components/messages';
|
||||||
|
import useUser from 'hooks/useUser';
|
||||||
|
import useApi from 'hooks/useApi';
|
||||||
|
|
||||||
|
export default function TeamWebsitesTable({ teamId, data = [], onSave }) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { user } = useUser();
|
||||||
|
const { del, useMutation } = useApi();
|
||||||
|
const { mutate } = useMutation(data => del(`/teamWebsites/${data.teamWebsiteId}`));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'name', label: formatMessage(labels.name), style: { flex: 2 } },
|
||||||
|
{ name: 'domain', label: formatMessage(labels.domain) },
|
||||||
|
{ name: 'action', label: ' ' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleRemoveWebsite = teamWebsiteId => {
|
||||||
|
mutate(
|
||||||
|
{ teamWebsiteId },
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
onSave();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table columns={columns} rows={data}>
|
||||||
|
<TableHeader>
|
||||||
|
{(column, index) => {
|
||||||
|
return (
|
||||||
|
<TableColumn key={index} style={{ ...column.style }}>
|
||||||
|
{column.label}
|
||||||
|
</TableColumn>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(row, keys, rowIndex) => {
|
||||||
|
const { id: teamWebsiteId } = row;
|
||||||
|
const { id: websiteId, name, domain, userId } = row.website;
|
||||||
|
const { teamUser } = row.team;
|
||||||
|
const owner = teamUser[0];
|
||||||
|
const canRemove = user.id === userId || user.id === owner.userId;
|
||||||
|
|
||||||
|
row.name = name;
|
||||||
|
row.domain = domain;
|
||||||
|
|
||||||
|
row.action = (
|
||||||
|
<Flexbox flex={1} justifyContent="end" gap={10}>
|
||||||
|
<Link href={`/websites/${websiteId}`} target="_blank">
|
||||||
|
<Button>
|
||||||
|
<Icon>
|
||||||
|
<Icons.External />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.view)}</Text>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{canRemove && (
|
||||||
|
<Button onClick={() => handleRemoveWebsite(teamWebsiteId)}>
|
||||||
|
<Icon>
|
||||||
|
<Icons.Trash />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.remove)}</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={rowIndex} data={row} keys={keys}>
|
||||||
|
{(data, key, colIndex) => {
|
||||||
|
return (
|
||||||
|
<TableCell key={colIndex} style={{ ...columns[colIndex]?.style }}>
|
||||||
|
<Flexbox flex={1} alignItems="center">
|
||||||
|
{data[key]}
|
||||||
|
</Flexbox>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
@ -76,7 +76,10 @@ export default function TeamsList() {
|
|||||||
{hasData && <TeamsTable data={data} onDelete={handleDelete} />}
|
{hasData && <TeamsTable data={data} onDelete={handleDelete} />}
|
||||||
{!hasData && (
|
{!hasData && (
|
||||||
<EmptyPlaceholder message={formatMessage(messages.noTeams)}>
|
<EmptyPlaceholder message={formatMessage(messages.noTeams)}>
|
||||||
|
<Flexbox gap={10}>
|
||||||
|
{joinButton}
|
||||||
{createButton}
|
{createButton}
|
||||||
|
</Flexbox>
|
||||||
</EmptyPlaceholder>
|
</EmptyPlaceholder>
|
||||||
)}
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
|
@ -1,26 +1,28 @@
|
|||||||
|
import { labels } from 'components/messages';
|
||||||
|
import useUser from 'hooks/useUser';
|
||||||
|
import { ROLES } from 'lib/constants';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
|
Flexbox,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Modal,
|
||||||
|
ModalTrigger,
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
|
||||||
TableBody,
|
TableBody,
|
||||||
TableRow,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
TableColumn,
|
TableColumn,
|
||||||
Button,
|
TableHeader,
|
||||||
Icon,
|
TableRow,
|
||||||
Flexbox,
|
|
||||||
Icons,
|
|
||||||
Text,
|
Text,
|
||||||
ModalTrigger,
|
|
||||||
Modal,
|
|
||||||
} from 'react-basics';
|
} from 'react-basics';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { labels } from 'components/messages';
|
|
||||||
import { ROLES } from 'lib/constants';
|
|
||||||
import TeamDeleteForm from './TeamDeleteForm';
|
import TeamDeleteForm from './TeamDeleteForm';
|
||||||
|
|
||||||
export default function TeamsTable({ data = [], onDelete }) {
|
export default function TeamsTable({ data = [], onDelete }) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ name: 'name', label: formatMessage(labels.name), style: { flex: 2 } },
|
{ name: 'name', label: formatMessage(labels.name), style: { flex: 2 } },
|
||||||
@ -42,10 +44,12 @@ export default function TeamsTable({ data = [], onDelete }) {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{(row, keys, rowIndex) => {
|
{(row, keys, rowIndex) => {
|
||||||
const { id } = row;
|
const { id } = row;
|
||||||
|
const owner = row.teamUser.find(({ role }) => role === ROLES.teamOwner);
|
||||||
|
const showDelete = user.id === owner?.userId;
|
||||||
|
|
||||||
const rowData = {
|
const rowData = {
|
||||||
...row,
|
...row,
|
||||||
owner: row.teamUser.find(({ role }) => role === ROLES.teamOwner)?.user?.username,
|
owner: owner?.user?.username,
|
||||||
action: (
|
action: (
|
||||||
<Flexbox flex={1} gap={10} justifyContent="end">
|
<Flexbox flex={1} gap={10} justifyContent="end">
|
||||||
<Link href={`/settings/teams/${id}`}>
|
<Link href={`/settings/teams/${id}`}>
|
||||||
@ -56,6 +60,7 @@ export default function TeamsTable({ data = [], onDelete }) {
|
|||||||
<Text>{formatMessage(labels.edit)}</Text>
|
<Text>{formatMessage(labels.edit)}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{showDelete && (
|
||||||
<ModalTrigger>
|
<ModalTrigger>
|
||||||
<Button>
|
<Button>
|
||||||
<Icon>
|
<Icon>
|
||||||
@ -74,6 +79,7 @@ export default function TeamsTable({ data = [], onDelete }) {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</ModalTrigger>
|
</ModalTrigger>
|
||||||
|
)}
|
||||||
</Flexbox>
|
</Flexbox>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
62
components/pages/settings/teams/WebsiteAddTeamForm.js
Normal file
62
components/pages/settings/teams/WebsiteAddTeamForm.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { labels } from 'components/messages';
|
||||||
|
import useApi from 'hooks/useApi';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Button, Dropdown, Form, FormButtons, FormRow, Item, SubmitButton } from 'react-basics';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import WebsiteTags from './WebsiteTags';
|
||||||
|
|
||||||
|
export default function WebsiteAddTeamForm({ teamId, onSave, onClose }) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { get, post, useQuery, useMutation } = useApi();
|
||||||
|
const { mutate, error } = useMutation(data => post(`/teams/${teamId}/websites`, data));
|
||||||
|
const { data: websites } = useQuery(['websites'], () => get('/websites'));
|
||||||
|
const [newWebsites, setNewWebsites] = useState([]);
|
||||||
|
const formRef = useRef();
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
mutate(
|
||||||
|
{ websiteIds: newWebsites },
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
onSave();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddWebsite = value => {
|
||||||
|
if (!newWebsites.some(a => a === value)) {
|
||||||
|
const nextValue = [...newWebsites];
|
||||||
|
|
||||||
|
nextValue.push(value);
|
||||||
|
|
||||||
|
setNewWebsites(nextValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveWebsite = value => {
|
||||||
|
const newValue = newWebsites.filter(a => a !== value);
|
||||||
|
|
||||||
|
setNewWebsites(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form onSubmit={handleSubmit} error={error} ref={formRef}>
|
||||||
|
<FormRow label={formatMessage(labels.websites)}>
|
||||||
|
<Dropdown items={websites} onChange={handleAddWebsite}>
|
||||||
|
{({ id, name }) => <Item key={id}>{name}</Item>}
|
||||||
|
</Dropdown>
|
||||||
|
</FormRow>
|
||||||
|
<WebsiteTags items={websites} websites={newWebsites} onClick={handleRemoveWebsite} />
|
||||||
|
<FormButtons flex>
|
||||||
|
<SubmitButton disabled={newWebsites && newWebsites.length === 0}>
|
||||||
|
{formatMessage(labels.addWebsites)}
|
||||||
|
</SubmitButton>
|
||||||
|
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||||
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
29
components/pages/settings/teams/WebsiteTags.js
Normal file
29
components/pages/settings/teams/WebsiteTags.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Button, Icon, Icons, Text } from 'react-basics';
|
||||||
|
import styles from './WebsiteTags.module.css';
|
||||||
|
|
||||||
|
export default function WebsiteTags({ items = [], websites = [], onClick }) {
|
||||||
|
if (websites.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.filters}>
|
||||||
|
{websites.map(websiteId => {
|
||||||
|
const website = items.find(a => a.id === websiteId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={websiteId} className={styles.tag}>
|
||||||
|
<Button onClick={() => onClick(websiteId)} variant="primary" size="sm">
|
||||||
|
<Text>
|
||||||
|
<b>{`${website.name}`}</b>
|
||||||
|
</Text>
|
||||||
|
<Icon>
|
||||||
|
<Icons.Close />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
11
components/pages/settings/teams/WebsiteTags.module.css
Normal file
11
components/pages/settings/teams/WebsiteTags.module.css
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
19
db/postgresql/migrations/07_remove_user_id/migration.sql
Normal file
19
db/postgresql/migrations/07_remove_user_id/migration.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `user_id` on the `team` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `user_id` on the `team_website` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "team_user_id_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "team_website_user_id_idx";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "team" DROP COLUMN "user_id";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "team_website" DROP COLUMN "user_id",
|
||||||
|
ADD COLUMN "userId" UUID;
|
@ -19,7 +19,6 @@ model User {
|
|||||||
|
|
||||||
Website Website[]
|
Website Website[]
|
||||||
teamUser TeamUser[]
|
teamUser TeamUser[]
|
||||||
teamWebsite TeamWebsite[]
|
|
||||||
|
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
@ -86,7 +85,6 @@ model WebsiteEvent {
|
|||||||
model Team {
|
model Team {
|
||||||
id String @id() @unique() @map("team_id") @db.Uuid
|
id String @id() @unique() @map("team_id") @db.Uuid
|
||||||
name String @db.VarChar(50)
|
name String @db.VarChar(50)
|
||||||
userId String @map("user_id") @db.Uuid
|
|
||||||
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime? @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime? @map("updated_at") @db.Timestamptz(6)
|
||||||
@ -94,7 +92,6 @@ model Team {
|
|||||||
teamUser TeamUser[]
|
teamUser TeamUser[]
|
||||||
teamWebsite TeamWebsite[]
|
teamWebsite TeamWebsite[]
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@index([accessCode])
|
@@index([accessCode])
|
||||||
@@map("team")
|
@@map("team")
|
||||||
}
|
}
|
||||||
@ -118,16 +115,13 @@ model TeamUser {
|
|||||||
model TeamWebsite {
|
model TeamWebsite {
|
||||||
id String @id() @unique() @map("team_website_id") @db.Uuid
|
id String @id() @unique() @map("team_website_id") @db.Uuid
|
||||||
teamId String @map("team_id") @db.Uuid
|
teamId String @map("team_id") @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id])
|
team Team @relation(fields: [teamId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
website Website @relation(fields: [websiteId], references: [id])
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
|
||||||
@@index([teamId])
|
@@index([teamId])
|
||||||
@@index([userId])
|
|
||||||
@@index([websiteId])
|
@@index([websiteId])
|
||||||
@@map("team_website")
|
@@map("team_website")
|
||||||
}
|
}
|
||||||
|
29
lib/auth.ts
29
lib/auth.ts
@ -1,10 +1,11 @@
|
|||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { validate } from 'uuid';
|
|
||||||
import cache from 'lib/cache';
|
import cache from 'lib/cache';
|
||||||
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
|
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
|
||||||
import { secret } from 'lib/crypto';
|
import { secret } from 'lib/crypto';
|
||||||
import { ensureArray, parseSecureToken, parseToken } from 'next-basics';
|
import { ensureArray, parseSecureToken, parseToken } from 'next-basics';
|
||||||
import { getTeamUser } from 'queries';
|
import { getTeamUser } from 'queries';
|
||||||
|
import { getTeamWebsite, getTeamWebsiteByTeamMemberId } from 'queries/admin/teamWebsite';
|
||||||
|
import { validate } from 'uuid';
|
||||||
import { Auth } from './types';
|
import { Auth } from './types';
|
||||||
|
|
||||||
const log = debug('umami:auth');
|
const log = debug('umami:auth');
|
||||||
@ -59,6 +60,12 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const teamWebsite = await getTeamWebsiteByTeamMemberId(websiteId, user.id);
|
||||||
|
|
||||||
|
if (teamWebsite) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const website = await cache.fetchWebsite(websiteId);
|
const website = await cache.fetchWebsite(websiteId);
|
||||||
|
|
||||||
if (website.userId) {
|
if (website.userId) {
|
||||||
@ -160,6 +167,26 @@ export async function canDeleteTeam({ user }: Auth, teamId: string) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function canDeleteTeamWebsite({ user }: Auth, teamWebsiteId: string) {
|
||||||
|
if (user.isAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validate(teamWebsiteId)) {
|
||||||
|
const teamWebsite = await getTeamWebsite(teamWebsiteId);
|
||||||
|
|
||||||
|
if (teamWebsite.website.userId === user.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamUser = await getTeamUser(teamWebsite.teamId, user.id);
|
||||||
|
|
||||||
|
return hasPermission(teamUser.role, PERMISSIONS.teamDelete);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function canCreateUser({ user }: Auth) {
|
export async function canCreateUser({ user }: Auth) {
|
||||||
return user.isAdmin;
|
return user.isAdmin;
|
||||||
}
|
}
|
||||||
|
31
pages/api/teamWebsites/[id].ts
Normal file
31
pages/api/teamWebsites/[id].ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { canDeleteTeamWebsite } from 'lib/auth';
|
||||||
|
import { useAuth } from 'lib/middleware';
|
||||||
|
import { NextApiRequestQueryBody } from 'lib/types';
|
||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
|
import { deleteTeamWebsite } from 'queries/admin/teamWebsite';
|
||||||
|
|
||||||
|
export interface TeamWebsiteRequestQuery {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
req: NextApiRequestQueryBody<TeamWebsiteRequestQuery>,
|
||||||
|
res: NextApiResponse,
|
||||||
|
) => {
|
||||||
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
const { id: teamWebsiteId } = req.query;
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
if (!(await canDeleteTeamWebsite(req.auth, teamWebsiteId))) {
|
||||||
|
return unauthorized(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const websites = await deleteTeamWebsite(teamWebsiteId);
|
||||||
|
|
||||||
|
return ok(res, websites);
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodNotAllowed(res);
|
||||||
|
};
|
@ -1,17 +1,17 @@
|
|||||||
import { NextApiResponse } from 'next';
|
|
||||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
|
||||||
import { NextApiRequestQueryBody } from 'lib/types';
|
|
||||||
import { canViewTeam } from 'lib/auth';
|
import { canViewTeam } from 'lib/auth';
|
||||||
import { useAuth } from 'lib/middleware';
|
import { useAuth } from 'lib/middleware';
|
||||||
import { getTeamWebsites } from 'queries/admin/team';
|
import { NextApiRequestQueryBody } from 'lib/types';
|
||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
|
import { createTeamWebsites, getTeamWebsites } from 'queries/admin/teamWebsite';
|
||||||
|
|
||||||
export interface TeamWebsiteRequestQuery {
|
export interface TeamWebsiteRequestQuery {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamWebsiteRequestBody {
|
export interface TeamWebsiteRequestBody {
|
||||||
websiteId: string;
|
|
||||||
teamWebsiteId?: string;
|
teamWebsiteId?: string;
|
||||||
|
websiteIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async (
|
export default async (
|
||||||
@ -21,6 +21,9 @@ export default async (
|
|||||||
await useAuth(req, res);
|
await useAuth(req, res);
|
||||||
|
|
||||||
const { id: teamId } = req.query;
|
const { id: teamId } = req.query;
|
||||||
|
const {
|
||||||
|
user: { id: userId },
|
||||||
|
} = req.auth;
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
if (!(await canViewTeam(req.auth, teamId))) {
|
if (!(await canViewTeam(req.auth, teamId))) {
|
||||||
@ -32,5 +35,17 @@ export default async (
|
|||||||
return ok(res, websites);
|
return ok(res, websites);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
if (!(await canViewTeam(req.auth, teamId))) {
|
||||||
|
return unauthorized(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { websiteIds } = req.body;
|
||||||
|
|
||||||
|
const websites = await createTeamWebsites(teamId, websiteIds);
|
||||||
|
|
||||||
|
return ok(res, websites);
|
||||||
|
}
|
||||||
|
|
||||||
return methodNotAllowed(res);
|
return methodNotAllowed(res);
|
||||||
};
|
};
|
||||||
|
@ -34,12 +34,14 @@ export default async (
|
|||||||
|
|
||||||
const { name } = req.body;
|
const { name } = req.body;
|
||||||
|
|
||||||
const team = await createTeam({
|
const team = await createTeam(
|
||||||
|
{
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
name,
|
name,
|
||||||
userId,
|
|
||||||
accessCode: getRandomChars(16),
|
accessCode: getRandomChars(16),
|
||||||
});
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
return ok(res, team);
|
return ok(res, team);
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,13 @@ import { methodNotAllowed, ok, notFound } from 'next-basics';
|
|||||||
import { createTeamUser, getTeam } from 'queries';
|
import { createTeamUser, getTeam } from 'queries';
|
||||||
import { ROLES } from 'lib/constants';
|
import { ROLES } from 'lib/constants';
|
||||||
|
|
||||||
export interface TeamsRequestBody {
|
export interface TeamsJoinRequestBody {
|
||||||
accessCode: string;
|
accessCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async (
|
export default async (
|
||||||
req: NextApiRequestQueryBody<any, TeamsRequestBody>,
|
req: NextApiRequestQueryBody<any, TeamsJoinRequestBody>,
|
||||||
res: NextApiResponse<Team[] | Team>,
|
res: NextApiResponse<Team>,
|
||||||
) => {
|
) => {
|
||||||
await useAuth(req, res);
|
await useAuth(req, res);
|
||||||
|
|
||||||
|
@ -18,26 +18,8 @@ export async function getTeams(where: Prisma.TeamWhereInput): Promise<Team[]> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTeamWebsites(teamId: string): Promise<TeamWebsite[]> {
|
export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise<Team> {
|
||||||
return prisma.client.teamWebsite.findMany({
|
const { id } = data;
|
||||||
where: {
|
|
||||||
teamId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
team: true,
|
|
||||||
},
|
|
||||||
orderBy: [
|
|
||||||
{
|
|
||||||
team: {
|
|
||||||
name: 'asc',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTeam(data: Prisma.TeamCreateInput): Promise<Team> {
|
|
||||||
const { id, userId } = data;
|
|
||||||
|
|
||||||
return prisma.transaction([
|
return prisma.transaction([
|
||||||
prisma.client.team.create({
|
prisma.client.team.create({
|
||||||
|
@ -1,43 +1,110 @@
|
|||||||
import { TeamWebsite } from '@prisma/client';
|
import { TeamWebsite, Prisma, Website, Team, User } from '@prisma/client';
|
||||||
|
import { ROLES } from 'lib/constants';
|
||||||
import { uuid } from 'lib/crypto';
|
import { uuid } from 'lib/crypto';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
|
|
||||||
export async function getTeamWebsite(teamId: string, userId: string): Promise<TeamWebsite> {
|
export async function getTeamWebsite(teamWebsiteId: string): Promise<
|
||||||
|
TeamWebsite & {
|
||||||
|
website: Website;
|
||||||
|
}
|
||||||
|
> {
|
||||||
return prisma.client.teamWebsite.findFirst({
|
return prisma.client.teamWebsite.findFirst({
|
||||||
where: {
|
where: {
|
||||||
teamId,
|
id: teamWebsiteId,
|
||||||
userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTeamWebsites(teamId: string): Promise<TeamWebsite[]> {
|
|
||||||
return prisma.client.teamWebsite.findMany({
|
|
||||||
where: {
|
|
||||||
teamId,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
|
||||||
website: true,
|
website: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTeamWebsite(
|
export async function getTeamWebsiteByTeamMemberId(
|
||||||
userId: string,
|
|
||||||
teamId: string,
|
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
|
userId: string,
|
||||||
): Promise<TeamWebsite> {
|
): Promise<TeamWebsite> {
|
||||||
|
return prisma.client.teamWebsite.findFirst({
|
||||||
|
where: {
|
||||||
|
websiteId,
|
||||||
|
team: {
|
||||||
|
teamUser: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamWebsites(teamId: string): Promise<
|
||||||
|
(TeamWebsite & {
|
||||||
|
team: Team;
|
||||||
|
website: Website & {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
})[]
|
||||||
|
> {
|
||||||
|
return prisma.client.teamWebsite.findMany({
|
||||||
|
where: {
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team: {
|
||||||
|
include: {
|
||||||
|
teamUser: {
|
||||||
|
where: {
|
||||||
|
role: ROLES.teamOwner,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
website: {
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
name: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTeamWebsite(teamId: string, websiteId: string): Promise<TeamWebsite> {
|
||||||
return prisma.client.teamWebsite.create({
|
return prisma.client.teamWebsite.create({
|
||||||
data: {
|
data: {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
userId,
|
|
||||||
teamId,
|
teamId,
|
||||||
websiteId,
|
websiteId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createTeamWebsites(teamId: string, websiteIds: string[]) {
|
||||||
|
const currentTeamWebsites = await getTeamWebsites(teamId);
|
||||||
|
|
||||||
|
// filter out websites that already exists on the team
|
||||||
|
const addWebsites = websiteIds.filter(
|
||||||
|
websiteId => !currentTeamWebsites.some(a => a.websiteId === websiteId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const teamWebsites: Prisma.TeamWebsiteCreateManyInput[] = addWebsites.map(a => {
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
teamId,
|
||||||
|
websiteId: a,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return prisma.client.teamWebsite.createMany({
|
||||||
|
data: teamWebsites,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteTeamWebsite(teamWebsiteId: string): Promise<TeamWebsite> {
|
export async function deleteTeamWebsite(teamWebsiteId: string): Promise<TeamWebsite> {
|
||||||
return prisma.client.teamWebsite.delete({
|
return prisma.client.teamWebsite.delete({
|
||||||
where: {
|
where: {
|
||||||
|
Loading…
Reference in New Issue
Block a user