This commit is contained in:
clumsy 2025-11-29 22:00:22 +00:00 committed by GitHub
commit a64df3c2bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 274 additions and 17 deletions

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

View file

@ -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)

View file

@ -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 (

View file

@ -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}

View file

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

View file

@ -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}

View file

@ -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 },
});
}

View file

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

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

View file

@ -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');

View file

@ -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]);

View file

@ -82,3 +82,4 @@ export * from './useRegionNames';
export * from './useSlug';
export * from './useSticky';
export * from './useTimezone';
export * from './usePreferences';

View file

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

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

View file

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

View file

@ -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';

View file

@ -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,
},
});
}