Feat/um 197 hook up teams (#1825)

* Link up teams UI.

* Fix auth order.

* PR touchups.
This commit is contained in:
Brian Cao 2023-03-09 12:42:12 -08:00 committed by GitHub
parent f908476e71
commit 8a9532f213
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 500 additions and 111 deletions

View File

@ -46,6 +46,7 @@ export const labels = defineMessages({
deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
reset: { id: 'label.reset', defaultMessage: 'Reset' },
addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
addWebsites: { id: 'label.add-websites', defaultMessage: 'Add websites' },
changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
@ -145,6 +146,10 @@ export const messages = defineMessages({
id: 'message.reset-website',
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: {
id: 'message.invalid-domain',
defaultMessage: 'Invalid domain. Do not include http/https.',
@ -162,6 +167,14 @@ export const messages = defineMessages({
id: 'messages.no-websites',
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.' },
goToSettings: {
id: 'message.go-to-settings',
@ -183,17 +196,6 @@ export const messages = defineMessages({
id: 'message.event-log',
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) {
@ -201,7 +203,3 @@ export function getMessage(id, formatMessage) {
return message ? formatMessage(message) : id;
}
export function getDeviceMessage(device) {
return devices[device] || labels.unknown;
}

View File

@ -1,13 +1,26 @@
import { Loading } from 'react-basics';
import { useIntl } from 'react-intl';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import { labels, messages } from 'components/messages';
import TeamWebsitesTable from 'components/pages/settings/teams/TeamWebsitesTable';
import useApi from 'hooks/useApi';
import WebsitesTable from 'components/pages/settings/websites/WebsitesTable';
import { messages } from 'components/messages';
import {
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 }) {
const { toast, showToast } = useToast();
const { formatMessage } = useIntl();
const { get, useQuery } = useApi();
const { data, isLoading } = useQuery(['teams:websites', teamId], () =>
const { data, isLoading, refetch } = useQuery(['teams:websites', teamId], () =>
get(`/teams/${teamId}/websites`),
);
const hasData = data && data.length !== 0;
@ -16,10 +29,37 @@ export default function TeamWebsites({ teamId }) {
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 (
<div>
{hasData && <WebsitesTable data={data} />}
{!hasData && formatMessage(messages.noData)}
{toast}
{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>
);
}

View 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>
);
}

View File

@ -76,7 +76,10 @@ export default function TeamsList() {
{hasData && <TeamsTable data={data} onDelete={handleDelete} />}
{!hasData && (
<EmptyPlaceholder message={formatMessage(messages.noTeams)}>
<Flexbox gap={10}>
{joinButton}
{createButton}
</Flexbox>
</EmptyPlaceholder>
)}
</Page>

View File

@ -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 {
Button,
Flexbox,
Icon,
Icons,
Modal,
ModalTrigger,
Table,
TableHeader,
TableBody,
TableRow,
TableCell,
TableColumn,
Button,
Icon,
Flexbox,
Icons,
TableHeader,
TableRow,
Text,
ModalTrigger,
Modal,
} from 'react-basics';
import { useIntl } from 'react-intl';
import { labels } from 'components/messages';
import { ROLES } from 'lib/constants';
import TeamDeleteForm from './TeamDeleteForm';
export default function TeamsTable({ data = [], onDelete }) {
const { formatMessage } = useIntl();
const { user } = useUser();
const columns = [
{ name: 'name', label: formatMessage(labels.name), style: { flex: 2 } },
@ -42,10 +44,12 @@ export default function TeamsTable({ data = [], onDelete }) {
<TableBody>
{(row, keys, rowIndex) => {
const { id } = row;
const owner = row.teamUser.find(({ role }) => role === ROLES.teamOwner);
const showDelete = user.id === owner?.userId;
const rowData = {
...row,
owner: row.teamUser.find(({ role }) => role === ROLES.teamOwner)?.user?.username,
owner: owner?.user?.username,
action: (
<Flexbox flex={1} gap={10} justifyContent="end">
<Link href={`/settings/teams/${id}`}>
@ -56,6 +60,7 @@ export default function TeamsTable({ data = [], onDelete }) {
<Text>{formatMessage(labels.edit)}</Text>
</Button>
</Link>
{showDelete && (
<ModalTrigger>
<Button>
<Icon>
@ -74,6 +79,7 @@ export default function TeamsTable({ data = [], onDelete }) {
)}
</Modal>
</ModalTrigger>
)}
</Flexbox>
),
};

View 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>
</>
);
}

View 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>
);
}

View 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;
}

View 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;

View File

@ -19,7 +19,6 @@ model User {
Website Website[]
teamUser TeamUser[]
teamWebsite TeamWebsite[]
@@map("user")
}
@ -86,7 +85,6 @@ model WebsiteEvent {
model Team {
id String @id() @unique() @map("team_id") @db.Uuid
name String @db.VarChar(50)
userId String @map("user_id") @db.Uuid
accessCode String? @unique @map("access_code") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @map("updated_at") @db.Timestamptz(6)
@ -94,7 +92,6 @@ model Team {
teamUser TeamUser[]
teamWebsite TeamWebsite[]
@@index([userId])
@@index([accessCode])
@@map("team")
}
@ -118,16 +115,13 @@ model TeamUser {
model TeamWebsite {
id String @id() @unique() @map("team_website_id") @db.Uuid
teamId String @map("team_id") @db.Uuid
userId String @map("user_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
team Team @relation(fields: [teamId], references: [id])
user User @relation(fields: [userId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([teamId])
@@index([userId])
@@index([websiteId])
@@map("team_website")
}

View File

@ -1,10 +1,11 @@
import debug from 'debug';
import { validate } from 'uuid';
import cache from 'lib/cache';
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
import { secret } from 'lib/crypto';
import { ensureArray, parseSecureToken, parseToken } from 'next-basics';
import { getTeamUser } from 'queries';
import { getTeamWebsite, getTeamWebsiteByTeamMemberId } from 'queries/admin/teamWebsite';
import { validate } from 'uuid';
import { Auth } from './types';
const log = debug('umami:auth');
@ -59,6 +60,12 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
return true;
}
const teamWebsite = await getTeamWebsiteByTeamMemberId(websiteId, user.id);
if (teamWebsite) {
return true;
}
const website = await cache.fetchWebsite(websiteId);
if (website.userId) {
@ -160,6 +167,26 @@ export async function canDeleteTeam({ user }: Auth, teamId: string) {
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) {
return user.isAdmin;
}

View 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);
};

View File

@ -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 { 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 {
id: string;
}
export interface TeamWebsiteRequestBody {
websiteId: string;
teamWebsiteId?: string;
websiteIds?: string[];
}
export default async (
@ -21,6 +21,9 @@ export default async (
await useAuth(req, res);
const { id: teamId } = req.query;
const {
user: { id: userId },
} = req.auth;
if (req.method === 'GET') {
if (!(await canViewTeam(req.auth, teamId))) {
@ -32,5 +35,17 @@ export default async (
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);
};

View File

@ -34,12 +34,14 @@ export default async (
const { name } = req.body;
const team = await createTeam({
const team = await createTeam(
{
id: uuid(),
name,
userId,
accessCode: getRandomChars(16),
});
},
userId,
);
return ok(res, team);
}

View File

@ -6,13 +6,13 @@ import { methodNotAllowed, ok, notFound } from 'next-basics';
import { createTeamUser, getTeam } from 'queries';
import { ROLES } from 'lib/constants';
export interface TeamsRequestBody {
export interface TeamsJoinRequestBody {
accessCode: string;
}
export default async (
req: NextApiRequestQueryBody<any, TeamsRequestBody>,
res: NextApiResponse<Team[] | Team>,
req: NextApiRequestQueryBody<any, TeamsJoinRequestBody>,
res: NextApiResponse<Team>,
) => {
await useAuth(req, res);

View File

@ -18,26 +18,8 @@ export async function getTeams(where: Prisma.TeamWhereInput): Promise<Team[]> {
});
}
export async function getTeamWebsites(teamId: string): Promise<TeamWebsite[]> {
return prisma.client.teamWebsite.findMany({
where: {
teamId,
},
include: {
team: true,
},
orderBy: [
{
team: {
name: 'asc',
},
},
],
} as any);
}
export async function createTeam(data: Prisma.TeamCreateInput): Promise<Team> {
const { id, userId } = data;
export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise<Team> {
const { id } = data;
return prisma.transaction([
prisma.client.team.create({

View File

@ -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 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({
where: {
teamId,
userId,
},
});
}
export async function getTeamWebsites(teamId: string): Promise<TeamWebsite[]> {
return prisma.client.teamWebsite.findMany({
where: {
teamId,
id: teamWebsiteId,
},
include: {
user: true,
website: true,
},
});
}
export async function createTeamWebsite(
userId: string,
teamId: string,
export async function getTeamWebsiteByTeamMemberId(
websiteId: string,
userId: string,
): 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({
data: {
id: uuid(),
userId,
teamId,
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> {
return prisma.client.teamWebsite.delete({
where: {