From 60ac63604fd53dabd554952c815f456422971ccf Mon Sep 17 00:00:00 2001 From: Clemens <31499125+ceviixx@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:32:57 +0100 Subject: [PATCH 1/2] feat: persist user preferences to database --- .../15_add_user_preferences/migration.sql | 5 ++ prisma/schema.prisma | 4 ++ .../settings/preferences/DateRangeSetting.tsx | 5 +- .../settings/preferences/LanguageSetting.tsx | 15 ++++- .../settings/preferences/ThemeSetting.tsx | 24 +++++++- .../settings/preferences/TimezoneSetting.tsx | 15 ++++- src/app/api/auth/login/route.ts | 6 +- src/app/api/auth/verify/route.ts | 5 +- .../api/users/[userId]/preferences/route.ts | 50 ++++++++++++++++ src/app/login/LoginForm.tsx | 13 +++- src/app/logout/LogoutPage.tsx | 3 +- src/components/hooks/index.ts | 1 + src/components/hooks/queries/useLoginQuery.ts | 16 +++++ src/components/hooks/usePreferences.ts | 28 +++++++++ src/lib/client.ts | 59 ++++++++++++++++++- src/lib/constants.ts | 2 +- src/queries/prisma/user.ts | 37 ++++++++++++ 17 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 prisma/migrations/15_add_user_preferences/migration.sql create mode 100644 src/app/api/users/[userId]/preferences/route.ts create mode 100644 src/components/hooks/usePreferences.ts diff --git a/prisma/migrations/15_add_user_preferences/migration.sql b/prisma/migrations/15_add_user_preferences/migration.sql new file mode 100644 index 00000000..85eefe73 --- /dev/null +++ b/prisma/migrations/15_add_user_preferences/migration.sql @@ -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); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aeb11648..e0ed2cff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/app/(main)/settings/preferences/DateRangeSetting.tsx b/src/app/(main)/settings/preferences/DateRangeSetting.tsx index c0e8221e..6409f9f7 100644 --- a/src/app/(main)/settings/preferences/DateRangeSetting.tsx +++ b/src/app/(main)/settings/preferences/DateRangeSetting.tsx @@ -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 ( diff --git a/src/app/(main)/settings/preferences/LanguageSetting.tsx b/src/app/(main)/settings/preferences/LanguageSetting.tsx index 0bcaa6ba..c5deca3e 100644 --- a/src/app/(main)/settings/preferences/LanguageSetting.tsx +++ b/src/app/(main)/settings/preferences/LanguageSetting.tsx @@ -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() { saveTimezone(value)} + onChange={handleChange} allowSearch={true} onSearch={setSearch} onOpenChange={handleOpen} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 2c312a91..dcb3eefe 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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 }, }); } diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts index b308b7b6..a8c85b08 100644 --- a/src/app/api/auth/verify/route.ts +++ b/src/app/api/auth/verify/route.ts @@ -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 }); } diff --git a/src/app/api/users/[userId]/preferences/route.ts b/src/app/api/users/[userId]/preferences/route.ts new file mode 100644 index 00000000..b90a75ba --- /dev/null +++ b/src/app/api/users/[userId]/preferences/route.ts @@ -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); +} diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index c1c2c431..2496b224 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -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'); diff --git a/src/app/logout/LogoutPage.tsx b/src/app/logout/LogoutPage.tsx index 909f35de..2126cfe5 100644 --- a/src/app/logout/LogoutPage.tsx +++ b/src/app/logout/LogoutPage.tsx @@ -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]); diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 04c3e37d..37ea114f 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -82,3 +82,4 @@ export * from './useRegionNames'; export * from './useSlug'; export * from './useSticky'; export * from './useTimezone'; +export * from './usePreferences'; diff --git a/src/components/hooks/queries/useLoginQuery.ts b/src/components/hooks/queries/useLoginQuery.ts index e800621a..42b8f107 100644 --- a/src/components/hooks/queries/useLoginQuery.ts +++ b/src/components/hooks/queries/useLoginQuery.ts @@ -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 }; } diff --git a/src/components/hooks/usePreferences.ts b/src/components/hooks/usePreferences.ts new file mode 100644 index 00000000..6e95f8b4 --- /dev/null +++ b/src/components/hooks/usePreferences.ts @@ -0,0 +1,28 @@ +import { useApi } from './useApi'; +import { useApp } from '@/store/app'; + +const userSelector = (state: { user: any }) => 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 }; +} diff --git a/src/lib/client.ts b/src/lib/client.ts index 795e7780..7113407b 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -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,53 @@ 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(THEME_CONFIG); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 50a25b8d..8832b258 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 VERSION_CHECK = 'umami.version-check'; export const SHARE_TOKEN_HEADER = 'x-umami-share-token'; diff --git a/src/queries/prisma/user.ts b/src/queries/prisma/user.ts index c599e386..04419bb4 100644 --- a/src/queries/prisma/user.ts +++ b/src/queries/prisma/user.ts @@ -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, + }, + }); +} From 8e91edd9d846e4fb2bcaa0cee10b859ed37f8270 Mon Sep 17 00:00:00 2001 From: Clemens <31499125+ceviixx@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:35:05 +0100 Subject: [PATCH 2/2] fix: address code review comments - Use DEFAULT_THEME constant for theme reset - Type user selector properly - Include LOCALE_CONFIG in preferences cleanup - Add newline to migration file --- prisma/migrations/15_add_user_preferences/migration.sql | 2 +- src/app/(main)/settings/preferences/ThemeSetting.tsx | 3 ++- src/components/hooks/usePreferences.ts | 3 ++- src/lib/client.ts | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/prisma/migrations/15_add_user_preferences/migration.sql b/prisma/migrations/15_add_user_preferences/migration.sql index 85eefe73..b5d6e9db 100644 --- a/prisma/migrations/15_add_user_preferences/migration.sql +++ b/prisma/migrations/15_add_user_preferences/migration.sql @@ -2,4 +2,4 @@ ALTER TABLE "user" ADD COLUMN "date_range" VARCHAR(50), ADD COLUMN "timezone" VARCHAR(100), ADD COLUMN "language" VARCHAR(10), -ADD COLUMN "theme" VARCHAR(20); \ No newline at end of file +ADD COLUMN "theme" VARCHAR(20); diff --git a/src/app/(main)/settings/preferences/ThemeSetting.tsx b/src/app/(main)/settings/preferences/ThemeSetting.tsx index 1b1efd43..f4b0fda2 100644 --- a/src/app/(main)/settings/preferences/ThemeSetting.tsx +++ b/src/app/(main)/settings/preferences/ThemeSetting.tsx @@ -1,6 +1,7 @@ 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(); @@ -13,7 +14,7 @@ export function ThemeSetting() { }; const handleReset = () => { - setTheme('light'); + setTheme(DEFAULT_THEME); updatePreferences({ theme: null }); }; diff --git a/src/components/hooks/usePreferences.ts b/src/components/hooks/usePreferences.ts index 6e95f8b4..06d5c784 100644 --- a/src/components/hooks/usePreferences.ts +++ b/src/components/hooks/usePreferences.ts @@ -1,7 +1,8 @@ +import { User } from '@/generated/prisma/client'; import { useApi } from './useApi'; import { useApp } from '@/store/app'; -const userSelector = (state: { user: any }) => state.user; +const userSelector = (state: { user: User }) => state.user; export function usePreferences() { const { post } = useApi(); diff --git a/src/lib/client.ts b/src/lib/client.ts index 7113407b..8fe190e2 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -67,5 +67,6 @@ export function setClientPreferences(preferences: { export function removeClientPreferences() { removeItem(DATE_RANGE_CONFIG); removeItem(TIMEZONE_CONFIG); + removeItem(LOCALE_CONFIG); removeItem(THEME_CONFIG); }