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

View File

@ -1,7 +1,8 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { FormattedMessage } from 'react-intl'; 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 Button from 'components/common/Button';
import Checkbox from 'components/common/Checkbox';
import FormLayout, { import FormLayout, {
FormButtons, FormButtons,
FormError, FormError,
@ -9,10 +10,13 @@ import FormLayout, {
FormRow, FormRow,
} from 'components/layout/FormLayout'; } from 'components/layout/FormLayout';
import useApi from 'hooks/useApi'; import useApi from 'hooks/useApi';
import useFetch from 'hooks/useFetch';
const initialValues = { const initialValues = {
username: '', username: '',
password: '', password: '',
isViewer: false,
websiteIds: [],
}; };
const validate = ({ id, username, password }) => { const validate = ({ id, username, password }) => {
@ -28,6 +32,44 @@ const validate = ({ id, username, password }) => {
return errors; 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 }) { export default function AccountEditForm({ values, onSave, onClose }) {
const { post } = useApi(); const { post } = useApi();
const [message, setMessage] = useState(); const [message, setMessage] = useState();
@ -52,7 +94,7 @@ export default function AccountEditForm({ values, onSave, onClose }) {
validate={validate} validate={validate}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
{() => ( {values => (
<Form> <Form>
<FormRow> <FormRow>
<label htmlFor="username"> <label htmlFor="username">
@ -72,6 +114,20 @@ export default function AccountEditForm({ values, onSave, onClose }) {
<FormError name="password" /> <FormError name="password" />
</div> </div>
</FormRow> </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> <FormButtons>
<Button type="submit" variant="action"> <Button type="submit" variant="action">
<FormattedMessage id="label.save" defaultMessage="Save" /> <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 { FormattedMessage } from 'react-intl';
import Link from 'next/link'; import Link from 'next/link';
import classNames from 'classnames'; import classNames from 'classnames';
@ -26,6 +26,14 @@ export default function AccountSettings() {
const [saved, setSaved] = useState(0); const [saved, setSaved] = useState(0);
const [message, setMessage] = useState(); const [message, setMessage] = useState();
const { data } = useFetch(`/accounts`, {}, [saved]); 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); 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" /> <FormattedMessage id="label.add-account" defaultMessage="Add account" />
</Button> </Button>
</PageHeader> </PageHeader>
<Table columns={columns} rows={data} /> <Table columns={columns} rows={formattedData} />
{editAccount && ( {editAccount && (
<Modal title={<FormattedMessage id="label.edit-account" defaultMessage="Edit account" />}> <Modal title={<FormattedMessage id="label.edit-account" defaultMessage="Edit account" />}>
<AccountEditForm <AccountEditForm

View File

@ -50,6 +50,7 @@ export default function WebsiteSettings() {
onClick={() => setShowUrl(row)} onClick={() => setShowUrl(row)}
/> />
)} )}
{!user.isViewer && (
<Button <Button
icon={<Code />} icon={<Code />}
size="small" size="small"
@ -59,6 +60,8 @@ export default function WebsiteSettings() {
tooltipId={`button-code-${row.websiteUuid}`} tooltipId={`button-code-${row.websiteUuid}`}
onClick={() => setShowCode(row)} onClick={() => setShowCode(row)}
/> />
)}
{!user.isViewer && (
<Button <Button
icon={<Pen />} icon={<Pen />}
size="small" size="small"
@ -66,6 +69,8 @@ export default function WebsiteSettings() {
tooltipId={`button-edit-${row.websiteUuid}`} tooltipId={`button-edit-${row.websiteUuid}`}
onClick={() => setEditWebsite(row)} onClick={() => setEditWebsite(row)}
/> />
)}
{!user.isViewer && (
<Button <Button
icon={<Reset />} icon={<Reset />}
size="small" size="small"
@ -73,6 +78,8 @@ export default function WebsiteSettings() {
tooltipId={`button-reset-${row.websiteUuid}`} tooltipId={`button-reset-${row.websiteUuid}`}
onClick={() => setResetWebsite(row)} onClick={() => setResetWebsite(row)}
/> />
)}
{!user.isViewer && (
<Button <Button
icon={<Trash />} icon={<Trash />}
size="small" size="small"
@ -80,6 +87,7 @@ export default function WebsiteSettings() {
tooltipId={`button-delete-${row.websiteUuid}`} tooltipId={`button-delete-${row.websiteUuid}`}
onClick={() => setDeleteWebsite(row)} onClick={() => setDeleteWebsite(row)}
/> />
)}
</ButtonLayout> </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

@ -12,10 +12,12 @@ model account {
username String @unique() @db.VarChar(255) username String @unique() @db.VarChar(255)
password String @db.VarChar(60) password String @db.VarChar(60)
isAdmin Boolean @default(false) @map("is_admin") isAdmin Boolean @default(false) @map("is_admin")
isViewer Boolean @default(false) @map("is_viewer")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamp(0) updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamp(0)
accountUuid String @unique() @map("account_uuid") @db.VarChar(36) accountUuid String @unique() @map("account_uuid") @db.VarChar(36)
website website[] website website[]
viewwebsites viewerforwebsite[]
@@index([accountUuid]) @@index([accountUuid])
} }
@ -97,7 +99,18 @@ model website {
event event[] event event[]
pageview pageview[] pageview pageview[]
session session[] session session[]
viewers viewerforwebsite[]
@@index([userId]) @@index([userId])
@@index([websiteUuid]) @@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) username String @unique @db.VarChar(255)
password String @db.VarChar(60) password String @db.VarChar(60)
isAdmin Boolean @default(false) @map("is_admin") isAdmin Boolean @default(false) @map("is_admin")
isViewer Boolean @default(false) @map("is_viewer")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamptz(6) updatedAt DateTime? @default(now()) @map("updated_at") @db.Timestamptz(6)
accountUuid String @unique @map("account_uuid") @db.Uuid accountUuid String @unique @map("account_uuid") @db.Uuid
website website[] website website[]
viewwebsites viewerforwebsite[]
@@index([accountUuid]) @@index([accountUuid])
} }
@ -97,7 +99,18 @@ model website {
event event[] event event[]
pageview pageview[] pageview pageview[]
session session[] session session[]
viewers viewerforwebsite[]
@@index([userId]) @@index([userId])
@@index([websiteUuid]) @@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) { if (type === TYPE_WEBSITE) {
const website = await getWebsite({ websiteUuid: id }); 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) { } else if (type === TYPE_ACCOUNT) {
const account = await getAccount({ accountUuid: id }); const account = await getAccount({ accountUuid: id });

View File

@ -19,7 +19,7 @@ export default async (req, res) => {
} }
if (req.method === 'POST') { if (req.method === 'POST') {
const { username, password } = req.body; const { username, password, isViewer, websiteIds } = req.body;
if (id !== userId && !isAdmin) { if (id !== userId && !isAdmin) {
return unauthorized(res); return unauthorized(res);
@ -29,6 +29,8 @@ export default async (req, res) => {
const data = {}; const data = {};
data.isViewer = isViewer;
if (password) { if (password) {
data.password = hashPassword(password); data.password = hashPassword(password);
} }
@ -36,6 +38,24 @@ export default async (req, res) => {
// Only admin can change these fields // Only admin can change these fields
if (isAdmin) { if (isAdmin) {
data.username = username; 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 // Check when username changes

View File

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

View File

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

View File

@ -14,7 +14,9 @@ export default async (req, res) => {
return unauthorized(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 ids = websites.map(({ websiteUuid }) => websiteUuid);
const token = createToken({ websites: ids }, secret()); const token = createToken({ websites: ids }, secret());
const data = await getRealtimeData(ids, subMinutes(new Date(), 30)); const data = await getRealtimeData(ids, subMinutes(new Date(), 30));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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