From 35b7e0bb60217c393c3a81415d92e43f499dd8a3 Mon Sep 17 00:00:00 2001 From: sk1982 Date: Thu, 28 Mar 2024 03:17:33 -0400 Subject: [PATCH] add user settings --- src/actions/user.ts | 33 ++++++++++++ src/app/(with-header)/settings/page.tsx | 9 +++- src/components/header-sidebar.tsx | 14 +---- src/components/user-settings.tsx | 68 +++++++++++++++++++++++ src/routes.ts | 24 +++++++++ src/types/user.ts | 11 ++++ src/types/validator-map.ts | 71 +++++++++++++++++++++++-- 7 files changed, 213 insertions(+), 17 deletions(-) create mode 100644 src/components/user-settings.tsx diff --git a/src/actions/user.ts b/src/actions/user.ts index 1ec30d5..5df1ee2 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -6,6 +6,9 @@ import { USER_PERMISSION_MASK, UserPermissions } from '@/types/permissions'; import { getUsers } from '@/data/user'; import { hasPermission } from '@/helpers/permissions'; import { ActionResult } from '@/types/action-result'; +import { makeValidator } from '@/types/validator-map'; +import { USER_VISIBILITY_MASK, UserPayload } from '@/types/user'; +import { getValidHomepageRoutes } from '@/routes'; export const createUserWithAccessCode = async (code: string) => { await requireUser({ permission: UserPermissions.USERMOD }); @@ -83,3 +86,33 @@ export const setUserPermissions = async (user: number, permissions: number) => { })) .executeTakeFirst(); }; + +export type UserUpdate = Partial<{ + visibility: number, + homepage: string | null +}>; + +const validator = makeValidator() + .nonNullableKeys('visibility') + .withValidator('visibility', val => val & USER_VISIBILITY_MASK) + .withValidator('homepage', (val, user) => { + const validRoutes = getValidHomepageRoutes(user) + .flatMap(r => r.routes) + .map(r => r.url); + + if (!validRoutes.includes(val)) + throw new Error(`Invalid homepage url ${val}`); + }); + +export const setUserSettings = async (data: UserUpdate) => { + const user = await requireUser(); + const result = await validator.validate(data, user); + + if (result.error) + return result; + + await db.updateTable('actaeon_user_ext') + .set(result.value) + .where('userId', '=', user.id) + .executeTakeFirst(); +}; diff --git a/src/app/(with-header)/settings/page.tsx b/src/app/(with-header)/settings/page.tsx index 580b87c..8ca5d99 100644 --- a/src/app/(with-header)/settings/page.tsx +++ b/src/app/(with-header)/settings/page.tsx @@ -1,13 +1,20 @@ import { getCards } from '@/actions/card'; import { Divider } from '@nextui-org/react'; import { AimeCard } from '@/components/aime-card'; +import { UserSettings } from '@/components/user-settings'; export default async function SettingsPage() { const card = await getCards(); return (
-
+
+
+ +
+ + +
Cards
diff --git a/src/components/header-sidebar.tsx b/src/components/header-sidebar.tsx index 63be5cf..ddbb489 100644 --- a/src/components/header-sidebar.tsx +++ b/src/components/header-sidebar.tsx @@ -9,7 +9,7 @@ import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/solid'; import { login, logout } from '@/actions/auth'; import { usePathname, useRouter } from 'next/navigation'; import { UserPayload } from '@/types/user'; -import { MAIN_ROUTES, ROUTES, Subroute, UserOnly } from '@/routes'; +import { MAIN_ROUTES, ROUTES, Subroute, UserOnly, filterRoute } from '@/routes'; import { useUser } from '@/helpers/use-user'; import { useBreakpoint } from '@/helpers/use-breakpoint'; import { useCookies } from 'next-client-cookies'; @@ -21,16 +21,6 @@ export type HeaderSidebarProps = { children?: React.ReactNode, }; -const filterRoute = (user: UserPayload | null | undefined, { userOnly, permissions }: { userOnly?: UserOnly, permissions?: (UserPermissions | UserPermissions[])[] }) => { - if (typeof userOnly === 'string' && !user?.[userOnly]) - return false; - if (typeof userOnly === 'boolean' && !user) - return false; - if (permissions?.length && !hasPermission(user?.permissions, ...permissions)) - return false; - return true; -}; - export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { const user = useUser(); const pathname = usePathname(); @@ -165,7 +155,7 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { {MAIN_ROUTES.routes.filter(filter).map(renderHeaderLink)}
}
- + {user && } diff --git a/src/components/user-settings.tsx b/src/components/user-settings.tsx new file mode 100644 index 0000000..ae11981 --- /dev/null +++ b/src/components/user-settings.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { setUserSettings } from '@/actions/user'; +import { useUser } from '@/helpers/use-user'; +import { getValidHomepageRoutes } from '@/routes'; +import { USER_VISIBILITY_NAMES, UserVisibility } from '@/types/user'; +import { Button, Checkbox, CheckboxGroup, Divider, Select, SelectItem, SelectSection } from '@nextui-org/react'; +import { useState } from 'react'; +import { useErrorModal } from './error-modal'; + +export const UserSettings = () => { + const user = useUser({ required: true }); + const homepageRoutes = getValidHomepageRoutes(user); + const [homepage, setHomepage] = useState(user.homepage); + const [visibility, setVisibility] = useState(user.visibility); + const [saved, setSaved] = useState(true); + const [loading, setLoading] = useState(false); + const setError = useErrorModal(); + + return (
+
+ Settings + {!saved && } +
+ +
+ + +
+ Profile Visibility + {[...USER_VISIBILITY_NAMES].map(([mask, name]) => ( { + setVisibility(v => s ? (v | mask) : (v & ~mask)); + setSaved(false); + }}> + {name} + ))} +
+
+
); +}; \ No newline at end of file diff --git a/src/routes.ts b/src/routes.ts index e00d378..cc0be09 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,5 +1,6 @@ import { UserPayload } from '@/types/user'; import { UserPermissions } from '@/types/permissions'; +import { hasPermission } from './helpers/permissions'; export type UserOnly = boolean | keyof UserPayload; @@ -68,3 +69,26 @@ export const ROUTES: Route[] = [{ userOnly: 'chuni' }] }, MAIN_ROUTES]; + +export const filterRoute = (user: UserPayload | null | undefined, { userOnly, permissions }: { userOnly?: UserOnly, permissions?: (UserPermissions | UserPermissions[])[]; }) => { + if (typeof userOnly === 'string' && !user?.[userOnly]) + return false; + if (typeof userOnly === 'boolean' && !user) + return false; + if (permissions?.length && !hasPermission(user?.permissions, ...permissions)) + return false; + return true; +}; + +export const getValidHomepageRoutes = (user: UserPayload) => { + const filter = filterRoute.bind(null, user); + return [MAIN_ROUTES, ...ROUTES.slice(0, -1)].filter(filter) + .map(({ name, routes }) => ({ + name, + routes: routes.filter(filter) + .flatMap(r => [r, ...(r.routes?.filter(filter) + ?.map(d => ({ ...d, name: `${r.name}┃${d.name}` })) ?? [])]) + .filter(r => !r.url.startsWith('/admin')) + .map(({ name, url }) => ({ name, url })) + })); +}; diff --git a/src/types/user.ts b/src/types/user.ts index 9cc3c5d..c68b639 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -19,3 +19,14 @@ export const enum UserVisibility { LOGGED_IN = 8, EVERYONE = 16 } + +export const USER_VISIBILITY_NAMES = new Map([ + [UserVisibility.EVERYONE, 'Everyone'], + [UserVisibility.LOGGED_IN, 'Logged-in Users'], + [UserVisibility.ARCADE, 'Shared Arcade Members'], + [UserVisibility.TEAMMATES, 'Teammates'], + [UserVisibility.FRIENDS, 'Friends'], +]); + +export const USER_VISIBILITY_MASK = UserVisibility.FRIENDS | UserVisibility.TEAMMATES | + UserVisibility.ARCADE | UserVisibility.LOGGED_IN | UserVisibility.EVERYONE; diff --git a/src/types/validator-map.ts b/src/types/validator-map.ts index 0a058a2..026f45d 100644 --- a/src/types/validator-map.ts +++ b/src/types/validator-map.ts @@ -1,11 +1,74 @@ import { Awaitable } from '@/types/awaitable'; +import { Entries } from 'type-fest'; type Validator = undefined extends D ? - (val: T[K]) => Awaitable | void : - (val: T[K], data: D) => Awaitable | void; + (val: NonNullable[K]>) => Awaitable | void : + (val: NonNullable[K]>, data: D) => Awaitable | void; export type ValidatorMap = { - set: (key: K, val: Validator) => void, - get: (key: K) => Validator | undefined, + set: (key: K, val: Validator, K, D>) => void, + get: (key: K) => Validator, K, D> | undefined, has: (key: string) => boolean }; + +type ValidationResult = { error: true, message: string; } | { error: false, value: T; }; + +type ValidatorChecker = undefined extends D ? (val: T) => Promise> : + (val: T, data: D) => Promise>; + +type ValidationInput = { [K in keyof T]?: T[K] | null }; + +type Validated = { [K in keyof T]?: (K extends NonNullableKeys ? T[K] : T[K] | null) } & + { [K in RequiredKeys]-?: (K extends NonNullableKeys ? T[K] : T[K] | null) }; + +class ValidatorBuilder { + private _nonNullableKeys: ((keyof T) & string)[] = []; + private _requiredKeys: ((keyof T) & string)[] = []; + private validatorMap: ValidatorMap, D> = new Map(); + + withValidator(key: K, validator: Validator>, K, D>): this{ + this.validatorMap.set(key, validator); + return this; + } + + requiredKeys(...keys: (string & K)[]) { + this._requiredKeys = keys; + return this as ValidatorBuilder; + } + + nonNullableKeys(...keys: (string & K)[]) { + this._nonNullableKeys = keys; + return this as ValidatorBuilder; + } + + validate = (async (value, data) => { + for (const k of this._requiredKeys) { + if (!(k in value)) + return { error: true, message: `Key ${k} is required` }; + } + + const update: ValidationInput = {}; + + for (let [key, val] of (Object.entries(value) as Entries>)) { + if (!this.validatorMap.has(key as any)) + return { error: true, message: `Unknown key ${key}` }; + + if (val === undefined) val = null; + try { + if (val !== null) + val = (await this.validatorMap.get(key as any)!(val as any, data)) ?? val; + } catch (e: any) { + return { error: true, message: e?.message ?? 'Unknown error occurred' }; + } + if (val === null && this._nonNullableKeys?.includes(key as any)) + return { error: true, message: `Key ${key} is required` }; + update[key as keyof ValidationInput] = val as any; + } + + return { error: false, value: update }; + }) as ValidatorChecker, Validated, D>; +} + +export const makeValidator = () => { + return new ValidatorBuilder(); +};