implemented the user view type and website sharing

pull/1849/head
hsensh 2023-03-28 10:49:20 +03:00
parent 7a3443cd06
commit 66e67c7a3b
19 changed files with 260 additions and 68 deletions

View File

@ -1,18 +1,23 @@
import React, { useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import Icon from 'components/common/Icon';
import Check from 'assets/check.svg';
import styles from './Checkbox.module.css';
function Checkbox({ name, value, label, onChange }) {
function Checkbox({ name, value, label, onChange, valueArray }) {
const ref = useRef();
const [isChecked, setIsChecked] = useState();
const onClick = () => ref.current.click();
useEffect(() => {
setIsChecked((valueArray && valueArray.includes(value)) || (!valueArray && value));
}, [valueArray, value]);
return (
<div className={styles.container}>
<div className={styles.checkbox} onClick={onClick}>
{value && <Icon icon={<Check />} size="small" />}
{isChecked && <Icon icon={<Check />} size="small" />}
</div>
<label className={styles.label} htmlFor={name} onClick={onClick}>
{label}
@ -22,7 +27,8 @@ function Checkbox({ name, value, label, onChange }) {
className={styles.input}
type="checkbox"
name={name}
defaultChecked={value}
value={valueArray ? value : isChecked}
defaultChecked={isChecked}
onChange={onChange}
/>
</div>

View File

@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import { Formik, Form, Field, useFormikContext, useField } from 'formik';
import Button from 'components/common/Button';
import Checkbox from 'components/common/Checkbox';
import FormLayout, {
FormButtons,
FormError,
@ -9,10 +10,13 @@ import FormLayout, {
FormRow,
} from 'components/layout/FormLayout';
import useApi from 'hooks/useApi';
import useFetch from 'hooks/useFetch';
const initialValues = {
username: '',
password: '',
isViewer: false,
websiteIds: [],
};
const validate = ({ id, username, password }) => {
@ -28,6 +32,44 @@ const validate = ({ id, username, password }) => {
return errors;
};
const WebsiteSelect = props => {
const { data } = useFetch(`/websites`);
const [field, meta] = useField(props);
const {
values: { websiteIds },
} = useFormikContext();
return (
<>
{data && data.length > 0 && (
<div
style={{
maxHeight: '20vh',
overflowY: 'auto',
padding: '0 1rem',
margin: '0 20px',
background: 'var(--gray100)',
border: '1px solid var(--gray500)',
borderRadius: '5px',
}}
>
{data.map(item => (
<div key={`websiteIds-${item.id}`}>
<Checkbox
{...field}
value={item.id.toString()}
label={item.name}
valueArray={websiteIds}
/>
</div>
))}
</div>
)}
{!!meta.touched && !!meta.error && <div>{meta.error}</div>}
</>
);
};
export default function AccountEditForm({ values, onSave, onClose }) {
const { post } = useApi();
const [message, setMessage] = useState();
@ -52,7 +94,7 @@ export default function AccountEditForm({ values, onSave, onClose }) {
validate={validate}
onSubmit={handleSubmit}
>
{() => (
{values => (
<Form>
<FormRow>
<label htmlFor="username">
@ -72,6 +114,20 @@ export default function AccountEditForm({ values, onSave, onClose }) {
<FormError name="password" />
</div>
</FormRow>
{!values.values.isAdmin && (
<FormRow>
<label />
<Field name="isViewer">
{({ field }) => (
<Checkbox
{...field}
label={<FormattedMessage id="label.is-viewer" defaultMessage="Viewer" />}
/>
)}
</Field>
</FormRow>
)}
{values.values.isViewer && <WebsiteSelect name="websiteIds" />}
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="label.save" defaultMessage="Save" />

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import Link from 'next/link';
import classNames from 'classnames';
@ -26,6 +26,14 @@ export default function AccountSettings() {
const [saved, setSaved] = useState(0);
const [message, setMessage] = useState();
const { data } = useFetch(`/accounts`, {}, [saved]);
const [formattedData, setFormattedData] = useState([]);
useEffect(() => {
if (data)
setFormattedData(
data.map(d => ({ ...d, websiteIds: d.viewwebsites.map(vw => vw.websiteId.toString()) })),
);
}, [data]);
const Checkmark = ({ isAdmin }) => (isAdmin ? <Icon icon={<Check />} size="medium" /> : null);
@ -103,7 +111,7 @@ export default function AccountSettings() {
<FormattedMessage id="label.add-account" defaultMessage="Add account" />
</Button>
</PageHeader>
<Table columns={columns} rows={data} />
<Table columns={columns} rows={formattedData} />
{editAccount && (
<Modal title={<FormattedMessage id="label.edit-account" defaultMessage="Edit account" />}>
<AccountEditForm

View File

@ -50,36 +50,44 @@ export default function WebsiteSettings() {
onClick={() => setShowUrl(row)}
/>
)}
<Button
icon={<Code />}
size="small"
tooltip={
<FormattedMessage id="message.get-tracking-code" defaultMessage="Get tracking code" />
}
tooltipId={`button-code-${row.websiteUuid}`}
onClick={() => setShowCode(row)}
/>
<Button
icon={<Pen />}
size="small"
tooltip={<FormattedMessage id="label.edit" defaultMessage="Edit" />}
tooltipId={`button-edit-${row.websiteUuid}`}
onClick={() => setEditWebsite(row)}
/>
<Button
icon={<Reset />}
size="small"
tooltip={<FormattedMessage id="label.reset" defaultMessage="Reset" />}
tooltipId={`button-reset-${row.websiteUuid}`}
onClick={() => setResetWebsite(row)}
/>
<Button
icon={<Trash />}
size="small"
tooltip={<FormattedMessage id="label.delete" defaultMessage="Delete" />}
tooltipId={`button-delete-${row.websiteUuid}`}
onClick={() => setDeleteWebsite(row)}
/>
{!user.isViewer && (
<Button
icon={<Code />}
size="small"
tooltip={
<FormattedMessage id="message.get-tracking-code" defaultMessage="Get tracking code" />
}
tooltipId={`button-code-${row.websiteUuid}`}
onClick={() => setShowCode(row)}
/>
)}
{!user.isViewer && (
<Button
icon={<Pen />}
size="small"
tooltip={<FormattedMessage id="label.edit" defaultMessage="Edit" />}
tooltipId={`button-edit-${row.websiteUuid}`}
onClick={() => setEditWebsite(row)}
/>
)}
{!user.isViewer && (
<Button
icon={<Reset />}
size="small"
tooltip={<FormattedMessage id="label.reset" defaultMessage="Reset" />}
tooltipId={`button-reset-${row.websiteUuid}`}
onClick={() => setResetWebsite(row)}
/>
)}
{!user.isViewer && (
<Button
icon={<Trash />}
size="small"
tooltip={<FormattedMessage id="label.delete" defaultMessage="Delete" />}
tooltipId={`button-delete-${row.websiteUuid}`}
onClick={() => setDeleteWebsite(row)}
/>
)}
</ButtonLayout>
);

View File

@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE `account` ADD COLUMN `is_viewer` BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE `event_data` DROP PRIMARY KEY,
MODIFY `event_data_id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
ADD PRIMARY KEY (`event_data_id`);
-- DropTable
DROP TABLE `_event_old`;
-- CreateTable
CREATE TABLE `viewerforwebsite` (
`user_id` INTEGER UNSIGNED NOT NULL,
`website_id` INTEGER UNSIGNED NOT NULL,
PRIMARY KEY (`user_id`, `website_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `viewerforwebsite` ADD CONSTRAINT `viewerforwebsite_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `account`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `viewerforwebsite` ADD CONSTRAINT `viewerforwebsite_website_id_fkey` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -8,14 +8,16 @@ datasource db {
}
model account {
id Int @id @default(autoincrement()) @map("user_id") @db.UnsignedInt
username String @unique() @db.VarChar(255)
password String @db.VarChar(60)
isAdmin Boolean @default(false) @map("is_admin")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamp(0)
accountUuid String @unique() @map("account_uuid") @db.VarChar(36)
website website[]
id Int @id @default(autoincrement()) @map("user_id") @db.UnsignedInt
username String @unique() @db.VarChar(255)
password String @db.VarChar(60)
isAdmin Boolean @default(false) @map("is_admin")
isViewer Boolean @default(false) @map("is_viewer")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamp(0)
accountUuid String @unique() @map("account_uuid") @db.VarChar(36)
website website[]
viewwebsites viewerforwebsite[]
@@index([accountUuid])
}
@ -86,18 +88,29 @@ model session {
}
model website {
id Int @id @default(autoincrement()) @map("website_id") @db.UnsignedInt
websiteUuid String @unique() @map("website_uuid") @db.VarChar(36)
userId Int @map("user_id") @db.UnsignedInt
name String @db.VarChar(100)
domain String? @db.VarChar(500)
shareId String? @unique() @map("share_id") @db.VarChar(64)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
account account @relation(fields: [userId], references: [id])
id Int @id @default(autoincrement()) @map("website_id") @db.UnsignedInt
websiteUuid String @unique() @map("website_uuid") @db.VarChar(36)
userId Int @map("user_id") @db.UnsignedInt
name String @db.VarChar(100)
domain String? @db.VarChar(500)
shareId String? @unique() @map("share_id") @db.VarChar(64)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
account account @relation(fields: [userId], references: [id])
event event[]
pageview pageview[]
session session[]
viewers viewerforwebsite[]
@@index([userId])
@@index([websiteUuid])
}
model viewerforwebsite {
userId Int @map("user_id") @db.UnsignedInt
websiteId Int @map("website_id") @db.UnsignedInt
account account @relation(fields: [userId], references: [id])
website website @relation(fields: [websiteId], references: [id])
@@id([userId, websiteId])
}

View File

@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "account" ADD COLUMN "is_viewer" BOOLEAN NOT NULL DEFAULT false;
-- DropTable
DROP TABLE "_event_old";
-- CreateTable
CREATE TABLE "viewerforwebsite" (
"user_id" INTEGER NOT NULL,
"website_id" INTEGER NOT NULL,
CONSTRAINT "viewerforwebsite_pkey" PRIMARY KEY ("user_id","website_id")
);
-- AddForeignKey
ALTER TABLE "viewerforwebsite" ADD CONSTRAINT "viewerforwebsite_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "account"("user_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "viewerforwebsite" ADD CONSTRAINT "viewerforwebsite_website_id_fkey" FOREIGN KEY ("website_id") REFERENCES "website"("website_id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -12,10 +12,12 @@ model account {
username String @unique @db.VarChar(255)
password String @db.VarChar(60)
isAdmin Boolean @default(false) @map("is_admin")
isViewer Boolean @default(false) @map("is_viewer")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamptz(6)
accountUuid String @unique @map("account_uuid") @db.Uuid
website website[]
viewwebsites viewerforwebsite[]
@@index([accountUuid])
}
@ -97,7 +99,18 @@ model website {
event event[]
pageview pageview[]
session session[]
viewers viewerforwebsite[]
@@index([userId])
@@index([websiteUuid])
}
model viewerforwebsite {
userId Int @map("user_id")
websiteId Int @map("website_id")
account account @relation(fields: [userId], references: [id])
website website @relation(fields: [websiteId], references: [id])
@@id([userId, websiteId])
}

View File

@ -52,7 +52,10 @@ export async function allowQuery(req, type, allowShareToken = true) {
if (type === TYPE_WEBSITE) {
const website = await getWebsite({ websiteUuid: id });
return website && website.userId === userId;
return (
website &&
(website.userId === userId || website.viewers.map(v => v.userId).includes(userId))
);
} else if (type === TYPE_ACCOUNT) {
const account = await getAccount({ accountUuid: id });

View File

@ -19,7 +19,7 @@ export default async (req, res) => {
}
if (req.method === 'POST') {
const { username, password } = req.body;
const { username, password, isViewer, websiteIds } = req.body;
if (id !== userId && !isAdmin) {
return unauthorized(res);
@ -29,6 +29,8 @@ export default async (req, res) => {
const data = {};
data.isViewer = isViewer;
if (password) {
data.password = hashPassword(password);
}
@ -36,6 +38,24 @@ export default async (req, res) => {
// Only admin can change these fields
if (isAdmin) {
data.username = username;
const existingWebsiteIds = websiteIds.map(id => parseInt(id));
const websitesToDisconnect = account.viewwebsites
.map(vw => vw.websiteId)
.filter(id => !existingWebsiteIds.includes(id));
const websitesToConnect = existingWebsiteIds.filter(
id => !account.viewwebsites.map(vw => vw.websiteId).includes(id),
);
data.viewwebsites = {
create: websitesToConnect.map(id => ({ website: { connect: { id } } })),
deleteMany: websitesToDisconnect.map(id => ({
websiteId: id,
userId: account.id,
})),
};
}
// Check when username changes

View File

@ -19,7 +19,7 @@ export default async (req, res) => {
}
if (req.method === 'POST') {
const { username, password, account_uuid } = req.body;
const { username, password, account_uuid, websiteIds, isViewer } = req.body;
const account = await getAccount({ username });
@ -31,6 +31,14 @@ export default async (req, res) => {
username,
password: hashPassword(password),
accountUuid: account_uuid || uuid(),
isViewer,
viewwebsites: {
create: websiteIds
.map(id => parseInt(id))
.map(id => ({
website: { connect: { id } },
})),
},
});
return ok(res, created);

View File

@ -12,8 +12,8 @@ export default async (req, res) => {
const account = await getAccount({ username });
if (account && checkPassword(password, account.password)) {
const { id, username, isAdmin, accountUuid } = account;
const user = { userId: id, username, isAdmin, accountUuid };
const { id, username, isAdmin, isViewer, accountUuid } = account;
const user = { userId: id, username, isAdmin, isViewer, accountUuid };
const token = createSecureToken(user, secret());
return ok(res, { token, user });

View File

@ -14,7 +14,9 @@ export default async (req, res) => {
return unauthorized(res);
}
const websites = await getUserWebsites({ userId });
const websites = await getUserWebsites({
OR: [{ userId }, { viewers: { some: { userId } } }],
});
const ids = websites.map(({ websiteUuid }) => websiteUuid);
const token = createToken({ websites: ids }, secret());
const data = await getRealtimeData(ids, subMinutes(new Date(), 30));

View File

@ -9,6 +9,7 @@ export default async (req, res) => {
await useAuth(req, res);
const { id: websiteUuid } = req.query;
const { accountUuid, isViewer } = req.auth;
if (req.method === 'GET') {
if (!(await allowQuery(req, TYPE_WEBSITE))) {
@ -21,12 +22,11 @@ export default async (req, res) => {
}
if (req.method === 'POST') {
if (!(await allowQuery(req, TYPE_WEBSITE, false))) {
if (!(await allowQuery(req, TYPE_WEBSITE, false)) || isViewer) {
return unauthorized(res);
}
const { name, domain, owner, enableShareUrl, shareId } = req.body;
const { accountUuid } = req.auth;
let account;
@ -62,7 +62,7 @@ export default async (req, res) => {
}
if (req.method === 'DELETE') {
if (!(await allowQuery(req, TYPE_WEBSITE, false))) {
if (!(await allowQuery(req, TYPE_WEBSITE, false)) || isViewer) {
return unauthorized(res);
}

View File

@ -9,9 +9,10 @@ export default async (req, res) => {
await useAuth(req, res);
const { id: websiteId } = req.query;
const { isViewer } = req.auth;
if (req.method === 'POST') {
if (!(await allowQuery(req, TYPE_WEBSITE, false))) {
if (!(await allowQuery(req, TYPE_WEBSITE, false)) || isViewer) {
return unauthorized(res);
}

View File

@ -8,7 +8,8 @@ export default async (req, res) => {
const { user_id, include_all } = req.query;
const { userId: currentUserId, isAdmin } = req.auth;
const { userId: currentUserId, isAdmin, isViewer } = req.auth;
const accountUuid = user_id || req.auth.accountUuid;
let account;
@ -26,7 +27,9 @@ export default async (req, res) => {
const websites =
isAdmin && include_all
? await getAllWebsites()
: await getUserWebsites({ userId: account?.id });
: await getUserWebsites({
OR: [{ userId: account?.id }, { viewers: { some: { userId: account?.id } } }],
});
return ok(res, websites);
}
@ -36,7 +39,7 @@ export default async (req, res) => {
const website_owner = account ? account.id : +owner;
if (website_owner !== currentUserId && !isAdmin) {
if ((website_owner !== currentUserId && !isAdmin) || isViewer) {
return unauthorized(res);
}

View File

@ -3,5 +3,8 @@ import prisma from 'lib/prisma';
export async function getAccount(where) {
return prisma.client.account.findUnique({
where,
include: {
viewwebsites: true,
},
});
}

View File

@ -12,9 +12,11 @@ export async function getAccounts() {
id: true,
username: true,
isAdmin: true,
isViewer: true,
createdAt: true,
updatedAt: true,
accountUuid: true,
viewwebsites: true,
},
});
}

View File

@ -5,6 +5,9 @@ export async function getWebsite(where) {
return prisma.client.website
.findUnique({
where,
include: {
viewers: true,
},
})
.then(async data => {
if (redis.enabled && data) {