add aime card component

This commit is contained in:
sk1982 2024-03-20 18:01:52 -04:00
parent d961ee9a4b
commit c8a3bf7038
9 changed files with 220 additions and 5 deletions

9
package-lock.json generated
View File

@ -27,6 +27,7 @@
"react": "^18", "react": "^18",
"react-day-picker": "^8.10.0", "react-day-picker": "^8.10.0",
"react-dom": "^18", "react-dom": "^18",
"react-icons": "^5.0.1",
"react-virtualized": "^9.22.5", "react-virtualized": "^9.22.5",
"sass": "^1.71.1", "sass": "^1.71.1",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
@ -9582,6 +9583,14 @@
"react": "^18.2.0" "react": "^18.2.0"
} }
}, },
"node_modules/react-icons": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.0.1.tgz",
"integrity": "sha512-WqLZJ4bLzlhmsvme6iFdgO8gfZP17rfjYEJ2m9RsZjZ+cc4k1hTzknEz63YS1MeT50kVzoa1Nz36f4BEx+Wigw==",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@ -33,6 +33,7 @@
"react": "^18", "react": "^18",
"react-day-picker": "^8.10.0", "react-day-picker": "^8.10.0",
"react-dom": "^18", "react-dom": "^18",
"react-icons": "^5.0.1",
"react-virtualized": "^9.22.5", "react-virtualized": "^9.22.5",
"sass": "^1.71.1", "sass": "^1.71.1",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",

View File

@ -6,15 +6,30 @@ import { auth, signIn, signOut } from '@/auth';
import { AuthError } from 'next-auth'; import { AuthError } from 'next-auth';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { db } from '@/db'; import { db } from '@/db';
import { UserPermissions } from '@/types/permissions';
import { requirePermission } from '@/helpers/permissions';
import { UserPayload } from '@/types/user';
export const getUser = async () => { export const getUser = async () => {
const session = await auth(); const session = await auth();
return session?.user; return session?.user;
}; };
export const requireUser = async () => { type RequireUserOptions = {
return await getUser() ?? permission?: UserPermissions[] | UserPermissions
redirect(`/auth/login?error=1&callbackUrl=${encodeURIComponent(new URL(headers().get('referer') ?? 'http://a/').pathname)}`); };
export const requireUser = async (opts?: RequireUserOptions): Promise<UserPayload> => {
const user = await getUser();
if (!user) {
return redirect(`/auth/login?error=1&callbackUrl=${encodeURIComponent(headers().get('x-path') ?? '/')}`)
}
if (opts?.permission !== undefined)
requirePermission(user.permissions, ...(Array.isArray(opts.permission) ? opts.permission : [opts.permission]));
return user;
}; };
type LoginOptions = { type LoginOptions = {

41
src/actions/card.ts Normal file
View File

@ -0,0 +1,41 @@
'use server';
import { requireUser } from '@/actions/auth';
import { db } from '@/db';
import { UserPermissions } from '@/types/permissions';
import { requirePermission } from '@/helpers/permissions';
export const getCards = async () => {
const user = await requireUser();
return db.selectFrom('aime_card')
.where('user', '=', user.id)
.selectAll()
.execute();
};
export const banUnbanCard = async (opts: { cardId: number, userId: number, isBan: boolean }) => {
await requireUser({ permission: UserPermissions.USERMOD });
await db.updateTable('aime_card')
.set({ is_banned: +opts.isBan })
.where(({ and, eb }) => and([
eb('id', '=', opts.cardId),
eb('user', '=', opts.userId)
]))
.executeTakeFirst();
};
export const lockUnlockCard = async (opts: { cardId: number, userId: number, isLock: boolean }) => {
const user = await requireUser();
if (opts.userId !== user.id)
requirePermission(user.permissions, UserPermissions.USERMOD);
await db.updateTable('aime_card')
.set({ is_locked: +opts.isLock })
.where(({ and, eb }) => and([
eb('id', '=', opts.cardId),
eb('user', '=', opts.userId)
]))
.executeTakeFirst();
};

View File

@ -1,3 +1,20 @@
export default function SettingsPage() { import { getCards } from '@/actions/card';
return (<div>settings</div>); import { Divider } from '@nextui-org/react';
import { AimeCard } from '@/components/aime-card';
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 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" />
<div className="px-1 sm:px-4 sm:pb-4 flex flex-wrap items-center justify-center gap-4">
{card.map(c => <AimeCard key={c.id} card={c} className="w-full" />)}
</div>
</div>
</div>
</div>);
} }

View File

