Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions prisma/migrations/15_add_user_preferences/migration.sql
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 4 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion src/app/(main)/settings/preferences/DateRangeSetting.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
15 changes: 12 additions & 3 deletions src/app/(main)/settings/preferences/LanguageSetting.tsx
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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) {
Expand All @@ -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}
Expand Down
24 changes: 22 additions & 2 deletions src/app/(main)/settings/preferences/ThemeSetting.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
import { Row, Button, Icon, useTheme } from '@umami/react-zen';
import { useMessages, usePreferences } from '@/components/hooks';
import { Sun, Moon } from '@/components/icons';

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('light');
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>
);
}
15 changes: 12 additions & 3 deletions src/app/(main)/settings/preferences/TimezoneSetting.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
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');

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) {
Expand All @@ -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}
Expand Down
6 changes: 4 additions & 2 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 },
});
}
5 changes: 3 additions & 2 deletions src/app/api/auth/verify/route.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 changes: 50 additions & 0 deletions src/app/api/users/[userId]/preferences/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
13 changes: 12 additions & 1 deletion src/app/login/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion src/app/logout/LogoutPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -17,6 +17,7 @@ export function LogoutPage() {
}

removeClientAuthToken();
removeClientPreferences();
setUser(null);
logout();
}, [router, post]);
Expand Down
1 change: 1 addition & 0 deletions src/components/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,4 @@ export * from './useRegionNames';
export * from './useSlug';
export * from './useSticky';
export * from './useTimezone';
export * from './usePreferences';
16 changes: 16 additions & 0 deletions src/components/hooks/queries/useLoginQuery.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,39 @@
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;
},
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 };
}
28 changes: 28 additions & 0 deletions src/components/hooks/usePreferences.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading