Merge branch 'dev' into analytics

This commit is contained in:
Mike Cao 2023-11-29 20:30:36 -08:00
commit 3e8d50d526
79 changed files with 572 additions and 418 deletions

View file

@ -1,14 +1,23 @@
'use client';
import { Loading } from 'react-basics';
import Script from 'next/script';
import { usePathname } from 'next/navigation';
import UpdateNotice from 'components/common/UpdateNotice';
import { useRequireLogin, useConfig } from 'components/hooks';
import { useLogin, useConfig } from 'components/hooks';
import UpdateNotice from './UpdateNotice';
export function Shell({ children }) {
const { user } = useRequireLogin();
export function App({ children }) {
const { user, isLoading, error } = useLogin();
const config = useConfig();
const pathname = usePathname();
if (isLoading) {
return <Loading />;
}
if (error) {
window.location.href = `${process.env.basePath || ''}/login`;
}
if (!user || !config) {
return null;
}
@ -24,4 +33,4 @@ export function Shell({ children }) {
);
}
export default Shell;
export default App;

View file

@ -14,6 +14,7 @@ import styles from './NavBar.module.css';
export function NavBar() {
const pathname = usePathname();
const { formatMessage, labels } = useMessages();
const cloudMode = Boolean(process.env.cloudMode);
const links = [
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
@ -22,6 +23,40 @@ export function NavBar() {
{ label: formatMessage(labels.settings), url: '/settings' },
].filter(n => n);
const menuItems = [
{
label: formatMessage(labels.dashboard),
url: '/dashboard',
},
!cloudMode && {
label: formatMessage(labels.settings),
url: '/settings',
children: [
{
label: formatMessage(labels.websites),
url: '/settings/websites',
},
{
label: formatMessage(labels.teams),
url: '/settings/teams',
},
{
label: formatMessage(labels.users),
url: '/settings/users',
},
{
label: formatMessage(labels.profile),
url: '/settings/profile',
},
],
},
cloudMode && {
label: formatMessage(labels.profile),
url: '/settings/profile',
},
!cloudMode && { label: formatMessage(labels.logout), url: '/logout' },
].filter(n => n);
return (
<div className={styles.navbar}>
<div className={styles.logo}>
@ -49,7 +84,7 @@ export function NavBar() {
<ProfileButton />
</div>
<div className={styles.mobile}>
<HamburgerButton />
<HamburgerButton menuItems={menuItems} />
</div>
</div>
);

View file

@ -1,7 +1,7 @@
import Dashboard from 'app/(main)/dashboard/Dashboard';
import { Metadata } from 'next';
export default function DashboardPage() {
export default function () {
return <Dashboard />;
}

View file

@ -1,11 +1,11 @@
import Shell from './Shell';
import App from './App';
import NavBar from './NavBar';
import Page from 'components/layout/Page';
import styles from './layout.module.css';
export default function AppLayout({ children }) {
export default function ({ children }) {
return (
<Shell>
<App>
<main className={styles.layout}>
<nav className={styles.nav}>
<NavBar />
@ -14,6 +14,6 @@ export default function AppLayout({ children }) {
<Page>{children}</Page>
</section>
</main>
</Shell>
</App>
);
}

View file

@ -5,9 +5,9 @@ import useFilterQuery from 'components/hooks/useFilterQuery';
import DataTable from 'components/common/DataTable';
import useCache from 'store/cache';
export default function ReportsDataTable({ websiteId }) {
export default function ReportsDataTable({ websiteId }: { websiteId?: string }) {
const { get } = useApi();
const modified = useCache(state => state?.reports);
const modified = useCache(state => (state as any)?.reports);
const queryResult = useFilterQuery(['reports', { websiteId, modified }], params =>
get(websiteId ? `/websites/${websiteId}/reports` : `/reports`, params),
);

View file

@ -1,7 +1,7 @@
import ReportsHeader from './ReportsHeader';
import ReportsDataTable from './ReportsDataTable';
export default function ReportsPage() {
export default function () {
return (
<>
<ReportsHeader />

View file

@ -2,11 +2,13 @@ import useApi from 'components/hooks/useApi';
import { useState } from 'react';
import { Button, Form, FormButtons, GridColumn, Loading, SubmitButton, Toggle } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
import WebsitesDataTable from '../../websites/WebsitesDataTable';
import WebsitesDataTable from 'app/(main)/settings/websites/WebsitesDataTable';
import Empty from 'components/common/Empty';
import { setValue } from 'store/cache';
import { useUser } from 'components/hooks';
export function TeamWebsiteAddForm({ teamId, onSave, onClose }) {
const { user } = useUser();
const { formatMessage, labels } = useMessages();
const { get, post, useQuery, useMutation } = useApi();
const { mutate, error } = useMutation(data => post(`/teams/${teamId}/websites`, data));
@ -37,7 +39,7 @@ export function TeamWebsiteAddForm({ teamId, onSave, onClose }) {
{!isLoading && !hasData && <Empty />}
{hasData && (
<Form onSubmit={handleSubmit} error={error}>
<WebsitesDataTable showHeader={false} showActions={false}>
<WebsitesDataTable userId={user.id} showHeader={false} showActions={false}>
<GridColumn name="select" label={formatMessage(labels.selectWebsite)} alignment="end">
{row => (
<Toggle

View file

@ -11,22 +11,22 @@ import useApi from 'components/hooks/useApi';
import { DOMAIN_REGEX } from 'lib/constants';
import useMessages from 'components/hooks/useMessages';
export function WebsiteAddForm({ onSave, onClose }) {
export function WebsiteAddForm({ onSave, onClose }: { onSave?: () => void; onClose?: () => void }) {
const { formatMessage, labels, messages } = useMessages();
const { post, useMutation } = useApi();
const { mutate, error, isLoading } = useMutation(data => post('/websites', data));
const handleSubmit = async data => {
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
onSave();
onClose();
onSave?.();
onClose?.();
},
});
};
return (
<Form onSubmit={handleSubmit} error={error}>
<Form onSubmit={handleSubmit} error={error as string}>
<FormRow label={formatMessage(labels.name)}>
<FormInput name="name" rules={{ required: formatMessage(labels.required) }}>
<TextField autoComplete="off" />
@ -47,9 +47,11 @@ export function WebsiteAddForm({ onSave, onClose }) {
<SubmitButton variant="primary" disabled={false}>
{formatMessage(labels.save)}
</SubmitButton>
<Button disabled={isLoading} onClick={onClose}>
{formatMessage(labels.cancel)}
</Button>
{onClose && (
<Button disabled={isLoading} onClick={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
</FormButtons>
</Form>
);

View file

@ -0,0 +1,15 @@
'use client';
import { useUser } from 'components/hooks';
import WebsitesDataTable from './WebsitesDataTable';
import WebsitesHeader from './WebsitesHeader';
export default function Websites() {
const { user } = useUser();
return (
<>
<WebsitesHeader />
<WebsitesDataTable userId={user.id} />
</>
);
}

View file

@ -1,13 +1,13 @@
'use client';
import { ReactNode } from 'react';
import WebsitesTable from 'app/(main)/settings/websites/WebsitesTable';
import useUser from 'components/hooks/useUser';
import useApi from 'components/hooks/useApi';
import DataTable from 'components/common/DataTable';
import useFilterQuery from 'components/hooks/useFilterQuery';
import useCache from 'store/cache';
export interface WebsitesDataTableProps {
userId: string;
allowEdit?: boolean;
allowView?: boolean;
showActions?: boolean;
@ -17,25 +17,25 @@ export interface WebsitesDataTableProps {
children?: ReactNode;
}
function useWebsites({ includeTeams, onlyTeams }) {
const { user } = useUser();
function useWebsites(userId: string, { includeTeams, onlyTeams }) {
const { get } = useApi();
const modified = useCache((state: any) => state?.websites);
return useFilterQuery(
['websites', { includeTeams, onlyTeams, modified }],
(params: any) => {
return get(`/users/${user?.id}/websites`, {
return get(`/users/${userId}/websites`, {
includeTeams,
onlyTeams,
...params,
});
},
{ enabled: !!user },
{ enabled: !!userId },
);
}
export function WebsitesDataTable({
userId,
allowEdit = true,
allowView = true,
showActions = true,
@ -44,7 +44,7 @@ export function WebsitesDataTable({
onlyTeams,
children,
}: WebsitesDataTableProps) {
const queryResult = useWebsites({ includeTeams, onlyTeams });
const queryResult = useWebsites(userId, { includeTeams, onlyTeams });
return (
<DataTable queryResult={queryResult}>

View file

@ -1,14 +1,8 @@
import WebsitesDataTable from './WebsitesDataTable';
import WebsitesHeader from './WebsitesHeader';
import { Metadata } from 'next';
import Websites from './Websites';
export default function () {
return (
<>
<WebsitesHeader />
<WebsitesDataTable />
</>
);
return <Websites />;
}
export const metadata: Metadata = {

View file

@ -1,6 +1,6 @@
'use client';
import WebsitesDataTable from '../settings/websites/WebsitesDataTable';
import { useMessages } from 'components/hooks';
import { useMessages, useUser } from 'components/hooks';
import { useState } from 'react';
import { Item, Tabs } from 'react-basics';
@ -10,6 +10,7 @@ const TABS = {
};
export function WebsitesBrowse() {
const { user } = useUser();
const { formatMessage, labels } = useMessages();
const [tab, setTab] = useState(TABS.myWebsites);
const allowEdit = !process.env.cloudMode;
@ -20,9 +21,14 @@ export function WebsitesBrowse() {
<Item key={TABS.myWebsites}>{formatMessage(labels.myWebsites)}</Item>
<Item key={TABS.teamWebsites}>{formatMessage(labels.teamWebsites)}</Item>
</Tabs>
{tab === TABS.myWebsites && <WebsitesDataTable allowEdit={allowEdit} />}
{tab === TABS.myWebsites && <WebsitesDataTable userId={user.id} allowEdit={allowEdit} />}
{tab === TABS.teamWebsites && (
<WebsitesDataTable showTeam={true} onlyTeams={true} allowEdit={allowEdit} />
<WebsitesDataTable
userId={user.id}
showTeam={true}
onlyTeams={true}
allowEdit={allowEdit}
/>
)}
</>
);

View file

@ -5,7 +5,7 @@ import ReferrersTable from 'components/metrics/ReferrersTable';
import BrowsersTable from 'components/metrics/BrowsersTable';
import OSTable from 'components/metrics/OSTable';
import DevicesTable from 'components/metrics/DevicesTable';
import WorldMap from 'components/common/WorldMap';
import WorldMap from 'components/metrics/WorldMap';
import CountriesTable from 'components/metrics/CountriesTable';
import EventsTable from 'components/metrics/EventsTable';
import EventsChart from 'components/metrics/EventsChart';

View file

@ -5,7 +5,7 @@ import firstBy from 'thenby';
import { Grid, GridRow } from 'components/layout/Grid';
import Page from 'components/layout/Page';
import RealtimeChart from 'components/metrics/RealtimeChart';
import WorldMap from 'components/common/WorldMap';
import WorldMap from 'components/metrics/WorldMap';
import RealtimeLog from './RealtimeLog';
import RealtimeHeader from './RealtimeHeader';
import RealtimeUrls from './RealtimeUrls';

View file

@ -8,16 +8,16 @@ import 'styles/locale.css';
import 'styles/index.css';
import 'styles/variables.css';
export default function RootLayout({ children }) {
export default function ({ children }) {
return (
<html lang="en" data-scroll="0">
<head>
<link rel="icon" href={`/favicon.ico`} />
<link rel="apple-touch-icon" sizes="180x180" href={`/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`/favicon-16x16.png`} />
<link rel="manifest" href={`/site.webmanifest`} />
<link rel="mask-icon" href={`/safari-pinned-tab.svg`} color="#5bbad5" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" />

View file

@ -1,5 +1,10 @@
import Logout from './Logout';
import { Metadata } from 'next';
export default function () {
return <Logout />;
}
export const metadata: Metadata = {
title: 'Logout | umami',
};

View file

@ -1,5 +1,10 @@
import Share from './Share';
import { Metadata } from 'next';
export default function ({ params: { id } }) {
return <Share shareId={id[0]} />;
}
export const metadata: Metadata = {
title: 'umami',
};

View file

@ -2,7 +2,13 @@ import { useState } from 'react';
import { Button, LoadingButton, Form, FormButtons } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
export function ConfirmDeleteForm({ name, onConfirm, onClose }) {
export interface ConfirmDeleteFormProps {
name: string;
onConfirm?: () => void;
onClose?: () => void;
}
export function ConfirmDeleteForm({ name, onConfirm, onClose }: ConfirmDeleteFormProps) {
const [loading, setLoading] = useState(false);
const { formatMessage, labels, messages, FormattedMessage } = useMessages();

View file

@ -1,6 +1,6 @@
import classNames from 'classnames';
import styles from './Empty.module.css';
import useMessages from 'components/hooks/useMessages';
import styles from './Empty.module.css';
export interface EmptyProps {
message?: string;

View file

@ -1,6 +1,12 @@
import { ReactNode } from 'react';
import { Icon, Text, Flexbox } from 'react-basics';
import Logo from 'assets/logo.svg';
export interface EmptyPlaceholderProps {
message: string;
children?: ReactNode;
}
export function EmptyPlaceholder({ message, children }) {
return (
<Flexbox direction="column" alignItems="center" justifyContent="center" gap={60} height={600}>

View file

@ -1,14 +1,19 @@
/* eslint-disable no-console */
import { ErrorInfo, ReactNode } from 'react';
import { ErrorBoundary as Boundary } from 'react-error-boundary';
import { Button } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
import styles from './ErrorBoundry.module.css';
const logError = (error, info) => {
const logError = (error: Error, info: ErrorInfo) => {
console.error(error, info.componentStack);
};
export function ErrorBoundary({ children }) {
export interface ErrorBoundaryProps {
children: ReactNode;
}
export function ErrorBoundary({ children }: ErrorBoundaryProps) {
const { formatMessage, messages } = useMessages();
const fallbackRender = ({ error, resetErrorBoundary }) => {

View file

@ -1,6 +1,6 @@
import styles from './Favicon.module.css';
function getHostName(url) {
function getHostName(url: string) {
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im);
return match && match.length > 1 ? match[1] : null;
}

View file

@ -1,13 +0,0 @@
import { ButtonGroup, Button, Flexbox } from 'react-basics';
export function FilterButtons({ items, selectedKey, onSelect }) {
return (
<Flexbox justifyContent="center">
<ButtonGroup items={items} selectedKey={selectedKey} onSelect={onSelect}>
{({ key, label }) => <Button key={key}>{label}</Button>}
</ButtonGroup>
</Flexbox>
);
}
export default FilterButtons;

View file

@ -0,0 +1,20 @@
import { Key } from 'react';
import { ButtonGroup, Button, Flexbox } from 'react-basics';
export interface FilterButtonsProps {
items: any[];
selectedKey?: Key;
onSelect: () => void;
}
export function FilterButtons({ items, selectedKey, onSelect }: FilterButtonsProps) {
return (
<Flexbox justifyContent="center">
<ButtonGroup items={items} selectedKey={selectedKey as any} onSelect={onSelect}>
{({ key, label }) => <Button key={key}>{label}</Button>}
</ButtonGroup>
</Flexbox>
);
}
export default FilterButtons;

View file

@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import { Icon, Icons } from 'react-basics';
import classNames from 'classnames';
import Link from 'next/link';
@ -6,7 +7,23 @@ import useNavigation from 'components/hooks/useNavigation';
import useMessages from 'components/hooks/useMessages';
import styles from './FilterLink.module.css';
export function FilterLink({ id, value, label, externalUrl, children, className }) {
export interface FilterLinkProps {
id: string;
value: string;
label: string;
externalUrl: string;
className: string;
children: ReactNode;
}
export function FilterLink({
id,
value,
label,
externalUrl,
children,
className,
}: FilterLinkProps) {
const { formatMessage, labels } = useMessages();
const { makeUrl, query } = useNavigation();
const active = query[id] !== undefined;

View file

@ -1,59 +0,0 @@
import { Button, Icon } from 'react-basics';
import { useState } from 'react';
import MobileMenu from './MobileMenu';
import Icons from 'components/icons';
import useMessages from 'components/hooks/useMessages';
export function HamburgerButton() {
const { formatMessage, labels } = useMessages();
const [active, setActive] = useState(false);
const cloudMode = Boolean(process.env.cloudMode);
const menuItems = [
{
label: formatMessage(labels.dashboard),
url: '/dashboard',
},
!cloudMode && {
label: formatMessage(labels.settings),
url: '/settings',
children: [
{
label: formatMessage(labels.websites),
url: '/settings/websites',
},
{
label: formatMessage(labels.teams),
url: '/settings/teams',
},
{
label: formatMessage(labels.users),
url: '/settings/users',
},
{
label: formatMessage(labels.profile),
url: '/settings/profile',
},
],
},
cloudMode && {
label: formatMessage(labels.profile),
url: '/settings/profile',
},
!cloudMode && { label: formatMessage(labels.logout), url: '/logout' },
].filter(n => n);
const handleClick = () => setActive(state => !state);
const handleClose = () => setActive(false);
return (
<>
<Button variant="quiet" onClick={handleClick}>
<Icon>{active ? <Icons.Close /> : <Icons.Menu />}</Icon>
</Button>
{active && <MobileMenu items={menuItems} onClose={handleClose} />}
</>
);
}
export default HamburgerButton;

View file

@ -0,0 +1,22 @@
import { Button, Icon } from 'react-basics';
import { useState } from 'react';
import MobileMenu from './MobileMenu';
import Icons from 'components/icons';
export function HamburgerButton({ menuItems }: { menuItems: any[] }) {
const [active, setActive] = useState(false);
const handleClick = () => setActive(state => !state);
const handleClose = () => setActive(false);
return (
<>
<Button variant="quiet" onClick={handleClick}>
<Icon>{active ? <Icons.Close /> : <Icons.Menu />}</Icon>
</Button>
{active && <MobileMenu items={menuItems} onClose={handleClose} />}
</>
);
}
export default HamburgerButton;

View file

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import { Tooltip } from 'react-basics';
import styles from './HoverTooltip.module.css';
export function HoverTooltip({ children }) {
export function HoverTooltip({ children }: { children: ReactNode }) {
const [position, setPosition] = useState({ x: -1000, y: -1000 });
useEffect(() => {

View file

@ -2,8 +2,17 @@ import classNames from 'classnames';
import Link from 'next/link';
import { useLocale } from 'components/hooks';
import styles from './LinkButton.module.css';
import { ReactNode } from 'react';
export function LinkButton({ href, className, variant, scroll = true, children }) {
export interface LinkButtonProps {
href: string;
className?: string;
variant?: string;
scroll?: boolean;
children?: ReactNode;
}
export function LinkButton({ href, className, variant, scroll = true, children }: LinkButtonProps) {
const { dir } = useLocale();
return (

View file

@ -4,12 +4,19 @@ import { usePathname } from 'next/navigation';
import Link from 'next/link';
import styles from './MobileMenu.module.css';
export function MobileMenu({ items = [], onClose }) {
export function MobileMenu({
items = [],
onClose,
}: {
items: any[];
className?: string;
onClose: () => void;
}): any {
const pathname = usePathname();
const Items = ({ items, className }) => (
const Items = ({ items, className }: { items: any[]; className?: string }): any => (
<div className={classNames(styles.items, className)}>
{items.map(({ label, url, children }) => {
{items.map(({ label, url, children }: { label: string; url: string; children: any[] }) => {
const selected = pathname.startsWith(url);
return (

View file

@ -3,7 +3,15 @@ import { Button, Icon, Icons } from 'react-basics';
import useMessages from 'components/hooks/useMessages';
import styles from './Pager.module.css';
export function Pager({ page, pageSize, count, onPageChange, className }) {
export interface PagerProps {
page: number;
pageSize: number;
count: number;
onPageChange: (nextPage: number) => void;
className?: string;
}
export function Pager({ page, pageSize, count, onPageChange, className }: PagerProps) {
const { formatMessage, labels } = useMessages();
const maxPage = pageSize && count ? Math.ceil(count / pageSize) : 0;
const lastPage = page === maxPage;
@ -13,7 +21,7 @@ export function Pager({ page, pageSize, count, onPageChange, className }) {
return null;
}
const handlePageChange = value => {
const handlePageChange = (value: number) => {
const nextPage = page + value;
if (nextPage > 0 && nextPage <= maxPage) {
onPageChange(nextPage);

View file

@ -1,2 +1,3 @@
declare module '*.css';
declare module '*.svg';
declare module '*.json';

View file

@ -13,7 +13,7 @@ export * from './useMessages';
export * from './useNavigation';
export * from './useReport';
export * from './useReports';
export * from './useRequireLogin';
export * from './useLogin';
export * from './useShareToken';
export * from './useSticky';
export * from './useTheme';

View file

@ -6,10 +6,10 @@ const countryNames = {
'en-US': enUS,
};
export function useCountryNames(locale) {
export function useCountryNames(locale: string) {
const [list, setList] = useState(countryNames[locale] || enUS);
async function loadData(locale) {
async function loadData(locale: string) {
const { data } = await httpGet(`${process.env.basePath}/intl/country/${locale}.json`);
if (data) {

View file

@ -6,7 +6,7 @@ import websiteStore, { setWebsiteDateRange } from 'store/websites';
import appStore, { setDateRange } from 'store/app';
import useApi from './useApi';
export function useDateRange(websiteId) {
export function useDateRange(websiteId: string) {
const { get } = useApi();
const { locale } = useLocale();
const websiteConfig = websiteStore(state => state[websiteId]?.dateRange);
@ -20,7 +20,7 @@ export function useDateRange(websiteId) {
if (typeof value === 'string') {
if (value === 'all') {
const result = await get(`/websites/${websiteId}/daterange`);
const result: any = await get(`/websites/${websiteId}/daterange`);
const { mindate, maxdate } = result;
const startDate = new Date(mindate);

View file

@ -1,6 +1,6 @@
import { useEffect } from 'react';
export function useDocumentClick(handler) {
export function useDocumentClick(handler: (event: MouseEvent) => any) {
useEffect(() => {
document.addEventListener('click', handler);

View file

@ -1,21 +0,0 @@
import { useEffect, useCallback } from 'react';
export function useEscapeKey(handler) {
const escFunction = useCallback(event => {
if (event.keyCode === 27) {
handler(event);
}
}, []);
useEffect(() => {
document.addEventListener('keydown', escFunction, false);
return () => {
document.removeEventListener('keydown', escFunction, false);
};
}, [escFunction]);
return null;
}
export default useEscapeKey;

View file

@ -0,0 +1,21 @@
import { useEffect, useCallback, KeyboardEvent } from 'react';
export function useEscapeKey(handler: (event: KeyboardEvent) => void) {
const escFunction = useCallback((event: KeyboardEvent) => {
if (event.key === 'Escape') {
handler(event);
}
}, []);
useEffect(() => {
document.addEventListener('keydown', escFunction as any, false);
return () => {
document.removeEventListener('keydown', escFunction as any, false);
};
}, [escFunction]);
return null;
}
export default useEscapeKey;

View file

@ -9,23 +9,23 @@ export function useFormat() {
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const formatBrowser = value => {
const formatBrowser = (value: string) => {
return BROWSERS[value] || value;
};
const formatCountry = value => {
const formatCountry = (value: string) => {
return countryNames[value] || value;
};
const formatRegion = value => {
const formatRegion = (value: string) => {
return regions[value] ? regions[value] : value;
};
const formatDevice = value => {
const formatDevice = (value: string) => {
return formatMessage(labels[value] || labels.unknown);
};
const formatValue = (value, type) => {
const formatValue = (value: string, type: string) => {
switch (type) {
case 'browser':
return formatBrowser(value);

View file

@ -0,0 +1,22 @@
import useApi from 'components/hooks/useApi';
import useUser from 'components/hooks/useUser';
export function useLogin() {
const { get, useQuery } = useApi();
const { user, setUser } = useUser();
const query = useQuery({
queryKey: ['login'],
queryFn: async () => {
const data = await get('/auth/verify');
setUser(data);
return data;
},
});
return { user, ...query };
}
export default useLogin;

View file

@ -1,20 +0,0 @@
import { useIntl, FormattedMessage } from 'react-intl';
import { messages, labels } from 'components/messages';
export function useMessages() {
const intl = useIntl();
const getMessage = id => {
const message = Object.values(messages).find(value => value.id === id);
return message ? formatMessage(message) : id;
};
const formatMessage = (descriptor, values, opts) => {
return descriptor ? intl.formatMessage(descriptor, values, opts) : null;
};
return { formatMessage, FormattedMessage, messages, labels, getMessage };
}
export default useMessages;

View file

@ -0,0 +1,30 @@
import { useIntl, FormattedMessage, MessageDescriptor, PrimitiveType } from 'react-intl';
import { messages, labels } from 'components/messages';
import { FormatXMLElementFn, Options } from 'intl-messageformat';
export function useMessages(): any {
const intl = useIntl();
const getMessage = (id: string) => {
const message = Object.values(messages).find(value => value.id === id);
return message ? formatMessage(message) : id;
};
const formatMessage = (
descriptor:
| MessageDescriptor
| {
id: string;
defaultMessage: string;
},
values?: Record<string, PrimitiveType | FormatXMLElementFn<string, string>>,
opts?: Options,
) => {
return descriptor ? intl.formatMessage(descriptor, values, opts) : null;
};
return { formatMessage, FormattedMessage, messages, labels, getMessage };
}
export default useMessages;

View file

@ -17,7 +17,7 @@ export function useNavigation() {
return obj;
}, [params]);
function makeUrl(params, reset) {
function makeUrl(params: any, reset?: boolean) {
return reset ? pathname : buildUrl(pathname, { ...query, ...params });
}

View file

@ -18,7 +18,7 @@ export function useReport(reportId, defaultParameters) {
};
const loadReport = async id => {
const data = await get(`/reports/${id}`);
const data: any = await get(`/reports/${id}`);
const { dateRange } = data?.parameters || {};
const { startDate, endDate } = dateRange || {};
@ -40,7 +40,7 @@ export function useReport(reportId, defaultParameters) {
const data = await post(`/reports/${type}`, { ...parameters, timezone });
setReport(
produce(state => {
produce((state: any) => {
state.parameters = parameters;
state.data = data;
@ -56,7 +56,7 @@ export function useReport(reportId, defaultParameters) {
const updateReport = useCallback(
async data => {
setReport(
produce(state => {
produce((state: any) => {
const { parameters, ...rest } = data;
if (parameters) {

View file

@ -1,28 +0,0 @@
import { useEffect } from 'react';
import useApi from 'components/hooks/useApi';
import useUser from 'components/hooks/useUser';
export function useRequireLogin(handler?: (data?: object) => void) {
const { get } = useApi();
const { user, setUser } = useUser();
useEffect(() => {
async function loadUser() {
try {
const data = await get('/auth/verify');
setUser(typeof handler === 'function' ? handler(data) : (data as any)?.user);
} catch {
location.href = `${process.env.basePath || ''}/login`;
}
}
if (!user) {
loadUser();
}
}, [user]);
return { user };
}
export default useRequireLogin;

View file

@ -1,9 +1,9 @@
import useStore, { setShareToken } from 'store/app';
import useApi from './useApi';
const selector = state => state.shareToken;
const selector = (state: { shareToken: string }) => state.shareToken;
export function useShareToken(shareId) {
export function useShareToken(shareId: string) {
const shareToken = useStore(selector);
const { get, useQuery } = useApi();
const { isLoading, error } = useQuery(['share', shareId], async () => {

View file

@ -5,8 +5,9 @@ export function useSticky({ enabled = true, threshold = 1 }) {
const ref = useRef(null);
useEffect(() => {
let observer;
const handler = ([entry]) => setIsSticky(entry.intersectionRatio < threshold);
let observer: IntersectionObserver | undefined;
const handler: IntersectionObserverCallback = ([entry]) =>
setIsSticky(entry.intersectionRatio < threshold);
if (enabled && ref.current) {
observer = new IntersectionObserver(handler, { threshold: [threshold] });

View file

@ -4,7 +4,7 @@ import { getItem, setItem } from 'next-basics';
import { THEME_COLORS, THEME_CONFIG } from 'lib/constants';
import { colord } from 'colord';
const selector = state => state.theme;
const selector = (state: { theme: string }) => state.theme;
export function useTheme() {
const defaultTheme =

View file

@ -1,6 +1,6 @@
import useApi from './useApi';
export function useWebsite(websiteId) {
export function useWebsite(websiteId: string) {
const { get, useQuery } = useApi();
return useQuery(['websites', websiteId], () => get(`/websites/${websiteId}`), {
enabled: !!websiteId,

2
src/declaration.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare module 'cors';
declare module 'debug';

View file

@ -1,19 +1,16 @@
export * from 'components/hooks/useApi';
export * from 'components/hooks/useConfig';
export * from 'components/hooks/useCountryNames';
export * from 'components/hooks/useDateRange';
export * from 'components/hooks/useDocumentClick';
export * from 'components/hooks/useEscapeKey';
export * from 'components/hooks/useFilterQuery';
export * from 'components/hooks/useFilters';
export * from 'components/hooks/useForceUpdate';
export * from 'components/hooks/useFormat';
export * from 'components/hooks/useLanguageNames';
export * from 'components/hooks/useLocale';
export * from 'components/hooks/useMessages';
export * from 'components/hooks/useNavigation';
export * from 'components/hooks/useReport';
export * from 'components/hooks/useReports';
export * from 'components/hooks/useRequireLogin';
export * from 'components/hooks/useLogin';
export * from 'components/hooks/useShareToken';
export * from 'components/hooks/useSticky';
export * from 'components/hooks/useTheme';
@ -21,7 +18,7 @@ export * from 'components/hooks/useTimezone';
export * from 'components/hooks/useUser';
export * from 'components/hooks/useWebsite';
export * from './app/(main)/settings/teams/[id]/TeamWebsiteAddForm';
export * from 'app/(main)/settings/teams/[id]/TeamWebsiteAddForm';
export * from 'app/(main)/settings/teams/[id]/TeamEditForm';
export * from 'app/(main)/settings/teams/[id]/TeamMemberRemoveButton';
export * from 'app/(main)/settings/teams/[id]/TeamMembers';
@ -44,8 +41,22 @@ export * from 'app/(main)/settings/websites/[id]/TrackingCode';
export * from 'app/(main)/settings/websites/[id]/WebsiteDeleteForm';
export * from 'app/(main)/settings/websites/[id]/WebsiteEditForm';
export * from 'app/(main)/settings/websites/[id]/WebsiteResetForm';
export * from 'app/(main)/settings/websites/WebsiteAddForm';
export * from 'app/(main)/settings/websites/WebsitesHeader';
export * from 'app/(main)/settings/websites/WebsiteSettings';
export * from './app/(main)/settings/websites/WebsitesDataTable';
export * from 'app/(main)/settings/websites/WebsitesDataTable';
export * from 'app/(main)/settings/websites/WebsitesTable';
export * from 'components/common/ConfirmDeleteForm';
export * from 'components/common/DataTable';
export * from 'components/common/Empty';
export * from 'components/common/ErrorBoundary';
export * from 'components/common/Favicon';
export * from 'components/common/FilterButtons';
export * from 'components/common/FilterLink';
export * from 'components/common/HamburgerButton';
export * from 'components/common/HoverTooltip';
export * from 'components/common/LinkButton';
export * from 'components/common/MobileMenu';
export * from 'components/common/Pager';

View file

@ -1,29 +1,30 @@
import { Report } from '@prisma/client';
import redis from '@umami/redis-client';
import debug from 'debug';
import redis from '@umami/redis-client';
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
import { secret } from 'lib/crypto';
import { createSecureToken, ensureArray, getRandomChars, parseToken } from 'next-basics';
import { findTeamWebsiteByUserId, getTeamUser, getTeamWebsite } from 'queries';
import { loadWebsite } from './load';
import { Auth } from './types';
import { NextApiRequest } from 'next';
const log = debug('umami:auth');
const cloudMode = process.env.CLOUD_MODE;
export async function setAuthKey(user, expire = 0) {
export async function saveAuth(data: any, expire = 0) {
const authKey = `auth:${getRandomChars(32)}`;
await redis.set(authKey, user);
await redis.client.set(authKey, data);
if (expire) {
await redis.expire(authKey, expire);
await redis.client.expire(authKey, expire);
}
return createSecureToken({ authKey }, secret());
}
export function getAuthToken(req) {
export function getAuthToken(req: NextApiRequest) {
try {
return req.headers.authorization.split(' ')[1];
} catch {
@ -31,7 +32,7 @@ export function getAuthToken(req) {
}
}
export function parseShareToken(req) {
export function parseShareToken(req: Request) {
try {
return parseToken(req.headers[SHARE_TOKEN_HEADER], secret());
} catch (e) {
@ -60,11 +61,7 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
export async function canCreateWebsite({ user, grant }: Auth) {
if (cloudMode) {
if (grant?.find(a => a === PERMISSIONS.websiteCreate)) {
return true;
}
return false;
return !!grant?.find(a => a === PERMISSIONS.websiteCreate);
}
if (user.isAdmin) {
@ -120,11 +117,7 @@ export async function canDeleteReport(auth: Auth, report: Report) {
export async function canCreateTeam({ user, grant }: Auth) {
if (cloudMode) {
if (grant?.find(a => a === PERMISSIONS.teamCreate)) {
return true;
}
return false;
return !!grant?.find(a => a === PERMISSIONS.teamCreate);
}
if (user.isAdmin) {

View file

@ -3,67 +3,71 @@ import redis from '@umami/redis-client';
import { getSession, getUserById, getWebsiteById } from '../queries';
async function fetchWebsite(id): Promise<Website> {
return redis.getCache(`website:${id}`, () => getWebsiteById(id), 86400);
return redis.client.getCache(`website:${id}`, () => getWebsiteById(id), 86400);
}
async function storeWebsite(data) {
const { id } = data;
const key = `website:${id}`;
const obj = await redis.setCache(key, data);
await redis.expire(key, 86400);
const obj = await redis.client.setCache(key, data);
await redis.client.expire(key, 86400);
return obj;
}
async function deleteWebsite(id) {
return redis.deleteCache(`website:${id}`);
return redis.client.deleteCache(`website:${id}`);
}
async function fetchUser(id): Promise<User> {
return redis.getCache(`user:${id}`, () => getUserById(id, { includePassword: true }), 86400);
return redis.client.getCache(
`user:${id}`,
() => getUserById(id, { includePassword: true }),
86400,
);
}
async function storeUser(data) {
const { id } = data;
const key = `user:${id}`;
const obj = await redis.setCache(key, data);
await redis.expire(key, 86400);
const obj = await redis.client.setCache(key, data);
await redis.client.expire(key, 86400);
return obj;
}
async function deleteUser(id) {
return redis.deleteCache(`user:${id}`);
return redis.client.deleteCache(`user:${id}`);
}
async function fetchSession(id) {
return redis.getCache(`session:${id}`, () => getSession(id), 86400);
return redis.client.getCache(`session:${id}`, () => getSession(id), 86400);
}
async function storeSession(data) {
const { id } = data;
const key = `session:${id}`;
const obj = await redis.setCache(key, data);
await redis.expire(key, 86400);
const obj = await redis.client.setCache(key, data);
await redis.client.expire(key, 86400);
return obj;
}
async function deleteSession(id) {
return redis.deleteCache(`session:${id}`);
return redis.client.deleteCache(`session:${id}`);
}
async function fetchUserBlock(userId: string) {
const key = `user:block:${userId}`;
return redis.get(key);
return redis.client.get(key);
}
async function incrementUserBlock(userId: string) {
const key = `user:block:${userId}`;
return redis.incr(key);
return redis.client.incr(key);
}
export default {
@ -78,5 +82,5 @@ export default {
deleteSession,
fetchUserBlock,
incrementUserBlock,
enabled: !!redis,
enabled: !!redis.enabled,
};

View file

@ -5,7 +5,7 @@ export function getClientAuthToken() {
return getItem(AUTH_TOKEN);
}
export function setClientAuthToken(token) {
export function setClientAuthToken(token: string) {
setItem(AUTH_TOKEN, token);
}

View file

@ -13,7 +13,7 @@ export const REPO_URL = 'https://github.com/umami-software/umami';
export const UPDATES_URL = 'https://api.umami.is/v1/updates';
export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
export const DEFAULT_LOCALE = process.env.defaultLocale ?? 'en-US';
export const DEFAULT_LOCALE = process.env.defaultLocale || 'en-US';
export const DEFAULT_THEME = 'light';
export const DEFAULT_ANIMATION_DURATION = 300;
export const DEFAULT_DATE_RANGE = '24hour';

View file

@ -1,13 +1,14 @@
import redis from '@umami/redis-client';
import cors from 'cors';
import debug from 'debug';
import redis from '@umami/redis-client';
import { getAuthToken, parseShareToken } from 'lib/auth';
import { ROLES } from 'lib/constants';
import { isUuid, secret } from 'lib/crypto';
import { secret } from 'lib/crypto';
import { findSession } from 'lib/session';
import {
badRequest,
createMiddleware,
forbidden,
parseSecureToken,
tooManyRequest,
unauthorized,
@ -38,6 +39,9 @@ export const useSession = createMiddleware(async (req, res, next) => {
if (e.message === 'Usage Limit.') {
return tooManyRequest(res, e.message);
}
if (e.message.startsWith('Website not found:')) {
return forbidden(res, e.message);
}
return badRequest(res, e.message);
}
@ -47,19 +51,21 @@ export const useSession = createMiddleware(async (req, res, next) => {
export const useAuth = createMiddleware(async (req, res, next) => {
const token = getAuthToken(req);
const payload = parseSecureToken(token, secret());
const shareToken = await parseShareToken(req);
const shareToken = await parseShareToken(req as any);
let user = null;
const { userId, authKey, grant } = payload || {};
if (isUuid(userId)) {
if (userId) {
user = await getUserById(userId);
} else if (redis && authKey) {
user = await redis.get(authKey);
} else if (redis.enabled && authKey) {
const key = await redis.client.get(authKey);
user = await getUserById(key?.userId);
}
if (process.env.NODE_ENV === 'development') {
log({ token, shareToken, payload, user, grant });
log('useAuth:', { token, shareToken, payload, user, grant });
}
if (!user?.id && !shareToken) {

View file

@ -1,6 +1,6 @@
import redis from '@umami/redis-client';
import debug from 'debug';
import { setAuthKey } from 'lib/auth';
import { saveAuth } from 'lib/auth';
import { secret } from 'lib/crypto';
import { useValidate } from 'lib/middleware';
import { NextApiRequestQueryBody, User } from 'lib/types';
@ -52,8 +52,8 @@ export default async (
const user = await getUserByUsername(username, { includePassword: true });
if (user && checkPassword(password, user.password)) {
if (redis) {
const token = await setAuthKey(user);
if (redis.enabled) {
const token = await saveAuth({ userId: user.id });
return ok(res, { token, user });
}

View file

@ -8,8 +8,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
await useAuth(req, res);
if (req.method === 'POST') {
if (redis) {
await redis.del(getAuthToken(req));
if (redis.enabled) {
await redis.client.del(getAuthToken(req));
}
return ok(res);

View file

@ -3,13 +3,13 @@ import { useAuth } from 'lib/middleware';
import { NextApiResponse } from 'next';
import { badRequest, ok } from 'next-basics';
import redis from '@umami/redis-client';
import { setAuthKey } from 'lib/auth';
import { saveAuth } from 'lib/auth';
export default async (req: NextApiRequestAuth, res: NextApiResponse) => {
await useAuth(req, res);
if (redis && req.auth.user) {
const token = await setAuthKey(req.auth.user, 86400);
if (redis.enabled && req.auth.user) {
const token = await saveAuth({ userId: req.auth.user.id }, 86400);
return ok(res, { user: req.auth.user, token });
}

View file

@ -6,5 +6,5 @@ import { ok } from 'next-basics';
export default async (req: NextApiRequestAuth, res: NextApiResponse) => {
await useAuth(req, res);
return ok(res, req.auth);
return ok(res, req.auth.user);
};

View file

@ -1,4 +1,3 @@
import { Resolver } from 'dns/promises';
import ipaddr from 'ipaddr.js';
import isbot from 'isbot';
import { COLLECTION_TYPE, HOSTNAME_REGEX } from 'lib/constants';
@ -74,18 +73,18 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
await useCors(req, res);
if (req.method === 'POST') {
if (isbot(req.headers['user-agent']) && !process.env.DISABLE_BOT_CHECK) {
if (!process.env.DISABLE_BOT_CHECK && isbot(req.headers['user-agent'])) {
return ok(res);
}
const { type, payload } = req.body;
await useValidate(schema, req, res);
if (await hasBlockedIp(req)) {
if (hasBlockedIp(req)) {
return forbidden(res);
}
const { type, payload } = req.body;
const { url, referrer, name: eventName, data: eventData, title: pageTitle } = payload;
await useSession(req, res);
@ -143,28 +142,16 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
return methodNotAllowed(res);
};
async function hasBlockedIp(req: NextApiRequestCollect) {
function hasBlockedIp(req: NextApiRequestCollect) {
const ignoreIps = process.env.IGNORE_IP;
const ignoreHostnames = process.env.IGNORE_HOSTNAME;
if (ignoreIps || ignoreHostnames) {
if (ignoreIps) {
const ips = [];
if (ignoreIps) {
ips.push(...ignoreIps.split(',').map(n => n.trim()));
}
if (ignoreHostnames) {
const resolver = new Resolver();
const promises = ignoreHostnames
.split(',')
.map(n => resolver.resolve4(n.trim()).catch(() => {}));
await Promise.all(promises).then(resolvedIps => {
ips.push(...resolvedIps.filter(n => n).flatMap(n => n as string[]));
});
}
const clientIp = getIpAddress(req);
return ips.find(ip => {
@ -177,8 +164,8 @@ async function hasBlockedIp(req: NextApiRequestCollect) {
if (addr.kind() === range[0].kind() && addr.match(range)) return true;
}
return false;
});
}
return false;
}

View file

@ -11,13 +11,17 @@ export interface GetUserOptions {
}
async function getUser(
where: Prisma.UserWhereInput | Prisma.UserWhereUniqueInput,
where: Prisma.UserWhereUniqueInput,
options: GetUserOptions = {},
): Promise<User> {
const { includePassword = false, showDeleted = false } = options;
return prisma.client.user.findFirst({
where: { ...where, ...(showDeleted ? {} : { deletedAt: null }) },
if (showDeleted) {
where.deletedAt = null;
}
return prisma.client.user.findUnique({
where,
select: {
id: true,
username: true,
@ -28,8 +32,8 @@ async function getUser(
});
}
export async function getUserById(userId: string, options: GetUserOptions = {}) {
return getUser({ id: userId }, options);
export async function getUserById(id: string, options: GetUserOptions = {}) {
return getUser({ id }, options);
}
export async function getUserByUsername(username: string, options: GetUserOptions = {}) {