mirror of
https://github.com/diced/zipline.git
synced 2025-12-05 20:40:12 -08:00
feat: a frontend
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
"@prisma/client": "4.16.1",
|
||||
"@prisma/internals": "^4.16.1",
|
||||
"@prisma/migrate": "^4.16.1",
|
||||
"@tabler/icons-react": "^2.22.0",
|
||||
"@tabler/icons-react": "^2.23.0",
|
||||
"argon2": "^0.30.3",
|
||||
"bytes": "^3.1.2",
|
||||
"colorette": "^2.0.20",
|
||||
@@ -42,8 +42,10 @@
|
||||
"next": "^13.4.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"swr": "^2.2.0",
|
||||
"znv": "^0.3.2",
|
||||
"zod": "^3.21.4"
|
||||
"zod": "^3.21.4",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^1.16.1",
|
||||
|
||||
190
src/components/Layout.tsx
Normal file
190
src/components/Layout.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { Response } from '@/lib/api/response';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import {
|
||||
AppShell,
|
||||
Burger,
|
||||
Header,
|
||||
MediaQuery,
|
||||
NavLink,
|
||||
Navbar,
|
||||
Paper,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconFileText,
|
||||
IconFileUpload,
|
||||
IconFiles,
|
||||
IconHome,
|
||||
IconLink,
|
||||
IconShieldLockFilled,
|
||||
IconUpload,
|
||||
IconUsersGroup,
|
||||
} from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
|
||||
type NavLinks = {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
active: (path: string) => boolean;
|
||||
href?: string;
|
||||
description?: string;
|
||||
links?: NavLinks[];
|
||||
if?: (user: Response['/api/user']['user']) => boolean;
|
||||
};
|
||||
|
||||
const navLinks: NavLinks[] = [
|
||||
{
|
||||
label: 'Home',
|
||||
icon: <IconHome size='1rem' />,
|
||||
active: (path: string) => path === '/dashbaord',
|
||||
href: '/dashboard',
|
||||
description: 'View recent files and your statistics',
|
||||
},
|
||||
{
|
||||
label: 'Files',
|
||||
icon: <IconFiles size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/files',
|
||||
href: '/dashboard/files',
|
||||
description: 'View your files',
|
||||
},
|
||||
{
|
||||
label: 'Upload',
|
||||
icon: <IconUpload size='1rem' />,
|
||||
active: (path: string) => path.startsWith('/dashboard/upload'),
|
||||
links: [
|
||||
{
|
||||
label: 'File',
|
||||
icon: <IconFileUpload size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/upload/file',
|
||||
href: '/dashboard/upload/file',
|
||||
description: 'Upload a file',
|
||||
},
|
||||
{
|
||||
label: 'Text',
|
||||
icon: <IconFileText size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/upload/text',
|
||||
href: '/dashboard/upload/text',
|
||||
description: 'Upload text, code, etc.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'URLs',
|
||||
icon: <IconLink size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/urls',
|
||||
href: '/dashboard/urls',
|
||||
description: 'View your URLs',
|
||||
},
|
||||
{
|
||||
label: 'Administrator',
|
||||
icon: <IconShieldLockFilled size='1rem' />,
|
||||
if: (user) => user?.administrator || false,
|
||||
active: (path: string) => path.startsWith('/dashboard/admin'),
|
||||
links: [
|
||||
{
|
||||
label: 'Users',
|
||||
icon: <IconUsersGroup size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/admin/users',
|
||||
href: '/dashboard/admin/users',
|
||||
description: 'View all users',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const theme = useMantineTheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { user, token } = useLogin();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
styles={{
|
||||
main: {
|
||||
background: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0],
|
||||
},
|
||||
}}
|
||||
navbarOffsetBreakpoint='sm'
|
||||
asideOffsetBreakpoint='sm'
|
||||
navbar={
|
||||
<Navbar hiddenBreakpoint='sm' hidden={!opened} width={{ sm: 200, lg: 300 }}>
|
||||
{navLinks
|
||||
.filter((link) => !link.if || link.if(user as Response['/api/user']['user']))
|
||||
.map((link) => {
|
||||
if (!link.links) {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
icon={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={router.pathname === link.href}
|
||||
component={Link}
|
||||
href={link.href || ''}
|
||||
description={link.description}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
icon={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={router.pathname === link.href}
|
||||
description={link.description}
|
||||
defaultOpened={link.active(router.pathname)}
|
||||
>
|
||||
{link.links.map((sublink) => (
|
||||
<NavLink
|
||||
key={sublink.label}
|
||||
label={sublink.label}
|
||||
icon={sublink.icon}
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
variant='light'
|
||||
active={router.pathname === link.href}
|
||||
component={Link}
|
||||
href={sublink.href || ''}
|
||||
description={sublink.description}
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Navbar>
|
||||
}
|
||||
header={
|
||||
<Header height={{ base: 50, md: 70 }} p='md'>
|
||||
<div style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
|
||||
<MediaQuery largerThan='sm' styles={{ display: 'none' }}>
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
size='sm'
|
||||
color={theme.colors.gray[6]}
|
||||
mr='xl'
|
||||
/>
|
||||
</MediaQuery>
|
||||
|
||||
<Text size={30} weight={700}>
|
||||
Zipline
|
||||
</Text>
|
||||
</div>
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
<Paper m={2} withBorder p={'xs'}>
|
||||
{children}
|
||||
</Paper>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
25
src/lib/api/response.ts
Normal file
25
src/lib/api/response.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ApiLoginResponse } from '@/pages/api/auth/login';
|
||||
import type { ApiLogoutResponse } from '@/pages/api/auth/logout';
|
||||
|
||||
import type { ApiUserResponse } from '@/pages/api/user';
|
||||
import type { ApiUserRecentResponse } from '@/pages/api/user/recent';
|
||||
import type { ApiUserTokenResponse } from '@/pages/api/user/token';
|
||||
import type { ApiUserFilesIdResponse } from '@/pages/api/user/files/[id]';
|
||||
import type { ApiUserFilesResponse } from '@/pages/api/user/files';
|
||||
|
||||
import type { ApiHealthcheckResponse } from '@/pages/api/healthcheck';
|
||||
import type { ApiSetupResponse } from '@/pages/api/setup';
|
||||
import type { ApiUploadResponse } from '@/pages/api/upload';
|
||||
|
||||
export type Response = {
|
||||
'/api/auth/login': ApiLoginResponse;
|
||||
'/api/auth/logout': ApiLogoutResponse;
|
||||
'/api/user/files/[id]': ApiUserFilesIdResponse;
|
||||
'/api/user/files': ApiUserFilesResponse;
|
||||
'/api/user': ApiUserResponse;
|
||||
'/api/user/recent': ApiUserRecentResponse;
|
||||
'/api/user/token': ApiUserTokenResponse;
|
||||
'/api/healthcheck': ApiHealthcheckResponse;
|
||||
'/api/setup': ApiSetupResponse;
|
||||
'/api/upload': ApiUploadResponse;
|
||||
};
|
||||
56
src/lib/db/models/file.ts
Normal file
56
src/lib/db/models/file.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '..';
|
||||
import { formatRootUrl } from '@/lib/url';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export type File = {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletesAt: Date | null;
|
||||
favorite: boolean;
|
||||
id: string;
|
||||
originalName: string;
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
type: string;
|
||||
views: number;
|
||||
zeroWidthSpace: string | null;
|
||||
password?: string | boolean | null;
|
||||
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export const fileSelect = {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
deletesAt: true,
|
||||
favorite: true,
|
||||
id: true,
|
||||
originalName: true,
|
||||
name: true,
|
||||
path: true,
|
||||
size: true,
|
||||
type: true,
|
||||
views: true,
|
||||
zeroWidthSpace: true,
|
||||
};
|
||||
|
||||
export function cleanFile(file: File) {
|
||||
file.password = !!file.password;
|
||||
|
||||
file.url = formatRootUrl(config.files.route, file.name);
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
export function cleanFiles(files: File[]) {
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
file.password = !!file.password;
|
||||
|
||||
file.url = formatRootUrl(config.files.route, file.name);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
20
src/lib/db/models/user.ts
Normal file
20
src/lib/db/models/user.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
administrator: boolean;
|
||||
avatar?: string | null;
|
||||
password?: string | null;
|
||||
token?: string | null;
|
||||
};
|
||||
|
||||
export const userSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
administrator: true,
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '..';
|
||||
|
||||
export type File = {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletesAt: Date | null;
|
||||
favorite: boolean;
|
||||
id: string;
|
||||
originalName: string;
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
type: string;
|
||||
views: number;
|
||||
zeroWidthSpace: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type FileSelectOptions = { password?: boolean };
|
||||
|
||||
export async function getFile(
|
||||
where: Prisma.FileWhereInput | Prisma.FileWhereUniqueInput,
|
||||
options?: FileSelectOptions
|
||||
): Promise<File | null> {
|
||||
return prisma.file.findFirst({
|
||||
where,
|
||||
select: {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
deletesAt: true,
|
||||
favorite: true,
|
||||
id: true,
|
||||
originalName: true,
|
||||
name: true,
|
||||
path: true,
|
||||
size: true,
|
||||
type: true,
|
||||
views: true,
|
||||
zeroWidthSpace: true,
|
||||
password: options?.password || false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createFile(data: Prisma.FileCreateInput, options?: FileSelectOptions): Promise<File> {
|
||||
return prisma.file.create({
|
||||
data,
|
||||
select: {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
deletesAt: true,
|
||||
favorite: true,
|
||||
id: true,
|
||||
originalName: true,
|
||||
name: true,
|
||||
path: true,
|
||||
size: true,
|
||||
type: true,
|
||||
views: true,
|
||||
zeroWidthSpace: true,
|
||||
password: options?.password || false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '..';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
administrator: boolean;
|
||||
avatar?: string | null;
|
||||
password?: string | null;
|
||||
token?: string | null;
|
||||
};
|
||||
|
||||
export type UserSelectOptions = { password?: boolean; avatar?: boolean; token?: boolean };
|
||||
|
||||
export async function getUser(
|
||||
where: Prisma.UserWhereInput | Prisma.UserWhereUniqueInput,
|
||||
options?: UserSelectOptions
|
||||
): Promise<User | null> {
|
||||
return prisma.user.findFirst({
|
||||
where,
|
||||
select: {
|
||||
administrator: true,
|
||||
avatar: options?.avatar || false,
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
password: options?.password || false,
|
||||
username: true,
|
||||
token: options?.token || false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
where: Prisma.UserWhereUniqueInput,
|
||||
data: Prisma.UserUpdateInput,
|
||||
options?: UserSelectOptions
|
||||
): Promise<User> {
|
||||
return prisma.user.update({
|
||||
where,
|
||||
data,
|
||||
select: {
|
||||
administrator: true,
|
||||
avatar: options?.avatar || false,
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
password: options?.password || false,
|
||||
username: true,
|
||||
token: options?.token || false,
|
||||
},
|
||||
});
|
||||
}
|
||||
32
src/lib/fetchApi.ts
Normal file
32
src/lib/fetchApi.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ErrorBody } from './response';
|
||||
|
||||
export async function fetchApi<Response = any, Error = string>(
|
||||
route: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
|
||||
body: any = null
|
||||
): Promise<{
|
||||
data: Response | null;
|
||||
error: ErrorBody | null;
|
||||
}> {
|
||||
let data: Response | null = null;
|
||||
let error: ErrorBody | null = null;
|
||||
|
||||
const res = await fetch(route, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
data = await res.json();
|
||||
} else {
|
||||
if (res.headers.get('Content-Type')?.startsWith('application/json')) {
|
||||
error = await res.json();
|
||||
}
|
||||
}
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
24
src/lib/hooks/useLogin.ts
Normal file
24
src/lib/hooks/useLogin.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import type { Response } from '../api/response';
|
||||
import { useUserStore } from '../store/user';
|
||||
|
||||
export default function useLogin() {
|
||||
const router = useRouter();
|
||||
const { data, error, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user');
|
||||
|
||||
const [user, setUser] = useUserStore((state) => [state.user, state.setUser]);
|
||||
const [token, setToken] = useUserStore((state) => [state.token, state.setToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.user && data?.token) {
|
||||
setUser(data.user);
|
||||
setToken(data.token);
|
||||
} else if (error) {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
}, [data, error]);
|
||||
|
||||
return { user, token, loading: isLoading || !user, mutate };
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { config } from '../config';
|
||||
import { decryptToken } from '../crypto';
|
||||
import { User, UserSelectOptions, getUser } from '../db/queries/user';
|
||||
import { prisma } from '../db';
|
||||
import { User, userSelect } from '../db/models/user';
|
||||
import { NextApiReq, NextApiRes } from '../response';
|
||||
import { Handler } from './combine';
|
||||
|
||||
export type ZiplineAuthOptions = {
|
||||
administratorOnly?: boolean;
|
||||
getOptions?: UserSelectOptions;
|
||||
select?: Prisma.UserSelect;
|
||||
};
|
||||
|
||||
declare module 'next' {
|
||||
@@ -31,7 +33,15 @@ export function ziplineAuth(options?: ZiplineAuthOptions) {
|
||||
const [date, token] = decryptedToken;
|
||||
if (isNaN(new Date(date).getTime())) return res.unauthorized('could not decrypt token date');
|
||||
|
||||
const user = await getUser({ token }, options?.getOptions || {});
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
...(options?.select && options.select),
|
||||
},
|
||||
});
|
||||
if (!user) return res.unauthorized();
|
||||
|
||||
req.user = user;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { User } from './db/queries/user';
|
||||
import { User } from './db/models/user';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export interface File {
|
||||
|
||||
17
src/lib/store/user.ts
Normal file
17
src/lib/store/user.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User } from '../db/models/user';
|
||||
|
||||
type UserStore = {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
setUser: (user?: User | null) => void;
|
||||
setToken: (token?: string | null) => void;
|
||||
};
|
||||
|
||||
export const useUserStore = create<UserStore>()((set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
setUser: (user) => set({ user }),
|
||||
setToken: (token) => set({ token }),
|
||||
}));
|
||||
3
src/lib/url.ts
Normal file
3
src/lib/url.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function formatRootUrl(route: string, src: string) {
|
||||
return `${route === '/' || route === '' ? '' : route}${encodeURI(src)}`
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
import { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
const fetcher = async (url: RequestInfo | URL) => {
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
|
||||
throw new Error(json.message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function App(props: AppProps) {
|
||||
const { Component, pageProps } = props;
|
||||
@@ -12,15 +25,21 @@ export default function App(props: AppProps) {
|
||||
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
|
||||
</Head>
|
||||
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
colorScheme: 'dark',
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher
|
||||
}}
|
||||
>
|
||||
<Component {...pageProps} />
|
||||
</MantineProvider>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
colorScheme: 'dark',
|
||||
}}
|
||||
>
|
||||
<Component {...pageProps} />
|
||||
</MantineProvider>
|
||||
</SWRConfig>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { serializeCookie } from '@/lib/cookie';
|
||||
import { encryptToken, verifyPassword } from '@/lib/crypto';
|
||||
import { User, getUser } from '@/lib/db/queries/user';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { cors } from '@/lib/middleware/cors';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
|
||||
type Data = {
|
||||
export type ApiLoginResponse = {
|
||||
user: User;
|
||||
token: string;
|
||||
};
|
||||
@@ -17,18 +18,27 @@ type Body = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
async function handler(req: NextApiReq<Body>, res: NextApiRes<Data>) {
|
||||
async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiLoginResponse>) {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username) return res.badRequest('Username is required');
|
||||
if (!password) return res.badRequest('Password is required');
|
||||
|
||||
const user = await getUser({ username }, { password: true, token: true });
|
||||
if (!user) return res.badRequest('Invalid username');
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
password: true,
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
if (!user) return res.badRequest('Invalid username', { username: true });
|
||||
|
||||
if (!user.password) return res.badRequest('User does not have a password, login through a provider');
|
||||
const valid = await verifyPassword(password, user.password);
|
||||
if (!valid) return res.badRequest('Invalid password');
|
||||
if (!valid) return res.badRequest('Invalid password', { password: true });
|
||||
|
||||
const token = encryptToken(user.token!, config.core.secret);
|
||||
|
||||
@@ -42,8 +52,8 @@ async function handler(req: NextApiReq<Body>, res: NextApiRes<Data>) {
|
||||
|
||||
res.setHeader('Set-Cookie', cookie);
|
||||
|
||||
delete user.token;
|
||||
delete user.password;
|
||||
delete (user as any).token;
|
||||
delete (user as any).password;
|
||||
|
||||
return res.ok({
|
||||
token,
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { cors } from '@/lib/middleware/cors';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
|
||||
type Data = {
|
||||
export type ApiLogoutResponse = {
|
||||
loggedOut?: boolean;
|
||||
};
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes<Data>) {
|
||||
async function handler(_: NextApiReq, res: NextApiRes<ApiLogoutResponse>) {
|
||||
res.setHeader('Set-Cookie', `zipline_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`);
|
||||
|
||||
return res.ok({ loggedOut: true });
|
||||
}
|
||||
|
||||
export default combine([cors(), method(['POST']), ziplineAuth()], handler);
|
||||
export default combine([method(['GET']), ziplineAuth()], handler);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { cors } from '@/lib/middleware/cors';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
|
||||
type Data = {
|
||||
export type ApiHealthcheckResponse = {
|
||||
pass: boolean;
|
||||
};
|
||||
|
||||
export async function handler(req: NextApiReq, res: NextApiRes<Data>) {
|
||||
export async function handler(req: NextApiReq, res: NextApiRes<ApiHealthcheckResponse>) {
|
||||
const logger = log('api').c('healthcheck');
|
||||
|
||||
try {
|
||||
@@ -25,4 +24,4 @@ export async function handler(req: NextApiReq, res: NextApiRes<Data>) {
|
||||
}
|
||||
}
|
||||
|
||||
export default combine([cors(), method(['GET'])], handler);
|
||||
export default combine([method(['GET'])], handler);
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { createToken, hashPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User } from '@/lib/db/queries/user';
|
||||
import { getZipline } from '@/lib/db/queries/zipline';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { getZipline } from '@/lib/db/models/zipline';
|
||||
import { log } from '@/lib/logger';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { cors } from '@/lib/middleware/cors';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
|
||||
type Response = {
|
||||
export type ApiSetupResponse = {
|
||||
firstSetup?: boolean;
|
||||
user?: User;
|
||||
};
|
||||
@@ -18,7 +17,7 @@ type Body = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export async function handler(req: NextApiReq<Body>, res: NextApiRes<Response>) {
|
||||
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiSetupResponse>) {
|
||||
const logger = log('api').c('setup');
|
||||
const { firstSetup, id } = await getZipline();
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { config as zconfig } from '@/lib/config';
|
||||
import { hashPassword } from '@/lib/crypto';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { createFile, getFile } from '@/lib/db/queries/file';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { cors } from '@/lib/middleware/cors';
|
||||
import { file } from '@/lib/middleware/file';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
@@ -15,7 +14,7 @@ import { UploadHeaders, parseHeaders } from '@/lib/uploader/parseHeaders';
|
||||
import bytes from 'bytes';
|
||||
import { extname, parse } from 'path';
|
||||
|
||||
type Data = {
|
||||
export type ApiUploadResponse = {
|
||||
files: {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -28,14 +27,14 @@ type Data = {
|
||||
|
||||
const logger = log('api').c('upload');
|
||||
|
||||
export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: NextApiRes<Data>) {
|
||||
export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: NextApiRes<ApiUploadResponse>) {
|
||||
if (!req.files || !req.files.length) return res.badRequest('No files received');
|
||||
|
||||
const options = parseHeaders(req.headers, zconfig.files);
|
||||
|
||||
if (options.header) return res.badRequest('', options);
|
||||
|
||||
const response: Data = {
|
||||
const response: ApiUploadResponse = {
|
||||
files: [],
|
||||
...(options.deletesAt && { deletesAt: options.deletesAt.toISOString() }),
|
||||
...(zconfig.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
|
||||
@@ -64,9 +63,11 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
|
||||
|
||||
if (options.overrides?.filename) {
|
||||
fileName = options.overrides!.filename!;
|
||||
const existing = await getFile({
|
||||
name: {
|
||||
startsWith: fileName,
|
||||
const existing = await prisma.file.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
startsWith: fileName,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) return res.badRequest(`A file with the name "${fileName}*" already exists`);
|
||||
@@ -84,16 +85,29 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
|
||||
}
|
||||
}
|
||||
|
||||
const fileUpload = await createFile({
|
||||
name: `${fileName}${extension}`,
|
||||
path: `${fileName}${extension}`,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
type: mimetype,
|
||||
...(options.maxViews && { maxViews: options.maxViews }),
|
||||
...(options.password && { password: await hashPassword(options.password) }),
|
||||
...(options.deletesAt && { deletesAt: options.deletesAt }),
|
||||
...(options.folder && { Folder: { connect: { id: options.folder } } }),
|
||||
const fileUpload = await prisma.file.create({
|
||||
data: {
|
||||
name: `${fileName}${extension}`,
|
||||
path: `${fileName}${extension}`,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
type: mimetype,
|
||||
User: {
|
||||
connect: {
|
||||
id: req.user.id,
|
||||
},
|
||||
},
|
||||
...(options.maxViews && { maxViews: options.maxViews }),
|
||||
...(options.password && { password: await hashPassword(options.password) }),
|
||||
...(options.deletesAt && { deletesAt: options.deletesAt }),
|
||||
...(options.folder && { Folder: { connect: { id: options.folder } } }),
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
id: true,
|
||||
type: true,
|
||||
size: true,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: remove gps
|
||||
@@ -115,12 +129,16 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
|
||||
});
|
||||
}
|
||||
|
||||
if (options.noJson) return res.status(200).end(response.files.map((x) => x.url).join(','));
|
||||
if (options.noJson)
|
||||
return res
|
||||
.status(200)
|
||||
.setHeader('content-type', 'text/plain')
|
||||
.end(response.files.map((x) => x.url).join(','));
|
||||
|
||||
return res.ok(response);
|
||||
}
|
||||
|
||||
export default combine([cors(), method(['POST']), ziplineAuth(), file()], handler);
|
||||
export default combine([method(['POST']), ziplineAuth(), file()], handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
|
||||
64
src/pages/api/user/files/[id].ts
Normal file
64
src/pages/api/user/files/[id].ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, fileSelect } from '@/lib/db/models/file';
|
||||
import { log } from '@/lib/logger';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
import bytes from 'bytes';
|
||||
|
||||
export type ApiUserFilesIdResponse = File;
|
||||
|
||||
type Body = {
|
||||
favorite?: boolean;
|
||||
};
|
||||
|
||||
type Query = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('user').c('files').c('id');
|
||||
|
||||
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUserFilesIdResponse>) {
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.query.id }, { name: req.query.id }],
|
||||
},
|
||||
select: fileSelect,
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
const newFile = await prisma.file.update({
|
||||
where: {
|
||||
id: req.query.id,
|
||||
},
|
||||
data: {
|
||||
...(req.body.favorite && { favorite: req.body.favorite }),
|
||||
},
|
||||
select: fileSelect,
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} updated file ${newFile.name} (favorite=${newFile.favorite})`);
|
||||
|
||||
return res.ok(newFile);
|
||||
} else if (req.method === 'DELETE') {
|
||||
const deletedFile = await prisma.file.delete({
|
||||
where: {
|
||||
id: req.query.id,
|
||||
},
|
||||
select: fileSelect,
|
||||
});
|
||||
|
||||
await datasource.delete(deletedFile.name);
|
||||
|
||||
logger.info(`${req.user.username} deleted file ${deletedFile.name} (size=${bytes(deletedFile.size)})`);
|
||||
|
||||
return res.ok(deletedFile);
|
||||
}
|
||||
|
||||
return res.ok(file);
|
||||
}
|
||||
|
||||
export default combine([method(['GET', 'PATCH', 'DELETE']), ziplineAuth()], handler);
|
||||
86
src/pages/api/user/files/index.ts
Normal file
86
src/pages/api/user/files/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
|
||||
export type ApiUserFilesResponse =
|
||||
| File[]
|
||||
| {
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
type Query = {
|
||||
page?: string;
|
||||
pagecount?: string;
|
||||
totalcount?: string;
|
||||
filter?: 'dashboard' | 'none';
|
||||
};
|
||||
|
||||
const PAGE_COUNT = 9;
|
||||
|
||||
export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUserFilesResponse>) {
|
||||
if (req.query.pagecount) {
|
||||
const count = await prisma.file.count({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return res.ok({ count: Math.ceil(count / PAGE_COUNT) });
|
||||
}
|
||||
|
||||
if (req.query.totalcount) {
|
||||
const totalCount = await prisma.file.count({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return res.ok({ totalCount });
|
||||
}
|
||||
|
||||
const { page, filter } = req.query;
|
||||
if (!page) return res.badRequest('Page is required');
|
||||
if (isNaN(Number(page))) return res.badRequest('Page must be a number');
|
||||
|
||||
const files = cleanFiles(
|
||||
await prisma.file.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
...(filter === 'dashboard' && {
|
||||
OR: [
|
||||
{
|
||||
type: { startsWith: 'image/' },
|
||||
},
|
||||
{
|
||||
type: { startsWith: 'video/' },
|
||||
},
|
||||
{
|
||||
type: { startsWith: 'audio/' },
|
||||
},
|
||||
{
|
||||
type: { startsWith: 'text/' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
...fileSelect,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
skip: (Number(page) - 1) * PAGE_COUNT,
|
||||
take: PAGE_COUNT,
|
||||
})
|
||||
);
|
||||
|
||||
return res.ok(files);
|
||||
}
|
||||
|
||||
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);
|
||||
@@ -1,13 +1,12 @@
|
||||
import { hashPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User } from '@/lib/db/queries/user';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { cors } from '@/lib/middleware/cors';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
|
||||
type Response = {
|
||||
export type ApiUserResponse = {
|
||||
user?: User;
|
||||
token?: string;
|
||||
};
|
||||
@@ -18,11 +17,11 @@ type EditBody = {
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export async function handler(req: NextApiReq<EditBody>, res: NextApiRes<Response>) {
|
||||
export async function handler(req: NextApiReq<EditBody>, res: NextApiRes<ApiUserResponse>) {
|
||||
if (req.method === 'GET') {
|
||||
return res.ok({ user: req.user, token: req.cookies.zipline_token });
|
||||
} else if (req.method === 'PATCH') {
|
||||
await prisma.user.update({
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
},
|
||||
@@ -31,8 +30,13 @@ export async function handler(req: NextApiReq<EditBody>, res: NextApiRes<Respons
|
||||
...(req.body.password && { password: await hashPassword(req.body.password) }),
|
||||
...(req.body.avatar && { avatar: req.body.avatar }),
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
},
|
||||
});
|
||||
|
||||
return res.ok({ user, token: req.cookies.zipline_token });
|
||||
}
|
||||
}
|
||||
|
||||
export default combine([cors(), method(['GET', 'PATCH']), ziplineAuth()], handler);
|
||||
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);
|
||||
|
||||
39
src/pages/api/user/recent.ts
Normal file
39
src/pages/api/user/recent.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
|
||||
export type ApiUserRecentResponse =
|
||||
| File[]
|
||||
| {
|
||||
count: number;
|
||||
};
|
||||
|
||||
type Query = {
|
||||
page?: string;
|
||||
pagecount?: string;
|
||||
};
|
||||
|
||||
export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUserRecentResponse>) {
|
||||
const files = cleanFiles(
|
||||
await prisma.file.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 4,
|
||||
})
|
||||
);
|
||||
|
||||
return res.ok(files);
|
||||
}
|
||||
|
||||
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);
|
||||
@@ -1,32 +1,50 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { serializeCookie } from '@/lib/cookie';
|
||||
import { createToken, encryptToken } from '@/lib/crypto';
|
||||
import { User, updateUser } from '@/lib/db/queries/user';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { cors } from '@/lib/middleware/cors';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
|
||||
type Response = {
|
||||
export type ApiUserTokenResponse = {
|
||||
user?: User;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export async function handler(req: NextApiReq, res: NextApiRes<Response>) {
|
||||
const user = await updateUser(
|
||||
{
|
||||
export async function handler(req: NextApiReq, res: NextApiRes<ApiUserTokenResponse>) {
|
||||
if (req.method === 'GET') {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
},
|
||||
select: {
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
|
||||
const token = encryptToken(user!.token, config.core.secret);
|
||||
|
||||
return res.ok({
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
},
|
||||
{
|
||||
data: {
|
||||
token: createToken(),
|
||||
},
|
||||
{
|
||||
select: {
|
||||
...userSelect,
|
||||
token: true,
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const token = encryptToken(user.token!, config.core.secret);
|
||||
const token = encryptToken(user.token, config.core.secret);
|
||||
|
||||
const cookie = serializeCookie('zipline_token', token, {
|
||||
// week
|
||||
@@ -37,7 +55,7 @@ export async function handler(req: NextApiReq, res: NextApiRes<Response>) {
|
||||
});
|
||||
res.setHeader('Set-Cookie', cookie);
|
||||
|
||||
delete user.token;
|
||||
delete (user as any).token;
|
||||
|
||||
return res.ok({
|
||||
user,
|
||||
@@ -45,4 +63,4 @@ export async function handler(req: NextApiReq, res: NextApiRes<Response>) {
|
||||
});
|
||||
}
|
||||
|
||||
export default combine([cors(), method(['PATCH']), ziplineAuth()], handler);
|
||||
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);
|
||||
|
||||
@@ -1,13 +1,129 @@
|
||||
import { Box, Center, Text, TextInput, Title } from '@mantine/core';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import useSWR from 'swr';
|
||||
import useSWRImmutable from 'swr/immutable';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
IconBrandGithubFilled,
|
||||
IconBrandGoogle,
|
||||
IconBrandDiscordFilled,
|
||||
IconCircleKeyFilled,
|
||||
Icon as TIcon,
|
||||
} from '@tabler/icons-react';
|
||||
import { hasLength, useForm } from '@mantine/form';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
|
||||
export default function Login() {
|
||||
function IconText({ Icon, text }: { Icon: TIcon; text: string }) {
|
||||
return (
|
||||
<Box py='xl'>
|
||||
<Center>
|
||||
<Title order={1}>
|
||||
<b>Zipline</b>
|
||||
</Title>
|
||||
</Center>
|
||||
</Box>
|
||||
<Group spacing='xs' align='center'>
|
||||
<Icon />
|
||||
<Text>{text}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const { data, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user');
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.user) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
validate: {
|
||||
username: hasLength({ min: 1 }, 'Username is required'),
|
||||
password: hasLength({ min: 1 }, 'Password is required'),
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
const { username, password } = values;
|
||||
|
||||
const { data, error } = await fetchApi<Response['/api/auth/login']>('/api/auth/login', 'POST', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.username) form.setFieldError('username', 'Invalid username');
|
||||
else if (error.password) form.setFieldError('password', 'Invalid password');
|
||||
} else {
|
||||
mutate(data as Response['/api/user']);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Center h='100vh'>
|
||||
<div>
|
||||
<Title order={1} size={50} align='center'>
|
||||
<b>Zipline</b>
|
||||
</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack my='sm'>
|
||||
<TextInput
|
||||
size='lg'
|
||||
placeholder='Enter your username...'
|
||||
{...form.getInputProps('username', { withError: true })}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
size='lg'
|
||||
placeholder='Enter your password...'
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
|
||||
<Button size='lg' fullWidth type='submit'>
|
||||
Login
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text size='sm' align='center' color='dimmed'>
|
||||
OR
|
||||
</Text>
|
||||
|
||||
<Stack my='xs'>
|
||||
<Button size='lg' fullWidth variant='outline'>
|
||||
Sign up
|
||||
</Button>
|
||||
<Button size='lg' fullWidth variant='outline'>
|
||||
<IconText Icon={IconBrandGithubFilled} text='Sign in with GitHub' />
|
||||
</Button>
|
||||
<Button size='lg' fullWidth variant='outline'>
|
||||
<IconText Icon={IconBrandGoogle} text='Sign in with Google' />
|
||||
</Button>
|
||||
<Button size='lg' fullWidth variant='outline'>
|
||||
<IconText Icon={IconBrandDiscordFilled} text='Sign in with Discord' />
|
||||
</Button>
|
||||
<Button size='lg' fullWidth variant='outline'>
|
||||
<IconText Icon={IconCircleKeyFilled} text='Sign in with Authentik' />
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Center>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
45
src/pages/auth/logout.tsx
Normal file
45
src/pages/auth/logout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { Group, LoadingOverlay, Text } from '@mantine/core';
|
||||
import { Icon as TIcon } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
function IconText({ Icon, text }: { Icon: TIcon; text: string }) {
|
||||
return (
|
||||
<Group spacing='xs' align='center'>
|
||||
<Icon />
|
||||
<Text>{text}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const [setUser, setToken] = useUserStore((state) => [state.setUser, state.setToken]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const userRes = await fetch('/api/user');
|
||||
if (userRes.ok) {
|
||||
const res = await fetch('/api/auth/logout');
|
||||
|
||||
if (res.ok) {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
mutate('/api/user', null);
|
||||
}
|
||||
} else {
|
||||
await router.push('/auth/login');
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingOverlay visible />
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/pages/dashboard/index.tsx
Normal file
27
src/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import Layout from '@/components/Layout';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { LoadingOverlay, Text, Title } from '@mantine/core';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default function DashboardIndex() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
|
||||
const { data: total, isLoading: totalLoading } = useSWR<
|
||||
Extract<Response['/api/user/files'], { totalCount: number }>
|
||||
>('/api/user/files?totalcount=true');
|
||||
|
||||
if (loading) return <LoadingOverlay visible />;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Title order={1}>
|
||||
Welcome back, <b>{user?.username}</b>
|
||||
</Title>
|
||||
<Text size='sm' color='dimmed'>
|
||||
You have <b>{totalLoading ? '...' : total?.totalCount}</b> files uploaded.
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
59
yarn.lock
59
yarn.lock
@@ -4252,22 +4252,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tabler/icons-react@npm:^2.22.0":
|
||||
version: 2.22.0
|
||||
resolution: "@tabler/icons-react@npm:2.22.0"
|
||||
"@tabler/icons-react@npm:^2.23.0":
|
||||
version: 2.23.0
|
||||
resolution: "@tabler/icons-react@npm:2.23.0"
|
||||
dependencies:
|
||||
"@tabler/icons": 2.22.0
|
||||
"@tabler/icons": 2.23.0
|
||||
prop-types: ^15.7.2
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0
|
||||
checksum: fe2a4c3e5483269ee178195746cdc4b8c1e150dec78aae4bbf3884f33dd819929a7ff1e53eb6ba1426b914ba710e13a30d0e70caf9e588b8df45e500f70941dd
|
||||
checksum: 9e40f615e20cd2a4f7a9f079fb076c8b53af7420b59b9cd80abc470c2cc39440012c0ede1779b5bca67a2f587d475571fd0c9b228e7e0071e069e90ccddc1828
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tabler/icons@npm:2.22.0":
|
||||
version: 2.22.0
|
||||
resolution: "@tabler/icons@npm:2.22.0"
|
||||
checksum: 3f0aaa801e8739d841ac5d335fbaee41399aaa9eeae03eb21c1dbe77877a29ba3664f16c225323a0635fad6548aca40212220edfb2919797454ea1029c5a5b78
|
||||
"@tabler/icons@npm:2.23.0":
|
||||
version: 2.23.0
|
||||
resolution: "@tabler/icons@npm:2.23.0"
|
||||
checksum: aceeebf5ea526e7cc00b128ae018f8501d78e4f5c91b4e0a1d4d5f2b6b9c67457f892b7f054e4f7f074b1189b4ea515455d130b8cf274265b5a12b67e1ee4d92
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13694,6 +13694,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"swr@npm:^2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "swr@npm:2.2.0"
|
||||
dependencies:
|
||||
use-sync-external-store: ^1.2.0
|
||||
peerDependencies:
|
||||
react: ^16.11.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 1f04795ff9dff54987cbf8a544afc9e42d1350a99e899be8a7cdc4885f561e3ee464f78245ee2ebc8ced262b04023d134f731e276227319dc2d6e1843389ddd8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"synckit@npm:^0.8.5":
|
||||
version: 0.8.5
|
||||
resolution: "synckit@npm:0.8.5"
|
||||
@@ -14491,6 +14502,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"use-sync-external-store@npm:1.2.0, use-sync-external-store@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "use-sync-external-store@npm:1.2.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 5c639e0f8da3521d605f59ce5be9e094ca772bd44a4ce7322b055a6f58eeed8dda3c94cabd90c7a41fb6fa852210092008afe48f7038792fd47501f33299116a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
|
||||
version: 1.0.2
|
||||
resolution: "util-deprecate@npm:1.0.2"
|
||||
@@ -14977,7 +14997,7 @@ __metadata:
|
||||
"@prisma/migrate": ^4.16.1
|
||||
"@remix-run/dev": ^1.16.1
|
||||
"@remix-run/eslint-config": ^1.16.1
|
||||
"@tabler/icons-react": ^2.22.0
|
||||
"@tabler/icons-react": ^2.23.0
|
||||
"@types/bytes": ^3.1.1
|
||||
"@types/express": ^4.17.17
|
||||
"@types/multer": ^1.4.7
|
||||
@@ -15000,10 +15020,12 @@ __metadata:
|
||||
prisma: ^4.16.1
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
swr: ^2.2.0
|
||||
tsup: ^7.0.0
|
||||
typescript: ^5.1.3
|
||||
znv: ^0.3.2
|
||||
zod: ^3.21.4
|
||||
zustand: ^4.3.8
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -15025,6 +15047,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zustand@npm:^4.3.8":
|
||||
version: 4.3.8
|
||||
resolution: "zustand@npm:4.3.8"
|
||||
dependencies:
|
||||
use-sync-external-store: 1.2.0
|
||||
peerDependencies:
|
||||
immer: ">=9.0"
|
||||
react: ">=16.8"
|
||||
peerDependenciesMeta:
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
checksum: 24db6bf063ce1fc8b2ee238f13211a88f43236541a716e5f6f706f613c671a45332465f9ed06d694f8c353da3d24c53ea668e5712a86aceda9ad74f6c433e8c0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zwitch@npm:^2.0.0":
|
||||
version: 2.0.4
|
||||
resolution: "zwitch@npm:2.0.4"
|
||||
|
||||
Reference in New Issue
Block a user