@ -0,0 +1,84 @@
'use client';
import { DB } from '@/types/db';
import { useState } from 'react';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { Button, Tooltip } from '@nextui-org/react';
import { useUser } from '@/helpers/use-user';
import { TbHammer, TbHammerOff, TbLock, TbLockOpen } from 'react-icons/tb';
import { hasPermission } from '@/helpers/permissions';
import { UserPermissions } from '@/types/permissions';
import { banUnbanCard, lockUnlockCard } from '@/actions/card';
type AimeCardProps = {
card: DB['aime_card'],
className?: string,
};
export const AimeCard = ({ className, card }: AimeCardProps) => {
const [showCode, setShowCode] = useState(false);
const user = useUser();
const canBan = hasPermission(user?.permissions, UserPermissions.USERMOD);
const [locked, setLocked] = useState(!!card.is_locked ?? false);
const [banned, setBanned] = useState(!!card.is_banned ?? true);
const formatOptions = {
year: '2-digit',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
} as const;
return (<div
className={`${className ?? ''} relative flex flex-col max-w-md aspect-[3.37/2.125] rounded-2xl shadow bg-gradient-to-tr from-pink-700 to-rose-600 text-white p-4 gap-4 transition-all ${locked || banned ? 'brightness-50' : ''} ${banned ? 'grayscale' : ''}`}>
<div className="flex gap-1">
<div className="mr-auto mt-0.5 text-sm sm:text-medium">
Registered {card.created_date?.toLocaleTimeString(undefined, formatOptions)}
</div>
{canBan && <Tooltip content={banned ? 'Unban card' : 'Ban card'} size="sm">
<Button isIconOnly variant="light" onPress={() => {
setBanned(!banned);
banUnbanCard({ cardId: card.id, userId: card.user, isBan: !banned })
}}>
{banned ? <TbHammerOff className="w-7 h-7 text-white" /> : <TbHammer className="w-7 h-7 text-white" />}
</Button>
</Tooltip>}
{!canBan && banned && <Tooltip content="This card is banned" size="sm">
<Button isIconOnly variant="light" disabled>
<TbHammer className="w-7 h-7 text-white" />
</Button>
</Tooltip>}
<Tooltip content={locked ? 'Unlock card' : 'Lock card'} size="sm">
<Button isIconOnly variant="light" onPress={() => {
setLocked(!locked);
lockUnlockCard({ cardId: card.id, userId: card.user, isLock: !locked });
}}>
{locked ? <TbLock className="w-7 h-7 text-white" /> : <TbLockOpen className="w-7 h-7 text-white" />}
</Button>
</Tooltip>
</div>
<div className={`[font-feature-settings:"fwid"] text-xs sm:text-medium font-semibold flex my-auto pb-4 sm:pb-0 gap-1 sm:gap-0 flex-wrap`}>
{showCode ? <>
{card.access_code?.match(/.{4}/g)?.join('-')}
<EyeSlashIcon className="h-6 cursor-pointer ml-auto" onClick={() => setShowCode(false)} />
</> :
<>
{('****-'.repeat(4) + card.access_code?.slice(-4))}
<EyeIcon className="h-6 cursor-pointer ml-auto" onClick={() => setShowCode(true)} />
</>}
</div>
<div className="text-sm sm:text-medium">
Last Used {card.last_login_date?.toLocaleTimeString(undefined, formatOptions)}
</div>
{(locked || banned) && <div className="absolute flex items-center justify-center w-full left-0 top-[60%] backdrop-blur h-12 sm:h-16 bg-gray-600/50 font-bold sm:text-2xl">
{locked ? 'Locked' : 'Banned'}
</div>}
</div>);
};

View File

@ -0,0 +1,22 @@
import { ArcadePermissions, UserPermissions } from '@/types/permissions';
import { redirect } from 'next/navigation';
export const hasPermission = <T extends UserPermissions | ArcadePermissions>(userPermission: number | null | undefined, ...requestedPermission: T[]) => {
if (!userPermission)
return false;
if (userPermission & (1 << UserPermissions.OWNER))
return true;
const permissionMask = requestedPermission
.reduce((mask, permission) => mask | (1 << permission), 0);
return (userPermission & permissionMask) === permissionMask;
}
export const requirePermission = <T extends UserPermissions | ArcadePermissions>(userPermission: number | null | undefined, ...requestedPermission: T[]) => {
if (!hasPermission(userPermission, ...requestedPermission))
redirect('/unauthorized');
}

9
src/middleware.ts Normal file
View File

@ -0,0 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const headers = new Headers(request.headers);
headers.set('x-path', request.nextUrl.basePath + request.nextUrl.pathname);
return NextResponse.next({
request: { headers }
});
}

17
src/types/permissions.ts Normal file
View File

@ -0,0 +1,17 @@
export const enum UserPermissions {
USER = 0, // regular user
USERMOD = 1, // can moderate other users
ACMOD = 2, // can add arcades and cabs
SYSADMIN = 3, // can change settings
OWNER = 7 // can do anything
}
export const enum ArcadePermissions {
VIEW = 0, // view info and cabs
BOOKKEEP = 1, // view bookkeeping info
EDITOR = 2, // can edit name, settings
REGISTRAR = 3, // can add cabs
OWNER = 7 // can do anything
}