mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Merge 7b4699a720 into a19b92a5cb
This commit is contained in:
commit
a64df3c2bf
17 changed files with 274 additions and 17 deletions
5
prisma/migrations/15_add_user_preferences/migration.sql
Normal file
5
prisma/migrations/15_add_user_preferences/migration.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "user" ADD COLUMN "date_range" VARCHAR(50),
|
||||
ADD COLUMN "timezone" VARCHAR(100),
|
||||
ADD COLUMN "language" VARCHAR(10),
|
||||
ADD COLUMN "theme" VARCHAR(20);
|
||||
|
|
@ -17,6 +17,10 @@ model User {
|
|||
role String @map("role") @db.VarChar(50)
|
||||
logoUrl String? @map("logo_url") @db.VarChar(2183)
|
||||
displayName String? @map("display_name") @db.VarChar(255)
|
||||
dateRange String? @map("date_range") @db.VarChar(50)
|
||||
timezone String? @db.VarChar(200)
|
||||
language String? @db.VarChar(10)
|
||||
theme String? @db.VarChar(20)
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
import { useState } from 'react';
|
||||
import { DateFilter } from '@/components/input/DateFilter';
|
||||
import { Button, Row } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { useMessages, usePreferences } from '@/components/hooks';
|
||||
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
|
||||
import { setItem, getItem } from '@/lib/storage';
|
||||
|
||||
export function DateRangeSetting() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { updatePreferences } = usePreferences();
|
||||
const [date, setDate] = useState(getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setItem(DATE_RANGE_CONFIG, value);
|
||||
setDate(value);
|
||||
updatePreferences({ dateRange: value });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setItem(DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE);
|
||||
setDate(DEFAULT_DATE_RANGE_VALUE);
|
||||
updatePreferences({ dateRange: null });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { useState } from 'react';
|
||||
import { Button, Select, ListItem, Row } from '@umami/react-zen';
|
||||
import { useLocale, useMessages } from '@/components/hooks';
|
||||
import { useLocale, useMessages, usePreferences } from '@/components/hooks';
|
||||
import { DEFAULT_LOCALE } from '@/lib/constants';
|
||||
import { languages } from '@/lib/lang';
|
||||
|
||||
export function LanguageSetting() {
|
||||
const [search, setSearch] = useState('');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { updatePreferences } = usePreferences();
|
||||
const { locale, saveLocale } = useLocale();
|
||||
const items = search
|
||||
? Object.keys(languages).filter(n => {
|
||||
|
|
@ -17,7 +18,15 @@ export function LanguageSetting() {
|
|||
})
|
||||
: Object.keys(languages);
|
||||
|
||||
const handleReset = () => saveLocale(DEFAULT_LOCALE);
|
||||
const handleChange = (value: string) => {
|
||||
saveLocale(value);
|
||||
updatePreferences({ language: value });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
saveLocale(DEFAULT_LOCALE);
|
||||
updatePreferences({ language: null });
|
||||
};
|
||||
|
||||
const handleOpen = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
|
|
@ -29,7 +38,7 @@ export function LanguageSetting() {
|
|||
<Row gap>
|
||||
<Select
|
||||
value={locale}
|
||||
onChange={val => saveLocale(val as string)}
|
||||
onChange={handleChange}
|
||||
allowSearch
|
||||
onSearch={setSearch}
|
||||
onOpenChange={handleOpen}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,42 @@
|
|||
import { Row, Button, Icon, useTheme } from '@umami/react-zen';
|
||||
import { useMessages, usePreferences } from '@/components/hooks';
|
||||
import { Sun, Moon } from '@/components/icons';
|
||||
import { DEFAULT_THEME } from '@/lib/constants';
|
||||
|
||||
export function ThemeSetting() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { updatePreferences } = usePreferences();
|
||||
|
||||
const handleChange = (value: 'light' | 'dark') => {
|
||||
setTheme(value);
|
||||
updatePreferences({ theme: value });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setTheme(DEFAULT_THEME);
|
||||
updatePreferences({ theme: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<Row gap>
|
||||
<Button variant={theme === 'light' ? 'primary' : undefined} onPress={() => setTheme('light')}>
|
||||
<Button
|
||||
variant={theme === 'light' ? 'primary' : undefined}
|
||||
onPress={() => handleChange('light')}
|
||||
>
|
||||
<Icon>
|
||||
<Sun />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Button variant={theme === 'dark' ? 'primary' : undefined} onPress={() => setTheme('dark')}>
|
||||
<Button
|
||||
variant={theme === 'dark' ? 'primary' : undefined}
|
||||
onPress={() => handleChange('dark')}
|
||||
>
|
||||
<Icon>
|
||||
<Moon />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { Row, Select, ListItem, Button } from '@umami/react-zen';
|
||||
import { useTimezone, useMessages } from '@/components/hooks';
|
||||
import { useTimezone, useMessages, usePreferences } from '@/components/hooks';
|
||||
import { getTimezone } from '@/lib/date';
|
||||
|
||||
const timezones = Intl.supportedValuesOf('timeZone');
|
||||
|
|
@ -8,12 +8,21 @@ const timezones = Intl.supportedValuesOf('timeZone');
|
|||
export function TimezoneSetting() {
|
||||
const [search, setSearch] = useState('');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { updatePreferences } = usePreferences();
|
||||
const { timezone, saveTimezone } = useTimezone();
|
||||
const items = search
|
||||
? timezones.filter(n => n.toLowerCase().includes(search.toLowerCase()))
|
||||
: timezones;
|
||||
|
||||
const handleReset = () => saveTimezone(getTimezone());
|
||||
const handleChange = (value: string) => {
|
||||
saveTimezone(value);
|
||||
updatePreferences({ timezone: value });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
saveTimezone(getTimezone());
|
||||
updatePreferences({ timezone: null });
|
||||
};
|
||||
|
||||
const handleOpen = isOpen => {
|
||||
if (isOpen) {
|
||||
|
|
@ -25,7 +34,7 @@ export function TimezoneSetting() {
|
|||
<Row gap>
|
||||
<Select
|
||||
value={timezone}
|
||||
onChange={(value: any) => saveTimezone(value)}
|
||||
onChange={handleChange}
|
||||
allowSearch={true}
|
||||
onSearch={setSearch}
|
||||
onOpenChange={handleOpen}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
import { createSecureToken } from '@/lib/jwt';
|
||||
import redis from '@/lib/redis';
|
||||
import { getUserByUsername } from '@/queries/prisma';
|
||||
import { getUserByUsername, getUserPreferences } from '@/queries/prisma';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { saveAuth } from '@/lib/auth';
|
||||
|
|
@ -39,8 +39,10 @@ export async function POST(request: Request) {
|
|||
token = createSecureToken({ userId: user.id, role }, secret());
|
||||
}
|
||||
|
||||
const preferences = await getUserPreferences(id);
|
||||
|
||||
return json({
|
||||
token,
|
||||
user: { id, username, role, createdAt, isAdmin: role === ROLES.admin },
|
||||
user: { id, username, role, createdAt, isAdmin: role === ROLES.admin, preferences },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { parseRequest } from '@/lib/request';
|
||||
import { json } from '@/lib/response';
|
||||
import { getAllUserTeams } from '@/queries/prisma';
|
||||
import { getAllUserTeams, getUserPreferences } from '@/queries/prisma';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
|
@ -10,6 +10,7 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
const teams = await getAllUserTeams(auth.user.id);
|
||||
const preferences = await getUserPreferences(auth.user.id);
|
||||
|
||||
return json({ ...auth.user, teams });
|
||||
return json({ ...auth.user, teams, preferences });
|
||||
}
|
||||
|
|
|
|||
50
src/app/api/users/[userId]/preferences/route.ts
Normal file
50
src/app/api/users/[userId]/preferences/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { z } from 'zod';
|
||||
import { canUpdateUser, canViewUser } from '@/permissions';
|
||||
import { getUserPreferences, updateUserPreferences } from '@/queries/prisma';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { userId } = await params;
|
||||
|
||||
if (!(await canViewUser(auth, userId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const preferences = await getUserPreferences(userId);
|
||||
|
||||
return json(preferences);
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||
const schema = z.object({
|
||||
dateRange: z.string().max(50).nullable().optional(),
|
||||
timezone: z.string().max(100).nullable().optional(),
|
||||
language: z.string().max(10).nullable().optional(),
|
||||
theme: z.string().max(20).nullable().optional(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { userId } = await params;
|
||||
|
||||
if (!(await canUpdateUser(auth, userId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined));
|
||||
|
||||
const preferences = await updateUserPreferences(userId, data);
|
||||
|
||||
return json(preferences);
|
||||
}
|
||||
|
|
@ -8,22 +8,33 @@ import {
|
|||
Icon,
|
||||
Column,
|
||||
Heading,
|
||||
useTheme,
|
||||
} from '@umami/react-zen';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||
import { setUser } from '@/store/app';
|
||||
import { setClientAuthToken } from '@/lib/client';
|
||||
import { setClientAuthToken, setClientPreferences } from '@/lib/client';
|
||||
import { Logo } from '@/components/svg';
|
||||
import { DEFAULT_THEME } from '@/lib/constants';
|
||||
|
||||
export function LoginForm() {
|
||||
const { formatMessage, labels, getErrorMessage } = useMessages();
|
||||
const router = useRouter();
|
||||
const { mutateAsync, error } = useUpdateQuery('/auth/login');
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await mutateAsync(data, {
|
||||
onSuccess: async ({ token, user }) => {
|
||||
setClientAuthToken(token);
|
||||
|
||||
if (user.preferences) {
|
||||
setClientPreferences(user.preferences);
|
||||
|
||||
const themeValue = user.preferences.theme || DEFAULT_THEME;
|
||||
setTheme(themeValue);
|
||||
}
|
||||
|
||||
setUser(user);
|
||||
|
||||
router.push('/websites');
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useEffect } from 'react';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { useApi } from '@/components/hooks';
|
||||
import { setUser } from '@/store/app';
|
||||
import { removeClientAuthToken } from '@/lib/client';
|
||||
import { removeClientAuthToken, removeClientPreferences } from '@/lib/client';
|
||||
|
||||
export function LogoutPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -17,6 +17,7 @@ export function LogoutPage() {
|
|||
}
|
||||
|
||||
removeClientAuthToken();
|
||||
removeClientPreferences();
|
||||
setUser(null);
|
||||
logout();
|
||||
}, [router, post]);
|
||||
|
|
|
|||
|
|
@ -82,3 +82,4 @@ export * from './useRegionNames';
|
|||
export * from './useSlug';
|
||||
export * from './useSticky';
|
||||
export * from './useTimezone';
|
||||
export * from './usePreferences';
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useTheme } from '@umami/react-zen';
|
||||
import { useApp, setUser } from '@/store/app';
|
||||
import { useApi } from '../useApi';
|
||||
import { setClientPreferences } from '@/lib/client';
|
||||
import { DEFAULT_THEME } from '@/lib/constants';
|
||||
|
||||
const selector = (state: { user: any }) => state.user;
|
||||
|
||||
export function useLoginQuery() {
|
||||
const { post, useQuery } = useApi();
|
||||
const user = useApp(selector);
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['login'],
|
||||
queryFn: async () => {
|
||||
const data = await post('/auth/verify');
|
||||
|
||||
if (data.preferences) {
|
||||
setClientPreferences(data.preferences);
|
||||
}
|
||||
|
||||
setUser(data);
|
||||
|
||||
return data;
|
||||
|
|
@ -19,5 +28,12 @@ export function useLoginQuery() {
|
|||
enabled: !user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data?.preferences !== undefined) {
|
||||
const themeValue = query.data.preferences.theme || DEFAULT_THEME;
|
||||
setTheme(themeValue);
|
||||
}
|
||||
}, [query.data, setTheme]);
|
||||
|
||||
return { user, setUser, ...query };
|
||||
}
|
||||
|
|
|
|||
29
src/components/hooks/usePreferences.ts
Normal file
29
src/components/hooks/usePreferences.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { User } from '@/generated/prisma/client';
|
||||
import { useApi } from './useApi';
|
||||
import { useApp } from '@/store/app';
|
||||
|
||||
const userSelector = (state: { user: User }) => state.user;
|
||||
|
||||
export function usePreferences() {
|
||||
const { post } = useApi();
|
||||
const user = useApp(userSelector);
|
||||
|
||||
const updatePreferences = async (preferences: {
|
||||
dateRange?: string | null;
|
||||
timezone?: string | null;
|
||||
language?: string | null;
|
||||
theme?: string | null;
|
||||
}) => {
|
||||
if (!user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await post(`/users/${user.id}/preferences`, preferences);
|
||||
} catch {
|
||||
// Silent fail: sync next login
|
||||
}
|
||||
};
|
||||
|
||||
return { updatePreferences };
|
||||
}
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
import { getItem, setItem, removeItem } from '@/lib/storage';
|
||||
import { AUTH_TOKEN } from './constants';
|
||||
import {
|
||||
AUTH_TOKEN,
|
||||
LOCALE_CONFIG,
|
||||
TIMEZONE_CONFIG,
|
||||
DATE_RANGE_CONFIG,
|
||||
THEME_CONFIG,
|
||||
} from './constants';
|
||||
import { setLocale, setTimezone, setDateRangeValue } from '@/store/app';
|
||||
|
||||
export function getClientAuthToken() {
|
||||
return getItem(AUTH_TOKEN);
|
||||
|
|
@ -12,3 +19,54 @@ export function setClientAuthToken(token: string) {
|
|||
export function removeClientAuthToken() {
|
||||
removeItem(AUTH_TOKEN);
|
||||
}
|
||||
|
||||
export function setClientPreferences(preferences: {
|
||||
dateRange?: string | null;
|
||||
timezone?: string | null;
|
||||
language?: string | null;
|
||||
theme?: string | null;
|
||||
}) {
|
||||
const { dateRange, timezone, language, theme } = preferences;
|
||||
|
||||
if (dateRange !== undefined) {
|
||||
if (dateRange === null) {
|
||||
removeItem(DATE_RANGE_CONFIG);
|
||||
} else {
|
||||
setItem(DATE_RANGE_CONFIG, dateRange);
|
||||
setDateRangeValue(dateRange);
|
||||
}
|
||||
}
|
||||
|
||||
if (timezone !== undefined) {
|
||||
if (timezone === null) {
|
||||
removeItem(TIMEZONE_CONFIG);
|
||||
} else {
|
||||
setItem(TIMEZONE_CONFIG, timezone);
|
||||
setTimezone(timezone);
|
||||
}
|
||||
}
|
||||
|
||||
if (language !== undefined) {
|
||||
if (language === null) {
|
||||
removeItem(LOCALE_CONFIG);
|
||||
} else {
|
||||
setItem(LOCALE_CONFIG, language);
|
||||
setLocale(language);
|
||||
}
|
||||
}
|
||||
|
||||
if (theme !== undefined) {
|
||||
if (theme === null) {
|
||||
removeItem(THEME_CONFIG);
|
||||
} else {
|
||||
setItem(THEME_CONFIG, theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removeClientPreferences() {
|
||||
removeItem(DATE_RANGE_CONFIG);
|
||||
removeItem(TIMEZONE_CONFIG);
|
||||
removeItem(LOCALE_CONFIG);
|
||||
removeItem(THEME_CONFIG);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export const AUTH_TOKEN = 'umami.auth';
|
|||
export const LOCALE_CONFIG = 'umami.locale';
|
||||
export const TIMEZONE_CONFIG = 'umami.timezone';
|
||||
export const DATE_RANGE_CONFIG = 'umami.date-range';
|
||||
export const THEME_CONFIG = 'umami.theme';
|
||||
export const THEME_CONFIG = 'zen.theme';
|
||||
export const DASHBOARD_CONFIG = 'umami.dashboard';
|
||||
export const LAST_TEAM_CONFIG = 'umami.last-team';
|
||||
export const VERSION_CHECK = 'umami.version-check';
|
||||
|
|
|
|||
|
|
@ -203,3 +203,40 @@ export async function deleteUser(userId: string) {
|
|||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getUserPreferences(userId: string) {
|
||||
return prisma.client.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
dateRange: true,
|
||||
timezone: true,
|
||||
language: true,
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUserPreferences(
|
||||
userId: string,
|
||||
data: {
|
||||
dateRange?: string;
|
||||
timezone?: string;
|
||||
language?: string;
|
||||
theme?: string;
|
||||
},
|
||||
) {
|
||||
return prisma.client.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data,
|
||||
select: {
|
||||
dateRange: true,
|
||||
timezone: true,
|
||||
language: true,
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue