diff --git a/package-lock.json b/package-lock.json index 322e76e..014a393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react": "^18", "react-day-picker": "^8.10.0", "react-dom": "^18", + "react-icons": "^5.0.1", "react-virtualized": "^9.22.5", "sass": "^1.71.1", "tailwind-merge": "^2.2.1", @@ -9582,6 +9583,14 @@ "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": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 388470b..2957d7c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react": "^18", "react-day-picker": "^8.10.0", "react-dom": "^18", + "react-icons": "^5.0.1", "react-virtualized": "^9.22.5", "sass": "^1.71.1", "tailwind-merge": "^2.2.1", diff --git a/src/actions/auth.ts b/src/actions/auth.ts index 1ab205f..2eda26e 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -6,15 +6,30 @@ import { auth, signIn, signOut } from '@/auth'; import { AuthError } from 'next-auth'; import bcrypt from 'bcrypt'; import { db } from '@/db'; +import { UserPermissions } from '@/types/permissions'; +import { requirePermission } from '@/helpers/permissions'; +import { UserPayload } from '@/types/user'; export const getUser = async () => { const session = await auth(); return session?.user; }; -export const requireUser = async () => { - return await getUser() ?? - redirect(`/auth/login?error=1&callbackUrl=${encodeURIComponent(new URL(headers().get('referer') ?? 'http://a/').pathname)}`); +type RequireUserOptions = { + permission?: UserPermissions[] | UserPermissions +}; + +export const requireUser = async (opts?: RequireUserOptions): Promise => { + 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 = { diff --git a/src/actions/card.ts b/src/actions/card.ts new file mode 100644 index 0000000..c0b6891 --- /dev/null +++ b/src/actions/card.ts @@ -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(); +}; diff --git a/src/app/(with-header)/settings/page.tsx b/src/app/(with-header)/settings/page.tsx index e334d8f..580b87c 100644 --- a/src/app/(with-header)/settings/page.tsx +++ b/src/app/(with-header)/settings/page.tsx @@ -1,3 +1,20 @@ -export default function SettingsPage() { - return (
settings
); +import { getCards } from '@/actions/card'; +import { Divider } from '@nextui-org/react'; +import { AimeCard } from '@/components/aime-card'; + +export default async function SettingsPage() { + const card = await getCards(); + + + return (
+
+
+
Cards
+ +
+ {card.map(c => )} +
+
+
+
); } diff --git a/src/components/aime-card.tsx b/src/components/aime-card.tsx new file mode 100644 index 0000000..b8f9398 --- /dev/null +++ b/src/components/aime-card.tsx @@ -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 (
+
+
+ Registered {card.created_date?.toLocaleTimeString(undefined, formatOptions)} +
+ + {canBan && + + } + + {!canBan && banned && + + } + + + + +
+ +
+ {showCode ? <> + {card.access_code?.match(/.{4}/g)?.join('-')} + setShowCode(false)} /> + : + <> + {('****-'.repeat(4) + card.access_code?.slice(-4))} + setShowCode(true)} /> + } +
+ +
+ Last Used {card.last_login_date?.toLocaleTimeString(undefined, formatOptions)} +
+ + {(locked || banned) &&
+ {locked ? 'Locked' : 'Banned'} +
} +
); +}; diff --git a/src/helpers/permissions.ts b/src/helpers/permissions.ts new file mode 100644 index 0000000..abc8dd8 --- /dev/null +++ b/src/helpers/permissions.ts @@ -0,0 +1,22 @@ +import { ArcadePermissions, UserPermissions } from '@/types/permissions'; +import { redirect } from 'next/navigation'; + + + +export const hasPermission = (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 = (userPermission: number | null | undefined, ...requestedPermission: T[]) => { + if (!hasPermission(userPermission, ...requestedPermission)) + redirect('/unauthorized'); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..28b21ff --- /dev/null +++ b/src/middleware.ts @@ -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 } + }); +} diff --git a/src/types/permissions.ts b/src/types/permissions.ts new file mode 100644 index 0000000..52d8306 --- /dev/null +++ b/src/types/permissions.ts @@ -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 +}