add user settings
This commit is contained in:
parent
7d164a2a12
commit
35b7e0bb60
@ -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();
|
||||
};
|
||||
|
@ -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" />
|
||||
|
@ -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>}
|
||||
|
68
src/components/user-settings.tsx
Normal file
68
src/components/user-settings.tsx
Normal 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>);
|
||||
};
|
@ -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 }))
|
||||
}));
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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>();
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user