add user settings

This commit is contained in:
sk1982 2024-03-28 03:17:33 -04:00
parent 7d164a2a12
commit 35b7e0bb60
7 changed files with 213 additions and 17 deletions

View File

@ -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<UserUpdate, UserPayload>()
.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();
};

View File

@ -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 (<div className="w-full flex items-center justify-center">
<div className="w-full max-w-full sm:max-w-5xl flex flex-col">
<div className="w-full max-w-full sm:max-w-5xl flex flex-col gap-2 2xl:max-w-screen-4xl 2xl:grid grid-cols-2">
<div>
<UserSettings />
</div>
<Divider className="block sm:hidden mt-2" />
<div className="w-full rounded-lg sm:bg-content1 sm:shadow-lg">
<div className="text-2xl font-semibold p-4">Cards</div>
<Divider className="mb-4 hidden sm:block" />

View File

@ -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)}
</div>}
<div className="hidden md:flex">
<Link href={routeGroup === MAIN_ROUTES ? '/settings' : `/settings?from=${encodeURIComponent(routeGroup.title)}`}>
<Link href="/settings">
{user && <Button isIconOnly variant="bordered" size="sm" className="mr-2">
<AdjustmentsHorizontalIcon className="w-6" />
</Button>}

View File

@ -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 (<section className="w-full rounded-lg sm:bg-content1 sm:shadow-lg">
<header className="text-2xl font-semibold px-4 items-center h-16 flex">
Settings
{!saved && <Button className="ml-auto" color="primary" isDisabled={loading} onPress={() => {
setLoading(true);
setUserSettings({ visibility, homepage })
.then(res => {
if (res?.error)
setError(res.message);
else
setSaved(true);
})
.finally(() => setLoading(false));
}}>Save</Button>}
</header>
<Divider className="mb-4 hidden sm:block" />
<section className="px-3 pb-4 flex flex-col gap-4">
<Select label="Homepage" labelPlacement="outside" placeholder="Default"
isDisabled={loading}
onSelectionChange={k => {
if (typeof k === 'string') return;
const val = [...k][0];
setHomepage(val?.toString() ?? null);
setSaved(false);
}}
selectedKeys={new Set(homepage ? [homepage] : [])}>
{homepageRoutes.map(({ name, routes }, i) => (<SelectSection key={i} title={name}
showDivider={i < homepageRoutes.length - 1}>
{routes.map(({ name: subrouteName, url }) => (<SelectItem key={url} value={url} textValue={`${name}${subrouteName}`}>
{subrouteName}
</SelectItem>))}
</SelectSection>))}
</Select>
<div className="flex flex-col gap-1">
<span className="text-sm mb-0.5">Profile Visibility</span>
{[...USER_VISIBILITY_NAMES].map(([mask, name]) => (<Checkbox key={mask} isSelected={!!(mask & visibility)}
isDisabled={loading ||
(mask !== UserVisibility.EVERYONE && !!(visibility & UserVisibility.EVERYONE))}
onValueChange={s => {
setVisibility(v => s ? (v | mask) : (v & ~mask));
setSaved(false);
}}>
{name}
</Checkbox>))}
</div>
</section>
</section>);
};

View File

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

View File

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

View File

@ -1,11 +1,74 @@
import { Awaitable } from '@/types/awaitable';
import { Entries } from 'type-fest';
type Validator<T, K extends keyof T, D> = undefined extends D ?
(val: T[K]) => Awaitable<T[K] | null | undefined> | void :
(val: T[K], data: D) => Awaitable<T[K] | null | undefined> | void;
(val: NonNullable<Required<T>[K]>) => Awaitable<T[K] | null | undefined> | void :
(val: NonNullable<Required<T>[K]>, data: D) => Awaitable<T[K] | null | undefined> | void;
export type ValidatorMap<T, D=undefined> = {
set: <K extends keyof T>(key: K, val: Validator<T, K, D>) => void,
get: <K extends keyof T>(key: K) => Validator<T, K, D> | undefined,
set: <K extends keyof T>(key: K, val: Validator<Required<T>, K, D>) => void,
get: <K extends keyof T>(key: K) => Validator<Required<T>, K, D> | undefined,
has: (key: string) => boolean
};
type ValidationResult<T> = { error: true, message: string; } | { error: false, value: T; };
type ValidatorChecker<T, R, D = undefined> = undefined extends D ? (val: T) => Promise<ValidationResult<R>> :
(val: T, data: D) => Promise<ValidationResult<R>>;
type ValidationInput<T> = { [K in keyof T]?: T[K] | null };
type Validated<T, NonNullableKeys extends keyof T, RequiredKeys extends keyof T> = { [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<T extends object, D, NonNullableKeys extends keyof T, RequiredKeys extends keyof T> {
private _nonNullableKeys: ((keyof T) & string)[] = [];
private _requiredKeys: ((keyof T) & string)[] = [];
private validatorMap: ValidatorMap<ValidationInput<T>, D> = new Map();
withValidator<K extends keyof T>(key: K, validator: Validator<Required<ValidationInput<T>>, K, D>): this{
this.validatorMap.set(key, validator);
return this;
}
requiredKeys<K extends keyof T = never>(...keys: (string & K)[]) {
this._requiredKeys = keys;
return this as ValidatorBuilder<T, D, NonNullableKeys, K>;
}
nonNullableKeys<K extends keyof T = never>(...keys: (string & K)[]) {
this._nonNullableKeys = keys;
return this as ValidatorBuilder<T, D, K, RequiredKeys>;
}
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<T> = {};
for (let [key, val] of (Object.entries(value) as Entries<ValidationInput<T>>)) {
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<T>] = val as any;
}
return { error: false, value: update };
}) as ValidatorChecker<ValidationInput<T>, Validated<T, NonNullableKeys, RequiredKeys>, D>;
}
export const makeValidator = <T extends object, D>() => {
return new ValidatorBuilder<T, D, never, never>();
};