diff --git a/src/actions/auth.ts b/src/actions/auth.ts new file mode 100644 index 0000000..1ab205f --- /dev/null +++ b/src/actions/auth.ts @@ -0,0 +1,119 @@ +'use server'; + +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { auth, signIn, signOut } from '@/auth'; +import { AuthError } from 'next-auth'; +import bcrypt from 'bcrypt'; +import { db } from '@/db'; + +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 LoginOptions = { + username?: string | null, + password?: string | null, + redirectTo?: string | null +} + +export const login = async (options?: LoginOptions) => { + if (!options) + return signIn(); + + try { + return await signIn('credentials', { + ...options, + redirectTo: options?.redirectTo ?? '/' + }); + } catch (e) { + if (e instanceof AuthError) { + if (e.type === 'CredentialsSignin') + return { error: true, message: 'Invalid username or password' }; + + return { error: true, message: 'Unknown log in error' }; + } + + throw e; + } +}; + +export const logout = async (options: { redirectTo?: string, redirect?: boolean }) => { + return signOut(options); +}; + +const emailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i; + +export const register = async (formData: FormData) => { + const username = formData.get('username')?.toString()?.trim(); + const password = formData.get('password')?.toString()?.trim(); + const email = formData.get('email')?.toString()?.trim(); + const password2 = formData.get('password2')?.toString()?.trim(); + const accessCode = formData.get('accessCode')?.toString()?.trim(); + + if (!username) + return { error: true, message: 'Username required' }; + if (!password) + return { error: true, message: 'Password required' }; + if (!email) + return { error: true, message: 'Email required' }; + if (!accessCode) + return { error: true, message: 'Access code required' }; + if (password !== password2) + return { error: true, message: 'Passwords do not match' }; + if (password.length < 8) + return { error: true, message: 'Password must be at least 8 characters' }; + if (!/^\d{20}$/.test(accessCode)) + return { error: true, message: 'Invalid access code format' }; + if (!emailRegex.test(email)) + return { error: true, message: 'Invalid email' }; + + const hashedPassword = await bcrypt.hash(password, process.env.BCRYPT_ROUNDS ? parseInt(process.env.BCRYPT_ROUNDS) : 12) + + const existingUser = await db.selectFrom('aime_user as u') + .leftJoin('aime_card as c', 'c.user', 'u.id') + .where(({eb, and, or, fn}) => + or([ + eb(fn('lower', ['u.username']), '=', username.toLowerCase()), + eb(fn('lower', ['u.email']), '=', email.toLowerCase()), + and([ + eb('c.access_code', '=', accessCode), + or([ + eb('u.username', 'is not', null), + eb('u.email', 'is not', null) + ]) + ]) + ]) + ) + .select('u.id') + .executeTakeFirst(); + + if (existingUser) + return { error: true, message: 'User already exists' }; + + const user = await db.selectFrom('aime_user as u') + .leftJoin('aime_card as c', 'c.user', 'u.id') + .where(eb => eb('c.access_code', '=', accessCode) + .and(eb('u.email', 'is', null)) + .and(eb('u.username', 'is', null))) + .select('u.id') + .executeTakeFirst(); + + if (!user) + return { error: true, message: 'Access code does not exist' }; + + await db.updateTable('aime_user') + .where('id', '=', user.id) + .set('username', username) + .set('password', hashedPassword) + .set('email', email) + .execute(); + + return { error: false }; +}; diff --git a/src/app/(centered)/auth/login/page.tsx b/src/app/(centered)/auth/login/page.tsx new file mode 100644 index 0000000..6b2832a --- /dev/null +++ b/src/app/(centered)/auth/login/page.tsx @@ -0,0 +1,10 @@ +import { PageProps } from '@/types/page'; +import { headers } from 'next/headers'; +import { LoginCard } from '@/components/login-card'; + +export default async function LoginPage({ searchParams }: PageProps) { + const referer = headers().get('referer'); + const callback = searchParams?.['callbackUrl']?.toString(); + + return (); +} diff --git a/src/app/(centered)/auth/register/page.tsx b/src/app/(centered)/auth/register/page.tsx new file mode 100644 index 0000000..e87152f --- /dev/null +++ b/src/app/(centered)/auth/register/page.tsx @@ -0,0 +1,6 @@ +import { PageProps } from '@/types/page'; +import { RegisterCard } from '@/components/register-card'; + +export default async function RegisterPage({ searchParams }: PageProps) { + return () +} diff --git a/src/app/(centered)/layout.tsx b/src/app/(centered)/layout.tsx new file mode 100644 index 0000000..ef160f0 --- /dev/null +++ b/src/app/(centered)/layout.tsx @@ -0,0 +1,7 @@ +import { LayoutProps } from '@/types/layout'; + +export default function CenteredLayout({ children }: LayoutProps) { + return
+ {children} +
+} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..517fb85 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,20 @@ +import { GET as authGet, POST as authPost } from '@/auth'; +import { NextRequest } from 'next/server'; + +let basePath = process.env.BASE_PATH ?? ''; +if (basePath.endsWith('/')) basePath = basePath.slice(0, -1); + +// https://github.com/vercel/next.js/issues/62756 +const fixPath = (request: NextRequest) => { + const newUrl = request.nextUrl.clone(); + // newUrl.pathname = `${basePath}${newUrl.pathname}`; + newUrl.basePath = basePath; + return new NextRequest(newUrl, request); +}; +export async function GET(request: NextRequest) { + return authGet(fixPath(request)); +} + +export async function POST(request: NextRequest) { + return authPost(fixPath(request)); +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..295cd4b --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,68 @@ +import NextAuth from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { db } from '@/db'; +import bcrypt from 'bcrypt'; +import { DBUserPayload } from '@/types/user'; + +let basePath = process.env.BASE_PATH ?? ''; +if (basePath.endsWith('/')) basePath = basePath.slice(0, -1); + +export const { + handlers: { GET, POST }, + auth, + signIn, + signOut +} = NextAuth({ + pages: { + signIn: `${basePath}/auth/login` + }, + basePath: `${basePath}/api/auth/`, + session: { + strategy: 'jwt' + }, + callbacks: { + jwt({ token, user }) { + token.user ??= user; + return token; + }, + session({ session, token, user }) { + session.user = { ...session.user, ...(token.user as any) }; + return session; + } + }, + providers: [CredentialsProvider({ + name: 'Credentials', + credentials: { + username: { label: 'Username', type: 'text', placeholder: 'Username' }, + password: { label: 'Password', type: 'password' } + }, + async authorize({ username, password }, req) { + if (typeof username !== 'string' || typeof password !== 'string') + return null; + + const user = await db.selectFrom('aime_user as u') + .where(({ eb, fn }) => + eb(fn('lower', ['u.username']), '=', username.toLowerCase().trim())) + .leftJoin( + eb => eb.selectFrom('chuni_profile_data as chuni') + .where(({ eb, selectFrom }) => eb('chuni.version', '=', selectFrom('chuni_static_music') + .select(({ fn }) => fn.max('version').as('latest')))) + .selectAll() + .as('chuni'), + join => join.onRef('chuni.user', '=', 'u.id')) + .select(({ fn }) => [ + 'u.username', 'u.password', 'u.id', 'u.email', 'u.permissions', 'u.created_date', 'u.last_login_date', + 'u.suspend_expire_time', + fn('not isnull', ['chuni.id']).as('chuni') + ]) + .executeTakeFirst(); + + if (!user?.password || !await bcrypt.compare(password.trim(), user.password)) + return null; + + const { password: _, ...payload } = user satisfies DBUserPayload; + + return payload as any; + } + })] +}); diff --git a/src/components/back-button.tsx b/src/components/back-button.tsx new file mode 100644 index 0000000..eb4f3aa --- /dev/null +++ b/src/components/back-button.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { Button, ButtonProps } from '@nextui-org/react'; + +type BackButtonProps = Partial & { + referer?: string | null +}; + +export const BackButton = ({ children, referer, ...props }: BackButtonProps) => { + if (referer === null) + return null; + + if (referer && globalThis.location && globalThis.location?.origin !== new URL(referer).origin) + return null; + + return (); +}; diff --git a/src/components/client-providers.tsx b/src/components/client-providers.tsx new file mode 100644 index 0000000..811a927 --- /dev/null +++ b/src/components/client-providers.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { ReactNode } from 'react'; +import { NextUIProvider } from '@nextui-org/react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; + +export function ClientProviders({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/components/login-card.tsx b/src/components/login-card.tsx new file mode 100644 index 0000000..d23cab0 --- /dev/null +++ b/src/components/login-card.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { Button, Card, CardBody, CardHeader, Checkbox, Divider, Input } from '@nextui-org/react'; +import { BackButton } from '@/components/back-button'; +import { ArrowLeftIcon } from '@heroicons/react/24/outline'; +import Link from 'next/link'; +import { useState } from 'react'; +import { login } from '@/actions/auth'; +import { redirect, useSearchParams } from 'next/navigation'; +import { useUser } from '@/helpers/use-user'; + +export type LoginCardProps = { + referer?: string | null, + callback?: string | null, + initialError?: string | null +}; + +export const LoginCard = ({ initialError, referer, callback }: LoginCardProps) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(initialError ? 'You must be logged in to do that.' : ''); + const user = useUser(); + + if (user) + return redirect(callback ?? '/'); + const submit = (form: FormData) => { + setLoading(true); + setError(''); + login({ + username: form.get('username') as string, + password: form.get('password') as string, + redirectTo: callback + }) + .then(res => { + if (res?.error) + setError(res.message) + }) + .finally(() => setLoading(false)); + }; + + return ( + + + + +
Login
+
+ + +
+ + +
+ + Register account + +
+ {error &&
+ {error} +
} + +
+
+
); +}; diff --git a/src/components/providers.tsx b/src/components/providers.tsx new file mode 100644 index 0000000..7448bfa --- /dev/null +++ b/src/components/providers.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; +import { CookiesProvider } from 'next-client-cookies/server'; +import { auth } from '@/auth'; +import { SessionProvider } from 'next-auth/react'; + +export async function Providers({ children }: { children: ReactNode }) { + return ( + {children} + ); +} diff --git a/src/components/register-card.tsx b/src/components/register-card.tsx new file mode 100644 index 0000000..92d3bb4 --- /dev/null +++ b/src/components/register-card.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Button, Card, CardBody, CardHeader, Divider, Input } from '@nextui-org/react'; +import { BackButton } from '@/components/back-button'; +import { ArrowLeftIcon } from '@heroicons/react/24/outline'; +import Link from 'next/link'; +import { useState } from 'react'; +import { useUser } from '@/helpers/use-user'; +import { redirect } from 'next/navigation'; +import { register } from '@/actions/auth'; + +export type RegisterCardProps = { + callback?: string | null, +}; + +export const RegisterCard = ({ callback }: RegisterCardProps) => { + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const user = useUser(); + + if (user) + return redirect(callback ?? '/'); + const submit = (data: FormData) => { + setLoading(true); + setError(''); + console.log(data); + register(data) + .then(({ error, message }) => { + if (!error) + return setSuccess(true); + setError(message ?? 'Unknown error occurred'); + }) + .finally(() => setLoading(false)); + }; + + return ( + + + + +
Register
+
+ + +
+ + + + + +
+ + Login + +
+ {error &&
+ {error} +
} + {success &&
+ Success! You may now log in. +
} + +
+
+
); +}; diff --git a/src/components/theme-switcher.tsx b/src/components/theme-switcher.tsx new file mode 100644 index 0000000..6440e32 --- /dev/null +++ b/src/components/theme-switcher.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useTheme } from 'next-themes'; +import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Switch } from '@nextui-org/react'; +import { MoonIcon, SunIcon } from '@heroicons/react/24/outline'; +import { useIsMounted } from 'usehooks-ts'; + +export function ThemeSwitcherDropdown() { + const { setTheme } = useTheme(); + + return ( + + + + + setTheme('dark')}> + Dark + + setTheme('light')}> + Light + + setTheme('system')}> + System + + + ); +} + +export function ThemeSwitcherSwitch() { + const { setTheme, theme } = useTheme(); + const mounted = useIsMounted(); + if (!mounted()) return null; + + return ( isSelected ? + : + } onChange={ev => setTheme(ev.target.checked ? 'dark' : 'light')} />); +} diff --git a/src/helpers/use-user.ts b/src/helpers/use-user.ts new file mode 100644 index 0000000..0ee693e --- /dev/null +++ b/src/helpers/use-user.ts @@ -0,0 +1,19 @@ +import { redirect, usePathname } from 'next/navigation'; +import { UserPayload } from '@/types/user'; +import { useSession } from 'next-auth/react'; + +type UseUserProps = { + required?: R +}; + +export const useUser = ({ required }: UseUserProps = {}): R extends true ? UserPayload : UserPayload | null | undefined => { + const session = useSession({ + required: required ?? false + }); + const path = usePathname(); + + if (required && !session.data?.user) + return redirect(`/auth/login?error=1&callbackUrl=${encodeURIComponent(process.env.NEXT_PUBLIC_BASE_PATH + path)}`); + + return session.data?.user as UserPayload; +}; diff --git a/src/types/page.ts b/src/types/page.ts new file mode 100644 index 0000000..00a8fc1 --- /dev/null +++ b/src/types/page.ts @@ -0,0 +1,3 @@ +export type PageProps = { + searchParams?: Record +};