Compare commits

..

28 Commits

Author SHA1 Message Date
diced 0b1db04159 fix: add errors to spec 2026-03-03 16:29:24 -08:00
diced 4735b102c3 fix: settings errors 2026-03-03 15:22:23 -08:00
diced 5d48735dfb fix: lint 2026-03-02 22:45:31 -08:00
diced ea9599a67a fix: more 2026-03-02 22:43:47 -08:00
diced 9bd22bd574 fix: responses + add descriptions 2026-03-02 22:43:40 -08:00
diced 6fef46246e refactor: generalized error codes 2026-03-02 19:57:23 -08:00
diced 3f159b3509 fix: finish up api refactor 2026-03-02 14:29:41 -08:00
diced eb3a58e790 feat: descriptions for api routes 2026-03-02 00:25:37 -08:00
diced 454b40501a refactor: models to zod 2026-03-01 22:41:36 -08:00
diced 4c6679b568 feat: add response schemas (WIP, hella unstable!!) 2026-03-01 14:57:16 -08:00
diced 3c757374e1 feat: revamp option selection for files page 2026-02-26 16:53:31 -08:00
diced c0e1aa9ac6 feat: revamp folders page 2026-02-26 16:11:49 -08:00
diced 40fd0b19eb feat: add multiple files for text uploads 2026-02-24 02:14:03 -08:00
diced 41240b7aff refactor: upload/partial logic + more sanitzation 2026-02-23 22:04:50 -08:00
diced 01f177fbc3 fix: permissions on docker scripts 2026-02-23 00:43:41 -08:00
diced ab1d394a46 fix: permissions 2026-02-23 00:42:01 -08:00
diced d08f1ba5da fix: #1002 2026-02-23 00:20:36 -08:00
diced 641a7c9b7b fix: maybe fix oauth issues #1001 2026-02-23 00:18:26 -08:00
diced a467ffe861 feat: new notifs position 2026-02-23 00:18:17 -08:00
dicedtomato 33ff667990 Merge commit from fork 2026-02-20 21:48:01 -08:00
diced e96015f5e0 fix: refactor + perf 2026-02-19 22:38:54 -08:00
diced d4d1cdc885 feat: revamped sessions 2026-02-15 21:18:02 -08:00
diced a7d831934d fix: add http but https warning 2026-02-12 16:30:56 -08:00
diced e9ef6a2d40 fix: #983 2026-02-12 16:05:28 -08:00
diced 7520efa835 fix: use exponential moving average for estimation (#996) 2026-02-12 15:55:22 -08:00
diced cff8454ac7 fix: no schema for settings api (from #990) 2026-02-12 14:53:38 -08:00
diced 847779601a fix: dev 2026-02-12 14:53:31 -08:00
Andrew Simonson 49c2088ea3 fix: max interval checks (#990)
* introduce max interval checks

* Update validate.ts

* Update validate.ts

* Update validate.ts

* Update validate.ts
2026-02-12 14:45:50 -08:00
275 changed files with 4503 additions and 2500 deletions
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
@@ -78,13 +78,12 @@ jobs:
sleep 2
done
- name: Run app
- name: Run generator
env:
DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline
CORE_SECRET: ${{ steps.secret.outputs.secret }}
ZIPLINE_OUTPUT_OPENAPI: true
run: pnpm start
NODE_ENV: production
run: pnpm openapi
- name: Verify openapi.json exists
run: |
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
+3 -1
View File
@@ -12,6 +12,7 @@
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
"validate": "tsx scripts/validate.ts",
"openapi": "tsx scripts/openapi.ts",
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
"db:migrate": "prisma migrate dev --create-only",
"docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w",
@@ -61,6 +62,7 @@
"cookie": "^1.1.1",
"cross-env": "^10.1.0",
"dayjs": "^1.11.19",
"detect-browser": "^5.3.0",
"dotenv": "^17.2.3",
"fast-glob": "^3.3.3",
"fastify": "^5.6.2",
@@ -124,5 +126,5 @@
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@10.12.1"
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
}
Generated Executable → Regular
+8
View File
@@ -128,6 +128,9 @@ importers:
dayjs:
specifier: ^1.11.19
version: 1.11.19
detect-browser:
specifier: ^5.3.0
version: 5.3.0
dotenv:
specifier: ^17.2.3
version: 17.2.3
@@ -2718,6 +2721,9 @@ packages:
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-browser@5.3.0:
resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==}
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
@@ -8132,6 +8138,8 @@ snapshots:
destr@2.0.5: {}
detect-browser@5.3.0: {}
detect-libc@1.0.3:
optional: true
Executable → Regular
View File
Executable → Regular
View File
@@ -0,0 +1,23 @@
/*
Warnings:
- You are about to drop the column `sessions` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "public"."User" DROP COLUMN "sessions";
-- CreateTable
CREATE TABLE "public"."UserSession" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ua" TEXT NOT NULL,
"client" TEXT NOT NULL,
"device" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "public"."UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Executable → Regular
+13 -1
View File
@@ -163,7 +163,7 @@ model User {
totpSecret String?
passkeys UserPasskey[]
sessions String[]
sessions UserSession[]
quota UserQuota?
@@ -191,6 +191,18 @@ model Export {
userId String
}
model UserSession {
id String @id
createdAt DateTime @default(now())
ua String
client String
device String
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String
}
model UserQuota {
id String @id @default(cuid())
createdAt DateTime @default(now())
+8 -2
View File
@@ -1,4 +1,6 @@
export function step(name: string, command: string, condition: () => boolean = () => true) {
type StepCommand = string | (() => void | Promise<void>);
export function step(name: string, command: StepCommand, condition: () => boolean = () => true) {
return {
name,
command,
@@ -35,7 +37,11 @@ export async function run(name: string, ...steps: Step[]) {
try {
log(`> Running step "${name}/${step.name}"...`);
execSync(step.command, { stdio: 'inherit' });
if (typeof step.command === 'string') {
execSync(step.command, { stdio: 'inherit' });
} else {
await step.command();
}
} catch {
console.error(`x Step "${name}/${step.name}" failed.`);
process.exit(1);
+110
View File
@@ -0,0 +1,110 @@
import { readFile, writeFile } from 'fs/promises';
import path from 'path';
import { run, step } from '.';
import { API_ERRORS, ApiError, ApiErrorCode } from '../src/lib/api/errors';
const ALL_METHODS = ['delete', 'get', 'head', 'patch', 'post', 'put'];
const GEN_PATH = path.resolve(__dirname, '..', 'openapi.json');
const ALL_ERRORS = Object.keys(API_ERRORS)
.map((code) => new ApiError(Number(code) as ApiErrorCode).toJSON())
.sort((a, b) => a.code - b.code);
const ERROR_SCHEMA = {
type: 'object',
description: 'Generic error for API endpoints.',
properties: {
error: {
type: 'string',
description:
'Message for the error. This may differ from the standard message for the error code, but the error code should be used to figure out the type of error.',
},
code: {
type: 'integer',
format: 'int32',
description:
'Zipline API error code. Ranges: 1xxx validation, 2xxx session, 3xxx permission, 4xxx not-found, 5xxx constraint, 6xxx internal, 9xxx generic.',
enum: ALL_ERRORS.map((entry) => entry.code),
'x-enumDescriptions': ALL_ERRORS.map((entry) => entry.message),
},
statusCode: {
type: 'integer',
format: 'int32',
description: 'HTTP status code returned alongside this error payload.',
},
},
required: ['error', 'code', 'statusCode'],
additionalProperties: true,
};
const ERROR_EXAMPLES = ALL_ERRORS.reduce<Record<string, unknown>>((examples, entry) => {
examples[`E${entry.code}`] = {
summary: `${entry.error}`,
value: entry,
};
return examples;
}, {});
const generic4xxResponse = {
description: 'API error response (4xx)',
content: {
'application/json': {
schema: ERROR_SCHEMA,
examples: ERROR_EXAMPLES,
},
},
};
function addErrorResponse(responses: Record<string, any>): void {
const response = (responses['4xx'] ??= structuredClone(generic4xxResponse));
response.description ??= generic4xxResponse.description;
response.content ??= {};
const jsonContent = (response.content['application/json'] ??= {});
jsonContent.schema ??= structuredClone(ERROR_SCHEMA);
jsonContent.examples ??= structuredClone(generic4xxResponse.content['application/json'].examples);
}
function filterRoutes(paths = {}): Record<string, any> {
return Object.fromEntries(Object.entries(paths).filter(([route]) => route.startsWith('/api')));
}
async function fixSpec() {
const spec = JSON.parse(await readFile(GEN_PATH, 'utf8'));
spec.paths = filterRoutes(spec.paths);
for (const [, pathItem] of Object.entries(spec.paths ?? {})) {
if (!pathItem) continue;
for (const method of ALL_METHODS) {
const operation = (<any>pathItem)[method];
if (!operation) continue;
operation.responses ??= {};
addErrorResponse(operation.responses);
}
}
await writeFile(GEN_PATH, JSON.stringify(spec));
}
process.env.ZIPLINE_OUTPUT_OPENAPI = 'true';
run(
'openapi',
step('run-prod', 'pnpm start', () => process.env.NODE_ENV === 'production'),
step('run-dev', 'pnpm dev', () => process.env.NODE_ENV !== 'production'),
step('check', async () => {
try {
await readFile(GEN_PATH);
} catch (e) {
console.error('\nSomething went wrong...', e);
throw new Error('No OpenAPI spec found at ./openapi.json');
}
}),
step('fix', fixSpec),
);
+1 -1
View File
@@ -60,7 +60,7 @@ export default function Root({
}}
modals={contextModals}
>
<Notifications zIndex={10000000} />
<Notifications position='top-center' zIndex={10000000} />
<Outlet />
</ModalsProvider>
</ThemeProvider>
+112 -326
View File
@@ -1,66 +1,53 @@
import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
import { Response } from '@/lib/api/response';
import LocalLogin from '@/components/pages/login/LocalLogin';
import PasskeyAuthButton from '@/components/pages/login/PasskeyAuthButton';
import SecureWarningModal from '@/components/pages/login/SecureWarningModal';
import TotpModal from '@/components/pages/login/TotpModal';
import { getWebClient } from '@/lib/api/detect';
import { ApiError } from '@/lib/api/errors';
import { fetchApi } from '@/lib/fetchApi';
import useLogin from '@/lib/hooks/useLogin';
import useObjectState from '@/lib/hooks/useObjectState';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Anchor,
Box,
Button,
Center,
Code,
Divider,
Group,
Image,
LoadingOverlay,
Modal,
Paper,
PasswordInput,
PinInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications, showNotification } from '@mantine/notifications';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { showNotification } from '@mantine/notifications';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import {
IconBrandDiscordFilled,
IconBrandGithubFilled,
IconBrandGoogleFilled,
IconCheck,
IconCircleKeyFilled,
IconKey,
IconShieldQuestion,
IconUserPlus,
IconX,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
export default function Login() {
useTitle('Login');
const location = useLocation();
const query = new URLSearchParams(location.search);
const navigate = useNavigate();
const { user, mutate } = useLogin();
const navigate = useNavigate();
const isHttps = window.location.protocol === 'https:';
const webClient = JSON.stringify(getWebClient());
const {
data: config,
error: configError,
isLoading: configLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const { data: config, error: configError, isLoading: configLoading } = useSWR('/api/server/public');
const showLocalLogin =
query.get('local') === 'true' ||
@@ -74,254 +61,122 @@ export default function Login() {
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length === 1 &&
query.get('local') !== 'true';
const [totpOpen, setTotpOpen] = useState(false);
const [pinDisabled, setPinDisabled] = useState(false);
const [pinError, setPinError] = useState('');
const [pin, setPin] = useState('');
const [passkeyErrored, setPasskeyErrored] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [secureModal, setSecureModal] = useState(false);
const form = useForm({
initialValues: {
username: '',
password: '',
},
validate: {
username: (value) => (value.length >= 1 ? null : 'Username is required'),
password: (value) => (value.length >= 1 ? null : 'Password is required'),
},
enhanceGetInputProps: ({ field }) => ({
name: field,
}),
});
const onSubmit = async (values: typeof form.values, code: string | undefined = undefined) => {
setPinDisabled(true);
setPinError('');
const { username, password } = values;
const { data, error } = await fetchApi<Response['/api/auth/login']>('/api/auth/login', 'POST', {
username,
password,
code,
});
if (error) {
if (error.error === 'Invalid username or password') {
form.setFieldError('username', 'Invalid username');
form.setFieldError('password', 'Invalid password');
} else if (error.error === 'Invalid code') setPinError(error.error!);
setPinDisabled(false);
} else {
if (data!.totp) {
setTotpOpen(true);
setPinDisabled(false);
return;
}
mutate(data as Response['/api/user']);
}
};
const handlePinChange = (value: string) => {
setPin(value);
if (value.length === 6) {
onSubmit(form.values, value);
}
};
const handlePasskeyLogin = async () => {
try {
setPasskeyLoading(true);
const { data: options, error: optionsError } = await fetchApi<Response['/api/auth/webauthn/options']>(
'/api/auth/webauthn/options',
'GET',
);
if (optionsError) {
setPasskeyErrored(true);
setPasskeyLoading(false);
notifications.show({
title: 'Error while authenticating with passkey',
message: optionsError.error,
color: 'red',
});
return;
}
const res = await startAuthentication({ optionsJSON: options!.options! });
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
response: res,
});
if (error) {
setPasskeyErrored(true);
setPasskeyLoading(false);
notifications.show({
title: 'Error while authenticating with passkey',
message: error.error,
color: 'red',
});
} else {
mutate(data as Response['/api/user']);
}
} catch (e) {
console.log(e);
setPasskeyErrored(true);
setPasskeyLoading(false);
}
};
useEffect(() => {
if (user) {
navigate('/dashboard');
}
}, [user]);
useEffect(() => {
if (willRedirect && config) {
const provider = Object.keys(config.oauthEnabled).find(
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,
);
if (provider) {
window.location.href = `/api/auth/oauth/${provider.toLowerCase()}`;
}
if (provider) window.location.href = `/api/auth/oauth/${provider.toLowerCase()}`;
}
}, [willRedirect, config]);
useEffect(() => {
if (passkeyErrored) {
setTimeout(() => {
setPasskeyErrored(false);
}, 3000);
const [totp, setTotp] = useObjectState({
open: false,
disabled: false,
error: '',
pin: '',
});
showNotification({
title: 'Error while authenticating with passkey',
message: 'Please try again',
color: 'red',
icon: <IconX size='1rem' />,
});
}
}, [passkeyErrored]);
const [secureModal, setSecureModal] = useState(false);
const form = useForm({
initialValues: { username: '', password: '' },
validate: {
username: (v) => (v.length >= 1 ? null : 'Username is required'),
password: (v) => (v.length >= 1 ? null : 'Password is required'),
},
});
useEffect(() => {
if (user) navigate('/dashboard');
if (config?.firstSetup) navigate('/auth/setup');
}, [config]);
}, [user, config, navigate]);
if (configLoading) return <LoadingOverlay visible />;
const handleLoginSubmit = async (values: any, code?: string) => {
setTotp({ disabled: true, error: '' });
if (configError)
return (
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
const { data, error } = await fetchApi(
'/api/auth/login',
'POST',
{ ...values, code },
{ 'x-zipline-client': webClient },
);
if (!config) return <LoadingOverlay visible />;
if (error) {
if (ApiError.check(error, 1044)) {
form.setFieldError('username', 'Invalid username');
form.setFieldError('password', 'Invalid password');
} else {
setTotp('error', error.error || 'Login failed');
}
setTotp('disabled', false);
} else if (data?.totp) {
setTotp({ open: true, disabled: false });
} else {
showNotification({
message: 'Logging in...',
icon: <IconCheck size='1rem' />,
autoClose: 700,
});
mutate(data);
}
};
if (configLoading || !config) return <LoadingOverlay visible />;
if (configError) return <GenericError title='Error' message='Config load failed' details={configError} />;
const hasBg = !!config.website.loginBackground;
return (
<>
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
<Modal onClose={() => {}} title='Enter code' opened={totpOpen} withCloseButton={false}>
<Center>
<PinInput
data-autofocus
length={6}
oneTimeCode
type='number'
placeholder=''
onChange={handlePinChange}
autoFocus={true}
error={!!pinError}
disabled={pinDisabled}
size='xl'
/>
</Center>
{pinError && (
<Text ta='center' size='sm' c='red' mt={0}>
{pinError}
</Text>
)}
<TotpModal
state={totp}
onPinChange={(val) => setTotp('pin', val)}
onVerify={() => handleLoginSubmit(form.values, totp.pin)}
onCancel={() => {
setTotp('open', false);
form.reset();
}}
/>
<Group mt='sm' grow>
<Button
leftSection={<IconX size='1rem' />}
color='red'
variant='outline'
onClick={() => {
setTotpOpen(false);
form.reset();
}}
>
Cancel login attempt
</Button>
<Button
leftSection={<IconShieldQuestion size='1rem' />}
loading={pinDisabled}
type='submit'
onClick={() => onSubmit(form.values, pin)}
>
Verify
</Button>
</Group>
</Modal>
<Modal opened={secureModal} onClose={() => setSecureModal(false)} title='HTTPS Configuration' size='lg'>
<Text>
It appears that you are accessing this instance through a secure context (HTTPS), but the server is
not configured to use HTTPS. This can lead issues when logging in.
</Text>
<Text mt='md'>
To resolve this issue, it is recommended to have your server configured to use HTTPS. This can be
done by setting the <Code>CORE_RETURN_HTTPS_URLS</Code> environment variable to <Code>true</Code>{' '}
and ensuring that your server has a valid SSL setup through a reverse proxy like Nginx or Caddy.
</Text>
<Text mt='md'>
After making these changes, restart the server for the changes to take effect. If you continue to
experience issues, please consult the{' '}
<Anchor
underline='always'
href='https://zipline.diced.sh/docs/config/settings#more-about-return-https-urls'
>
documentation
</Anchor>{' '}
or seek support.
</Text>
</Modal>
<SecureWarningModal
opened={secureModal}
onClose={() => setSecureModal(false)}
returnHttps={config.returnHttps}
/>
{isHttps && !config.returnHttps && (
<Box pos='absolute' top={10} left='50%' style={{ transform: 'translateX(-50%)' }}>
<Text size='sm' c='red' ta='center'>
You are accessing this instance through a secure context but the server is not configured to use
HTTPS. Click <Anchor onClick={() => setSecureModal(true)}> here</Anchor> to learn more.
You are accessing this instance through a <b>secure</b> context but the server is not configured
to use HTTPS. Click <Anchor onClick={() => setSecureModal(true)}> here</Anchor> to learn more.
</Text>
</Box>
)}
{!isHttps && config.returnHttps && (
<Box pos='absolute' top={10} left='50%' style={{ transform: 'translateX(-50%)' }}>
<Text size='sm' c='red' ta='center'>
You are accessing this instance through an <b>insecure</b> context but the server is configured to
use HTTPS. This may cause issues when logging in. Click{' '}
<Anchor onClick={() => setSecureModal(true)}> here</Anchor> to learn more.
</Text>
</Box>
)}
<Center h='100vh'>
{config.website.loginBackground && (
{hasBg && (
<Image
src={config.website.loginBackground}
alt={config.website.loginBackground + ' failed to load'}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
...(config.website.loginBackgroundBlur && { filter: 'blur(10px)' }),
}}
pos='absolute'
inset={0}
w='100%'
h='100%'
fit='cover'
style={{ filter: config.website.loginBackgroundBlur ? 'blur(10px)' : undefined }}
/>
)}
@@ -330,98 +185,29 @@ export default function Login() {
p='xl'
shadow='xl'
withBorder
pos='relative'
style={{
backgroundColor: config.website.loginBackground ? 'rgba(0, 0, 0, 0)' : undefined,
backdropFilter: config.website.loginBackgroundBlur ? 'blur(35px)' : undefined,
backgroundColor: hasBg ? 'transparent' : undefined,
backdropFilter: hasBg ? 'blur(35px)' : undefined,
}}
>
<div style={{ width: '100%', overflowWrap: 'break-word' }}>
<Title
order={1}
ta='center'
style={{
whiteSpace: 'normal',
fontSize: `clamp(20px, ${Math.max(
50 - (config.website.title?.length ?? 0) / 2,
20,
)}px, 50px)`,
}}
>
<b>{config.website.title ?? 'Zipline'}</b>
</Title>
</div>
<Title order={1} ta='center' mb='md'>
<b>{config.website.title ?? 'Zipline'}</b>
</Title>
{showLocalLogin && (
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
autoComplete='username'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('username', { withError: true })}
/>
<PasswordInput
size='md'
placeholder='Enter your password...'
autoComplete='current-password'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('password')}
/>
<Button
size='md'
fullWidth
type='submit'
loading={!config}
variant={config.website.loginBackground ? 'outline' : 'filled'}
>
Login
</Button>
</Stack>
</form>
)}
<Stack my='xs'>
{(config.features.oauthRegistration || config.features.userRegistration) && (
<Divider label='or' />
<Stack>
{showLocalLogin && (
<LocalLogin
form={form}
onSubmit={handleLoginSubmit}
loading={totp.disabled}
hasBackground={hasBg}
/>
)}
{config.mfa.passkeys && browserSupportsWebAuthn() && (
<Button
onClick={handlePasskeyLogin}
size='md'
fullWidth
variant='outline'
leftSection={<IconKey size='1rem' />}
color={passkeyErrored ? 'red' : undefined}
loading={passkeyLoading}
>
Login with passkey
</Button>
)}
<Divider label='or' />
{config.features.userRegistration && (
<Button
component={Link}
to='/auth/register'
size='md'
fullWidth
variant='outline'
leftSection={<IconUserPlus size='1rem' />}
>
Sign up
</Button>
)}
{config.mfa.passkeys && browserSupportsWebAuthn() && <PasskeyAuthButton onAuthSuccess={mutate} />}
<Group grow>
{config.oauthEnabled.discord && (
-35
View File
@@ -1,35 +0,0 @@
import { useTitle } from '@/lib/hooks/useTitle';
import { useUserStore } from '@/lib/store/user';
import { LoadingOverlay } from '@mantine/core';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { mutate } from 'swr';
export default function Logout() {
useTitle('Log out');
const setUser = useUserStore((state) => state.setUser);
const navigate = useNavigate();
useEffect(() => {
(async () => {
const userRes = await fetch('/api/user');
if (userRes.ok) {
const res = await fetch('/api/auth/logout');
if (res.ok) {
setUser(null);
mutate('/api/user', null);
navigate('/auth/login');
} else {
navigate('/dashboard');
}
} else {
navigate('/dashboard');
}
})();
}, []);
return <LoadingOverlay visible />;
}
+15 -6
View File
@@ -22,6 +22,8 @@ import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
import { getWebClient } from '@/lib/api/detect';
import { ApiError } from '@/lib/api/errors';
export function Component() {
useTitle('Register');
@@ -99,14 +101,21 @@ export function Component() {
return;
}
const { data, error } = await fetchApi('/api/auth/register', 'POST', {
username,
password,
code,
});
const { data, error } = await fetchApi(
'/api/auth/register',
'POST',
{
username,
password,
code,
},
{
'x-zipline-client': JSON.stringify(getWebClient()),
},
);
if (error) {
if (error.error === 'Username is taken') {
if (ApiError.check(error, 1039)) {
form.setFieldError('username', 'Username is taken');
} else {
notifications.show({
-2
View File
@@ -6,7 +6,6 @@ import DashboardErrorBoundary from './error/DashboardErrorBoundary';
import RootErrorBoundary from './error/RootErrorBoundary';
import FourOhFour from './pages/404';
import Login from './pages/auth/login';
import Logout from './pages/auth/logout';
import Root from './Root';
export async function dashboardLoader() {
@@ -38,7 +37,6 @@ export const router = createBrowserRouter([
path: '/auth',
children: [
{ path: 'login', Component: Login },
{ path: 'logout', Component: Logout },
{ path: 'register', lazy: () => import('./pages/auth/register') },
{
path: 'setup',
View File
View File
Executable → Regular
+3 -6
View File
@@ -51,6 +51,7 @@ import ConfigProvider from './ConfigProvider';
import VersionBadge from './VersionBadge';
import { Link, useLoaderData } from 'react-router-dom';
import { dashboardLoader } from '../client/routes';
import { useLogout } from '@/lib/hooks/useLogout';
type NavLinks = {
label: string;
@@ -158,6 +159,7 @@ export default function Layout() {
const clipboard = useClipboard();
const setUser = useUserStore((s) => s.setUser);
const location = useLocation();
const logout = useLogout();
const loaderData = useLoaderData<typeof dashboardLoader>();
const config = loaderData.config;
@@ -304,12 +306,7 @@ export default function Layout() {
)}
<Menu.Divider />
<Menu.Item
color='red'
leftSection={<IconLogout size='1rem' />}
component={Link}
to='/auth/logout'
>
<Menu.Item color='red' leftSection={<IconLogout size='1rem' />} onClick={logout}>
Logout
</Menu.Item>
</Menu.Dropdown>
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
+61 -33
View File
@@ -1,9 +1,10 @@
import { File } from '@/lib/db/models/file';
import { fetchApi } from '@/lib/fetchApi';
import useObjectState from '@/lib/hooks/useObjectState';
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { mutateFiles } from '../actions';
export default function EditFileDetailsModal({
@@ -15,13 +16,41 @@ export default function EditFileDetailsModal({
file: File | null;
onClose: () => void;
}) {
if (!file) return null;
const [formData, setFormData] = useObjectState<{
name: string;
maxViews: number | null;
password: string | null;
originalName: string | null;
type: string | null;
}>({
name: file?.name ?? '',
maxViews: file?.maxViews ?? null,
password: file?.password ? '' : null,
originalName: file?.originalName ?? null,
type: file?.type ?? null,
});
const [name, setName] = useState<string>(file.name ?? '');
const [maxViews, setMaxViews] = useState<number | null>(file?.maxViews ?? null);
const [password, setPassword] = useState<string | null>('');
const [originalName, setOriginalName] = useState<string | null>(file?.originalName ?? null);
const [type, setType] = useState<string | null>(file?.type ?? null);
useEffect(() => {
if (open) {
setFormData({
name: file?.name ?? '',
maxViews: file?.maxViews ?? null,
password: file?.password ? '' : null,
originalName: file?.originalName ?? null,
type: file?.type ?? null,
});
} else {
setFormData({
name: '',
maxViews: null,
password: null,
originalName: null,
type: null,
});
}
}, [open, file]);
if (!file) return null;
const handleRemovePassword = async () => {
if (!file.password) return;
@@ -58,12 +87,12 @@ export default function EditFileDetailsModal({
name?: string;
} = {};
if (maxViews !== null) data['maxViews'] = maxViews;
if (originalName !== null) data['originalName'] = originalName?.trim();
if (type !== null) data['type'] = type?.trim();
if (name !== file.name) data['name'] = name.trim();
if (formData.maxViews !== null) data['maxViews'] = formData.maxViews;
if (formData.originalName !== null) data['originalName'] = formData.originalName?.trim();
if (formData.type !== null) data['type'] = formData.type?.trim();
if (formData.name !== file.name) data['name'] = formData.name.trim();
const passwordTrimmed = password?.trim();
const passwordTrimmed = formData.password?.trim();
if (passwordTrimmed !== '') data['password'] = passwordTrimmed;
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
@@ -85,29 +114,19 @@ export default function EditFileDetailsModal({
onClose();
setPassword(null);
setFormData('password', null);
mutateFiles();
}
};
useEffect(() => {
if (open) {
setName(file.name ?? '');
setMaxViews(file.maxViews ?? null);
setPassword(file.password ? '' : null);
setOriginalName(file.originalName ?? null);
setType(file.type ?? null);
}
}, [open, file]);
return (
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Stack gap='xs' my='sm'>
<TextInput
label='Name'
description='Rename the file.'
value={name}
onChange={(event) => setName(event.currentTarget.value.trim())}
value={formData.name}
onChange={(event) => setFormData('name', event.currentTarget.value.trim())}
/>
<NumberInput
@@ -115,17 +134,20 @@ export default function EditFileDetailsModal({
placeholder='Unlimited'
description='The maximum number of views this file can have before it is deleted. Leave blank to allow as many views as you want.'
min={0}
value={maxViews || ''}
onChange={(value) => setMaxViews(value === '' ? null : Number(value))}
value={formData.maxViews || ''}
onChange={(value) => setFormData('maxViews', value === '' ? null : Number(value))}
leftSection={<IconEye size='1rem' />}
/>
<TextInput
label='Original Name'
description='Add an original name. When downloading this file, instead of using the generated file name (if chosen), it will download with this "original name" instead.'
value={originalName ?? ''}
value={formData.originalName ?? ''}
onChange={(event) =>
setOriginalName(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
setFormData(
'originalName',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
}
/>
@@ -137,9 +159,12 @@ export default function EditFileDetailsModal({
doing, this can mess with how Zipline renders specific file types.
</>
}
value={type ?? ''}
value={formData.type ?? ''}
onChange={(event) =>
setType(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
setFormData(
'type',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
}
c='red'
/>
@@ -159,10 +184,13 @@ export default function EditFileDetailsModal({
<PasswordInput
label='Password'
description='Set a password for this file. Leave blank to disable password protection.'
value={password ?? ''}
value={formData.password ?? ''}
autoComplete='off'
onChange={(event) =>
setPassword(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim())
setFormData(
'password',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
}
leftSection={<IconKey size='1rem' />}
/>
View File
View File
View File
View File
View File
Executable → Regular
+5 -9
View File
@@ -1,3 +1,4 @@
import { mutateFolder } from '@/components/pages/folders/actions';
import { Response } from '@/lib/api/response';
import type { File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
@@ -135,7 +136,7 @@ export async function createFolderAndAdd(file: File, folderName: string | null)
});
}
mutateFolders();
mutateFolder();
mutateFiles();
}
@@ -165,7 +166,7 @@ export async function removeFromFolder(file: File) {
});
}
mutateFolders();
mutateFolder();
mutateFiles();
}
@@ -196,7 +197,7 @@ export async function addToFolder(file: File, folderId: string | null) {
});
}
mutateFolders();
mutateFolder();
mutateFiles();
}
@@ -228,7 +229,7 @@ export async function addMultipleToFolder(files: File[], folderId: string | null
});
}
mutateFolders();
mutateFolder();
mutateFiles();
}
@@ -236,8 +237,3 @@ export function mutateFiles() {
mutate('/api/user/recent');
mutate((key) => (key as Record<any, any>)?.key === '/api/user/files'); // paged files
}
export function mutateFolders() {
mutate('/api/user/folders');
mutate('/api/user/folders?noincl=true');
}
View File
@@ -1,124 +0,0 @@
import { Response } from '@/lib/api/response';
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { fetchApi } from '@/lib/fetchApi';
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IncompleteFileStatus } from '@/prisma/client';
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
import { ReactNode, useState } from 'react';
import useSWR from 'swr';
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
PENDING: (
<Badge variant='light' color='gray'>
Pending
</Badge>
),
PROCESSING: (
<Badge variant='light' color='yellow'>
Processing
</Badge>
),
COMPLETE: (
<Badge variant='light' color='green'>
Complete
</Badge>
),
FAILED: (
<Badge variant='light' color='red'>
Failed
</Badge>
),
};
export default function PendingFilesButton() {
const [open, setOpen] = useState(false);
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
>('/api/user/files/incomplete');
const handleDelete = async (incompleteFile: IncompleteFile) => {
const { error } = await fetchApi<Response['/api/user/files/incomplete']>(
'/api/user/files/incomplete',
'DELETE',
{
id: [incompleteFile.id],
},
);
if (error) {
showNotification({
title: 'Error',
message: `Failed to delete pending file: ${error.error}`,
color: 'red',
icon: <IconFileDots size='1rem' />,
});
} else {
showNotification({
message: 'Cleared Pending File!',
color: 'green',
icon: <IconTrashFilled size='1rem' />,
});
}
mutate();
};
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title='Pending Files'>
<Stack gap='xs'>
{incompleteFiles?.map((incompleteFile) => (
<Card key={incompleteFile.id} withBorder>
<Group justify='space-between'>
<Text fw='bolder'>{incompleteFile.metadata.file.filename}</Text>
{badgeMap[incompleteFile.status]}
</Group>
<Group justify='space-between'>
<Text size='xs' c='dimmed' fw='bold'>
{incompleteFile.metadata.file.type}
</Text>
<Text size='xs' c='dimmed'>
{incompleteFile.chunksComplete} / {incompleteFile.chunksTotal} processed
</Text>
</Group>
<Text size='xs' c='dimmed'>
{incompleteFile.id}
</Text>
<Group justify='space-between'>
<Button
fullWidth
size='compact-sm'
mt='xs'
color='red'
variant='light'
onClick={() => handleDelete(incompleteFile)}
leftSection={<IconTrashFilled size='1rem' />}
>
Clear
</Button>
</Group>
</Card>
))}
{incompleteFiles?.length === 0 && (
<Paper withBorder px='sm' py='xs'>
No pending files
</Paper>
)}
</Stack>
</Modal>
<Tooltip label='View pending files'>
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
<IconFileDots size='1rem' />
</ActionIcon>
</Tooltip>
</>
);
}
@@ -0,0 +1,122 @@
import { Response } from '@/lib/api/response';
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { fetchApi } from '@/lib/fetchApi';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { IncompleteFileStatus } from '@/prisma/client';
import { Badge, Button, Card, Group, Modal, Paper, Stack, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
import { ReactNode } from 'react';
import useSWR from 'swr';
import { DashboardFilesModals } from '.';
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
PENDING: (
<Badge variant='light' color='gray'>
Pending
</Badge>
),
PROCESSING: (
<Badge variant='light' color='yellow'>
Processing
</Badge>
),
COMPLETE: (
<Badge variant='light' color='green'>
Complete
</Badge>
),
FAILED: (
<Badge variant='light' color='red'>
Failed
</Badge>
),
};
export default function PendingFilesModal({
modals,
setModals,
}: {
modals: DashboardFilesModals;
setModals: UpdateFn<DashboardFilesModals>;
}) {
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
>('/api/user/files/incomplete');
const handleDelete = async (incompleteFile: IncompleteFile) => {
const { error } = await fetchApi<Response['/api/user/files/incomplete']>(
'/api/user/files/incomplete',
'DELETE',
{
id: [incompleteFile.id],
},
);
if (error) {
showNotification({
title: 'Error',
message: `Failed to delete pending file: ${error.error}`,
color: 'red',
icon: <IconFileDots size='1rem' />,
});
} else {
showNotification({
message: 'Cleared Pending File!',
color: 'green',
icon: <IconTrashFilled size='1rem' />,
});
}
mutate();
};
return (
<Modal opened={modals.pending} onClose={() => setModals('pending', false)}>
<Stack gap='xs'>
{incompleteFiles?.map((incompleteFile) => (
<Card key={incompleteFile.id} withBorder>
<Group justify='space-between'>
<Text fw='bolder'>{incompleteFile.metadata.file.filename}</Text>
{badgeMap[incompleteFile.status]}
</Group>
<Group justify='space-between'>
<Text size='xs' c='dimmed' fw='bold'>
{incompleteFile.metadata.file.type}
</Text>
<Text size='xs' c='dimmed'>
{incompleteFile.chunksComplete} / {incompleteFile.chunksTotal} processed
</Text>
</Group>
<Text size='xs' c='dimmed'>
{incompleteFile.id}
</Text>
<Group justify='space-between'>
<Button
fullWidth
size='compact-sm'
mt='xs'
color='red'
variant='light'
onClick={() => handleDelete(incompleteFile)}
leftSection={<IconTrashFilled size='1rem' />}
>
Clear
</Button>
</Group>
</Card>
))}
{incompleteFiles?.length === 0 && (
<Paper withBorder px='sm' py='xs'>
No pending files
</Paper>
)}
</Stack>
</Modal>
);
}
@@ -53,7 +53,7 @@ function SortableTableField({ item }: { item: FieldSettings }) {
);
}
export default function TableEditModal({ opened, onCLose }: { opened: boolean; onCLose: () => void }) {
export default function TableEditModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const [fields, setIndex, reset] = useFileTableSettingsStore(
useShallow((state) => [state.fields, state.setIndex, state.reset]),
);
@@ -73,7 +73,7 @@ export default function TableEditModal({ opened, onCLose }: { opened: boolean; o
};
return (
<Modal opened={opened} onClose={onCLose} title='Table Options' centered>
<Modal opened={opened} onClose={onClose} title='Table Options' centered>
<Text mb='md' size='sm' c='dimmed'>
Select and drag fields below to make them appear/disappear/reorder in the file table view.
</Text>
View File
+67 -41
View File
@@ -1,23 +1,44 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import useObjectState from '@/lib/hooks/useObjectState';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
import FavoriteFiles from './views/FavoriteFiles';
import FileTable from './views/FileTable';
import Files from './views/Files';
import TagsButton from './tags/TagsButton';
import PendingFilesButton from './PendingFilesButton';
import { IconFileUpload, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
import { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core';
import {
IconDots,
IconFileDots,
IconFileUpload,
IconGridPatternFilled,
IconTableOptions,
IconTags,
} from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { useState } from 'react';
import PendingFilesModal from './PendingFilesModal';
import TagsModal from './tags/TagsModal';
import FavoriteFiles from './views/FavoriteFiles';
import Files from './views/FilesGridView';
import FileTable from './views/FilesTableView';
export type DashboardFilesModals = {
table: boolean;
idSearch: boolean;
tags: boolean;
pending: boolean;
};
export default function DashboardFiles() {
const view = useViewStore((state) => state.files);
const [tableEditOpen, setTableEditOpen] = useState(false);
const [idSearchOpen, setIdSearchOpen] = useState(false);
const [modals, setModals] = useObjectState<DashboardFilesModals>({
table: false,
idSearch: false,
tags: false,
pending: false,
});
return (
<>
<TagsModal modals={modals} setModals={setModals} />
<PendingFilesModal modals={modals} setModals={setModals} />
<Group>
<Title>Files</Title>
@@ -29,29 +50,43 @@ export default function DashboardFiles() {
</Link>
</Tooltip>
<TagsButton />
<PendingFilesButton />
{view === 'table' && (
<>
<Tooltip label='Table Options'>
<ActionIcon variant='outline' onClick={() => setTableEditOpen((open) => !open)}>
<IconTableOptions size='1rem' />
<Menu>
<Menu.Target>
<Tooltip label='More actions'>
<ActionIcon variant='outline'>
<IconDots size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Search by ID'>
<ActionIcon
variant='outline'
onClick={() => {
setIdSearchOpen((open) => !open);
}}
>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
</Tooltip>
</>
)}
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconTags size='1rem' />} onClick={() => setModals('tags', !modals.tags)}>
Manage Tags
</Menu.Item>
<Menu.Item
leftSection={<IconFileDots size='1rem' />}
onClick={() => setModals('pending', !modals.pending)}
>
View Pending Files
</Menu.Item>
{view === 'table' && (
<>
<Menu.Label>Table Options</Menu.Label>
<Menu.Item
leftSection={<IconGridPatternFilled size='1rem' />}
onClick={() => setModals('idSearch', !modals.idSearch)}
>
Search by ID
</Menu.Item>
<Menu.Item
leftSection={<IconTableOptions size='1rem' />}
onClick={() => setModals('table', !modals.table)}
>
Table Options
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<GridTableSwitcher type='files' />
</Group>
@@ -63,16 +98,7 @@ export default function DashboardFiles() {
<Files />
</>
) : (
<FileTable
idSearch={{
open: idSearchOpen,
setOpen: setIdSearchOpen,
}}
tableEdit={{
open: tableEditOpen,
setOpen: setTableEditOpen,
}}
/>
<FileTable modals={modals} setModals={setModals} />
)}
</>
);
View File
View File
View File
@@ -2,17 +2,24 @@ import { mutateFiles } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
import { IconPencil, IconPlus, IconTagOff, IconTrashFilled } from '@tabler/icons-react';
import { useState } from 'react';
import useSWR from 'swr';
import { DashboardFilesModals } from '..';
import CreateTagModal from './CreateTagModal';
import EditTagModal from './EditTagModal';
import TagPill from './TagPill';
export default function TagsButton() {
const [open, setOpen] = useState(false);
export default function TagsModals({
modals,
setModals,
}: {
modals: DashboardFilesModals;
setModals: UpdateFn<DashboardFilesModals>;
}) {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
@@ -47,8 +54,8 @@ export default function TagsButton() {
<EditTagModal open={!!selectedTag} onClose={() => setSelectedTag(null)} tag={selectedTag} />
<Modal
opened={open}
onClose={() => setOpen(false)}
opened={modals.tags}
onClose={() => setModals('tags', false)}
title={
<Group>
<Title>Tags</Title>
@@ -94,12 +101,6 @@ export default function TagsButton() {
)}
</Stack>
</Modal>
<Tooltip label='View tags'>
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
<IconTags size='1rem' />
</ActionIcon>
</Tooltip>
</>
);
}
+2
View File
@@ -19,6 +19,7 @@ type ApiPaginationOptions = {
| 'favorite';
order?: 'asc' | 'desc';
id?: string;
folderId?: string;
search?: {
field?: string;
query: string;
@@ -45,6 +46,7 @@ const fetcher = async (
if (options.search.field) searchParams.append('searchField', options.search.field);
searchParams.append('searchQuery', options.search.query);
}
if (options.folderId) searchParams.append('folder', options.folderId);
const res = await fetch(`/api/user/files${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
View File
@@ -21,7 +21,7 @@ const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export default function Files({ id }: { id?: string }) {
export default function Files({ id, folderId }: { id?: string; folderId?: string }) {
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(15);
@@ -29,6 +29,7 @@ export default function Files({ id }: { id?: string }) {
page,
perpage,
id,
folderId,
});
const from = (page - 1) * perpage + 1;
@@ -1,6 +1,6 @@
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import RelativeDate from '@/components/RelativeDate';
import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/components/file/actions';
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { type File } from '@/lib/db/models/file';
@@ -44,6 +44,8 @@ import { lazy, useEffect, useMemo, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { UpdateFn } from '@/lib/hooks/useObjectState';
import { DashboardFilesModals } from '..';
import TableEditModal, { NAMES } from '../TableEditModal';
import { bulkDelete, bulkFavorite } from '../bulk';
import TagPill from '../tags/TagPill';
@@ -111,7 +113,7 @@ function TagsFilter({
const combobox = useCombobox();
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>('/api/user/tags');
const [value, setValue] = useState(searchQuery.tags.split(','));
const [value, setValue] = useState(() => searchQuery.tags.split(','));
const handleValueSelect = (val: string) => {
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
};
@@ -178,18 +180,14 @@ function TagsFilter({
export default function FileTable({
id,
tableEdit,
idSearch,
folderId,
modals,
setModals,
}: {
id?: string;
tableEdit: {
open: boolean;
setOpen: (open: boolean) => void;
};
idSearch: {
open: boolean;
setOpen: (open: boolean) => void;
};
folderId?: string;
modals?: Partial<DashboardFilesModals>;
setModals?: UpdateFn<DashboardFilesModals>;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
@@ -259,6 +257,7 @@ export default function FileTable({
sort,
order,
id,
folderId,
...(searchQuery[searchField].trim() !== '' && {
search: {
field: searchField,
@@ -385,7 +384,9 @@ export default function FileTable({
user={id}
/>
<TableEditModal opened={tableEdit.open} onCLose={() => tableEdit.setOpen(false)} />
{modals && setModals && modals.table && (
<TableEditModal opened={modals.table} onClose={() => setModals('table', false)} />
)}
<Box>
<Collapse in={selectedFiles.length > 0}>
@@ -478,30 +479,33 @@ export default function FileTable({
</Paper>
</Collapse>
<Collapse in={idSearch.open}>
<Paper withBorder p='sm' mt='sm'>
<TextInput
placeholder='Search by ID'
value={searchQuery.id}
onChange={(e) => {
setSearchField('id');
setSearchQuery({
field: 'id',
query: e.target.value,
});
}}
size='sm'
/>
</Paper>
</Collapse>
{modals && setModals && modals.idSearch && (
<Collapse in={modals.idSearch}>
<Paper withBorder p='sm' mt='sm'>
<TextInput
placeholder='Search by ID'
value={searchQuery.id}
onChange={(e) => {
setSearchField('id');
setSearchQuery({
field: 'id',
query: e.target.value,
});
}}
size='sm'
/>
</Paper>
</Collapse>
)}
{/* @ts-ignore */}
{/*@ts-ignore*/}
<DataTable
mt='xs'
borderRadius='sm'
withTableBorder
minHeight={200}
records={data?.page ?? []}
noRecordsText='No files'
columns={[
...columns,
{
View File
+23 -10
View File
@@ -5,7 +5,7 @@ import { useClipboard } from '@mantine/hooks';
import {
IconCopy,
IconDots,
IconFiles,
IconFileZip,
IconFolder,
IconFolderOpen,
IconFolderSymlink,
@@ -22,6 +22,7 @@ import DeleteFolderModal from './modals/DeleteFolderModal';
import EditFolderNameModal from './modals/EditFolderNameModal';
import MoveFolderModal from './modals/MoveFolderModal';
import ViewFilesModal from './modals/ViewFilesModal';
import { withoutPropagation } from './views/FolderTableView';
export default function FolderCard({
folder,
@@ -81,38 +82,50 @@ export default function FolderCard({
Open Folder
</Menu.Item>
)}
<Menu.Item leftSection={<IconFiles size='1rem' />} onClick={() => setViewOpen(true)}>
View Files
</Menu.Item>
<Menu.Item leftSection={<IconFolderSymlink size='1rem' />} onClick={() => setMoveOpen(true)}>
<Menu.Item
leftSection={<IconFolderSymlink size='1rem' />}
onClick={withoutPropagation(() => setMoveOpen(true))}
>
Move Folder
</Menu.Item>
<Menu.Item
leftSection={<IconFileZip size='1rem' />}
component='a'
href={`/api/user/folders/${folder.id}/export`}
target='_blank'
onClick={withoutPropagation(() => {})}
>
Export as ZIP
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
onClick={() => editFolderVisibility(folder, !folder.public)}
onClick={withoutPropagation(() => editFolderVisibility(folder, !folder.public))}
>
{folder.public ? 'Make Private' : 'Make Public'}
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
onClick={() => editFolderUploads(folder, !folder.allowUploads)}
onClick={withoutPropagation(() => editFolderUploads(folder, !folder.allowUploads))}
>
{folder.allowUploads ? 'Disallow anonymous uploads' : 'Allow anonymous uploads'}
</Menu.Item>
<Menu.Item leftSection={<IconPencil size='1rem' />} onClick={() => setEditOpen(true)}>
<Menu.Item
leftSection={<IconPencil size='1rem' />}
onClick={withoutPropagation(() => setEditOpen(true))}
>
Edit Name
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size='1rem' />}
disabled={!folder.public}
onClick={() => copyFolderUrl(folder, clipboard)}
onClick={withoutPropagation(() => copyFolderUrl(folder, clipboard))}
>
Copy URL
</Menu.Item>
<Menu.Item
leftSection={<IconTrashFilled size='1rem' />}
color='red'
onClick={() => setDeleteOpen(true)}
onClick={withoutPropagation(() => setDeleteOpen(true))}
>
Delete
</Menu.Item>
+8 -2
View File
@@ -48,7 +48,7 @@ export async function editFolderVisibility(folder: Folder, isPublic: boolean) {
});
}
mutate('/api/user/folders');
mutateFolder(folder.id);
}
export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
@@ -76,5 +76,11 @@ export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
});
}
mutate('/api/user/folders');
mutateFolder(folder.id);
}
export async function mutateFolder(folderId?: string) {
if (folderId) return mutate(`/api/user/folders/${folderId}`);
return mutate((key) => typeof key === 'string' && key.startsWith('/api/user/folders'));
}
+75 -4
View File
@@ -3,14 +3,35 @@ import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import { SEPARATOR, useTitle } from '@/lib/hooks/useTitle';
import { useViewStore } from '@/lib/store/view';
import { Anchor, Breadcrumbs, Button, Group, Modal, Stack, Switch, TextInput, Title } from '@mantine/core';
import {
Alert,
Anchor,
Box,
Breadcrumbs,
Button,
Collapse,
CopyButton,
Divider,
Group,
Modal,
Paper,
Stack,
Switch,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconFolderPlus, IconHome, IconPlus } from '@tabler/icons-react';
import { IconFolderPlus, IconHome, IconPlus, IconShare } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
import FilesGridView from '../files/views/FilesGridView';
import FilesTableView from '../files/views/FilesTableView';
import { mutateFolder } from './actions';
import FolderGridView from './views/FolderGridView';
import FolderTableView from './views/FolderTableView';
@@ -20,6 +41,7 @@ export default function DashboardFolders() {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [filesOpen, setFilesOpen] = useState(true);
const folderPath = useMemo(() => {
const pathname = location.pathname.replace('/dashboard/folders', '');
@@ -62,7 +84,7 @@ export default function DashboardFolders() {
color: 'red',
});
} else {
mutate((key: string) => key.startsWith('/api/user/folders'));
mutateFolder();
setOpen(false);
form.reset();
}
@@ -108,6 +130,8 @@ export default function DashboardFolders() {
const breadcrumbs = buildBreadcrumbs();
useTitle(currentFolder ? `Folders ${SEPARATOR} ${currentFolder.name}` : 'Folders');
useEffect(() => {
if (!currentFolderId) return;
if (isLoading) return;
@@ -176,6 +200,53 @@ export default function DashboardFolders() {
) : (
<FolderTableView currentFolderId={currentFolderId} onNavigate={navigateToFolder} />
)}
{currentFolderId && currentFolder && (
<Box>
<Divider mx='-xs' my='xs' />
{currentFolder?.allowUploads && (
<Alert
icon={<IconShare size='1rem' />}
variant='outline'
mb='sm'
styles={{ message: { marginTop: 0 } }}
>
This folder allows anonymous uploads. Share the link below to allow others to let others upload
files to this folder.
<br />
<Anchor href={`/folder/${currentFolder.id}/upload`} target='_blank'>
{`${window?.location?.origin ?? ''}/folder/${currentFolder.id}/upload`}
</Anchor>
<CopyButton value={`${window?.location?.origin ?? ''}/folder/${currentFolder.id}/upload`}>
{({ copied, copy }) => (
<Button mx='sm' size='compact-xs' color={copied ? 'teal' : 'blue'} onClick={copy}>
{copied ? 'Copied url' : 'Copy url'}
</Button>
)}
</CopyButton>
</Alert>
)}
<Text
mt='sm'
c='dimmed'
size='sm'
onClick={() => setFilesOpen((o) => !o)}
style={{ cursor: 'pointer', userSelect: 'none' }}
>
{filesOpen ? '▼' : '▶'} {currentFolder.name}&#39;s files{' '}
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
</Text>
<Collapse in={filesOpen}>
{view === 'grid' ? (
<Paper withBorder p='sm'>
<FilesGridView folderId={currentFolderId} />
</Paper>
) : (
<FilesTableView folderId={currentFolderId} />
)}
</Collapse>
</Box>
)}
</>
);
}
@@ -8,9 +8,9 @@ import { Button, Combobox, InputBase, Modal, Radio, Stack, Text, useCombobox } f
import { notifications } from '@mantine/notifications';
import { IconTrashFilled } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { mutate } from 'swr';
import { mutateFolder } from '../actions';
type ChildrenAction = 'moveToRoot' | 'moveToFolder' | 'cascade';
type ChildrenAction = 'root' | 'folder' | 'cascade';
export default function DeleteFolderModal({
folder,
@@ -22,7 +22,7 @@ export default function DeleteFolderModal({
onClose: () => void;
}) {
const [loading, setLoading] = useState(false);
const [childrenAction, setChildrenAction] = useState<ChildrenAction>('moveToRoot');
const [childrenAction, setChildrenAction] = useState<ChildrenAction>('root');
const [targetFolderId, setTargetFolderId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const combobox = useCombobox();
@@ -56,7 +56,7 @@ export default function DeleteFolderModal({
if (hasContent) {
body.childrenAction = childrenAction;
if (childrenAction === 'moveToFolder') {
if (childrenAction === 'folder') {
if (!targetFolderId) {
notifications.show({
title: 'No folder selected',
@@ -90,7 +90,7 @@ export default function DeleteFolderModal({
message: `${folder.name} has been deleted`,
color: 'green',
});
mutate((key: string) => typeof key === 'string' && key.startsWith('/api/user/folders'));
mutateFolder();
onClose();
}
};
@@ -112,8 +112,8 @@ export default function DeleteFolderModal({
<Radio.Group value={childrenAction} onChange={(v) => setChildrenAction(v as ChildrenAction)}>
<Stack gap='xs'>
<Radio value='moveToRoot' label='Move contents to root folder' />
<Radio value='moveToFolder' label='Move contents to another folder' />
<Radio value='root' label='Move contents to root folder' />
<Radio value='folder' label='Move contents to another folder' />
<Radio
value='cascade'
label={
@@ -125,7 +125,7 @@ export default function DeleteFolderModal({
</Stack>
</Radio.Group>
{childrenAction === 'moveToFolder' && (
{childrenAction === 'folder' && (
<Combobox
store={combobox}
withinPortal={true}
@@ -171,7 +171,8 @@ export default function DeleteFolderModal({
{childrenAction === 'cascade' && (
<Text size='sm' c='red' fw={500}>
Warning: This will permanently delete all subfolders and files within this folder.
Warning: This will permanently delete all contents within this folder (subfolders will be
deleted, and files will be unlinked from their folders).
</Text>
)}
</>
@@ -1,4 +1,3 @@
import { mutateFolders } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import type { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
@@ -7,6 +6,7 @@ import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import { IconPencil } from '@tabler/icons-react';
import { useEffect } from 'react';
import { mutateFolder } from '../actions';
export default function EditFolderNameModal({
folder,
@@ -43,7 +43,7 @@ export default function EditFolderNameModal({
message: error.error,
});
} else {
mutateFolders();
mutateFolder();
showNotification({
title: 'Folder name updated',
message: 'Folder name has been updated successfully to ' + data?.name,
@@ -8,7 +8,7 @@ import { Button, Combobox, InputBase, Modal, Stack, Text, useCombobox } from '@m
import { notifications } from '@mantine/notifications';
import { IconFolderSymlink } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { mutate } from 'swr';
import { mutateFolder } from '../actions';
export default function MoveFolderModal({
folder,
@@ -73,7 +73,7 @@ export default function MoveFolderModal({
message: `${folder.name} has been moved`,
color: 'green',
});
mutate((key: string) => typeof key === 'string' && key.startsWith('/api/user/folders'));
mutateFolder();
onClose();
}
};
View File
View File
+96 -81
View File
@@ -1,11 +1,12 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { ActionIcon, Badge, Box, Checkbox, Group, Text, Tooltip } from '@mantine/core';
import { ActionIcon, Badge, Box, Checkbox, Group, Menu, Text, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import {
IconCopy,
IconFiles,
IconDots,
IconFileZip,
IconFolder,
IconFolderOpen,
IconFolderSymlink,
@@ -15,7 +16,6 @@ import {
IconShare,
IconShareOff,
IconTrashFilled,
IconZip,
} from '@tabler/icons-react';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useMemo, useState } from 'react';
@@ -26,6 +26,90 @@ import EditFolderNameModal from '../modals/EditFolderNameModal';
import MoveFolderModal from '../modals/MoveFolderModal';
import ViewFilesModal from '../modals/ViewFilesModal';
export const withoutPropagation = (fn: () => void) => (e: React.MouseEvent) => {
e.stopPropagation();
fn();
};
function FolderDotsMenu({
folder,
onNavigate,
setDeleteOpen,
setMoveOpen,
setEditNameOpen,
}: {
folder: Folder;
onNavigate: (folderId: string) => void;
setDeleteOpen: (folder: Folder) => void;
setMoveOpen: (folder: Folder) => void;
setEditNameOpen: (folder: Folder) => void;
}) {
const [opened, setOpened] = useState(false);
return (
<Menu shadow='md' width={200} opened={opened} onChange={setOpened}>
<Menu.Target>
<Tooltip label='More actions'>
<ActionIcon onClick={withoutPropagation(() => setOpened((o) => !o))}>
<IconDots size='1rem' />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
{onNavigate && (
<Menu.Item
leftSection={<IconFolderOpen size='1rem' />}
onClick={withoutPropagation(() => onNavigate(folder.id!))}
>
Open Folder
</Menu.Item>
)}
<Menu.Item
leftSection={<IconFolderSymlink size='1rem' />}
onClick={withoutPropagation(() => setMoveOpen(folder))}
>
Move Folder
</Menu.Item>
<Menu.Item
leftSection={<IconFileZip size='1rem' />}
component='a'
href={`/api/user/folders/${folder.id}/export`}
target='_blank'
onClick={withoutPropagation(() => {})}
>
Export as ZIP
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
onClick={withoutPropagation(() => editFolderVisibility(folder, !folder.public))}
>
{folder.public ? 'Make Private' : 'Make Public'}
</Menu.Item>
<Menu.Item
leftSection={folder.public ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
onClick={withoutPropagation(() => editFolderUploads(folder, !folder.allowUploads))}
>
{folder.allowUploads ? 'Disallow anonymous uploads' : 'Allow anonymous uploads'}
</Menu.Item>
<Menu.Item
leftSection={<IconPencil size='1rem' />}
onClick={withoutPropagation(() => setEditNameOpen(folder))}
>
Edit Name
</Menu.Item>
<Menu.Item
leftSection={<IconTrashFilled size='1rem' />}
color='red'
onClick={withoutPropagation(() => setDeleteOpen(folder))}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
export default function FolderTableView({
currentFolderId,
onNavigate,
@@ -90,6 +174,7 @@ export default function FolderTableView({
records={sorted ?? []}
onRowClick={({ record }) => onNavigate(record.id)}
rowStyle={() => ({ cursor: 'pointer' })}
noRecordsText='No subfolders'
columns={[
{
accessor: 'name',
@@ -134,38 +219,14 @@ export default function FolderTableView({
textAlign: 'right',
render: (folder) => (
<Group gap='sm' justify='right' wrap='nowrap'>
{folder.public && (
<Tooltip label='Open public link'>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
window.open(`/folder/${folder.id}`, '_blank');
}}
>
<IconFolderOpen size='1rem' />
</ActionIcon>
</Tooltip>
)}
<Tooltip label='View files'>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
setSelectedFolder(folder);
}}
>
<IconFiles size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Move folder'>
<ActionIcon
onClick={(e) => {
e.stopPropagation();
setMoveOpen(folder);
}}
>
<IconFolderSymlink size='1rem' />
</ActionIcon>
</Tooltip>
<FolderDotsMenu
folder={folder}
onNavigate={onNavigate}
setDeleteOpen={setDeleteOpen}
setMoveOpen={setMoveOpen}
setEditNameOpen={setEditNameOpen}
/>
<Tooltip label='Copy folder link'>
<ActionIcon
onClick={(e) => {
@@ -177,52 +238,6 @@ export default function FolderTableView({
<IconCopy size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label={folder.public ? 'Make private' : 'Make public'}>
<ActionIcon
color={folder.public ? 'blue' : 'gray'}
onClick={(e) => {
e.stopPropagation();
editFolderVisibility(folder, !folder.public);
}}
>
{folder.public ? <IconLockOpen size='1rem' /> : <IconLock size='1rem' />}
</ActionIcon>
</Tooltip>
<Tooltip
label={folder.allowUploads ? 'Disable anonymous uploads' : 'Allow anonymous uploads'}
>
<ActionIcon
color={folder.allowUploads ? 'blue' : 'gray'}
onClick={(e) => {
e.stopPropagation();
editFolderUploads(folder, !folder.allowUploads);
}}
>
{folder.allowUploads ? <IconShareOff size='1rem' /> : <IconShare size='1rem' />}
</ActionIcon>
</Tooltip>
<Tooltip label='Edit Folder Name'>
<ActionIcon
color='blue'
onClick={(e) => {
e.stopPropagation();
setEditNameOpen(folder);
}}
>
<IconPencil size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Export folder as ZIP'>
<ActionIcon
color='blue'
onClick={(e) => {
e.stopPropagation();
window.open(`/api/user/folders/${folder.id}/export`, '_blank');
}}
>
<IconZip size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete Folder'>
<ActionIcon
color='red'
View File
View File
View File
View File
View File
+50
View File
@@ -0,0 +1,50 @@
import { Stack, TextInput, PasswordInput, Button } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
export default function LocalLogin({
form,
onSubmit,
loading,
hasBackground,
}: {
form: UseFormReturnType<any>;
onSubmit: (values: any) => void;
loading: boolean;
hasBackground: boolean;
}) {
return (
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
autoComplete='username'
styles={{
input: { backgroundColor: hasBackground ? 'transparent' : undefined },
}}
{...form.getInputProps('username')}
/>
<PasswordInput
size='md'
placeholder='Enter your password...'
autoComplete='current-password'
styles={{
input: { backgroundColor: hasBackground ? 'transparent' : undefined },
}}
{...form.getInputProps('password')}
/>
<Button
size='md'
fullWidth
type='submit'
loading={loading}
variant={hasBackground ? 'outline' : 'filled'}
>
Login
</Button>
</Stack>
</form>
);
}
@@ -0,0 +1,50 @@
import { Button } from '@mantine/core';
import { IconKey } from '@tabler/icons-react';
import { useState } from 'react';
import { startAuthentication } from '@simplewebauthn/browser';
import { fetchApi } from '@/lib/fetchApi';
import { notifications } from '@mantine/notifications';
import { getWebClient } from '@/lib/api/detect';
export default function PasskeyAuthButton({ onAuthSuccess }: { onAuthSuccess: (data: any) => void }) {
const [loading, setLoading] = useState(false);
const [errored, setErrored] = useState(false);
const handleLogin = async () => {
setLoading(true);
try {
const { data: options } = await fetchApi<any>('/api/auth/webauthn/options', 'GET');
const res = await startAuthentication({ optionsJSON: options.options });
const { data, error } = await fetchApi<any>(
'/api/auth/webauthn',
'POST',
{ response: res },
{ 'x-zipline-client': JSON.stringify(getWebClient()) },
);
if (error) throw new Error(error.error);
onAuthSuccess(data);
} catch (e: any) {
setErrored(true);
setTimeout(() => setErrored(false), 3000);
notifications.show({ title: 'Auth Failed', message: e.message, color: 'red' });
} finally {
setLoading(false);
}
};
return (
<Button
onClick={handleLogin}
size='md'
fullWidth
variant='outline'
leftSection={<IconKey size='1rem' />}
color={errored ? 'red' : undefined}
loading={loading}
>
Login with passkey
</Button>
);
}
@@ -0,0 +1,57 @@
import { Anchor, Code, Modal, Text } from '@mantine/core';
export default function SecureWarningModal({
returnHttps,
opened,
onClose,
}: {
returnHttps: boolean;
opened: boolean;
onClose: () => void;
}) {
return (
<Modal opened={opened} onClose={onClose} title='HTTPS Configuration' size='lg'>
<Text>
{returnHttps ? (
<>
It appears that you are accessing this instance through an insecure context (HTTP), but the server
is configured to use HTTPS. This can lead to issues when logging in, as secure cookies may not be
sent by the browser.
</>
) : (
<>
It appears that you are accessing this instance through a secure context (HTTPS), but the server
is not configured to use HTTPS. This can lead issues when logging in.
</>
)}
</Text>
<Text mt='md'>
{returnHttps ? (
<>
To resolve this issue, please access this instance through HTTPS. If that is currently not
possible, you can temporarily set the <Code>CORE_RETURN_HTTPS_URLS</Code> environment variable to{' '}
<Code>false</Code>.
</>
) : (
<>
To resolve this issue, it is recommended to have your server configured to use HTTPS. This can be
done by setting the <Code>CORE_RETURN_HTTPS_URLS</Code> environment variable to <Code>true</Code>{' '}
and ensuring that your server has a valid SSL setup through a reverse proxy like Nginx or Caddy.
</>
)}
</Text>
<Text mt='md'>
After making these changes, restart the server for the changes to take effect. If you continue to
experience issues, please consult the{' '}
<Anchor
underline='always'
href='https://zipline.diced.sh/docs/config/settings#more-about-return-https-urls'
>
documentation
</Anchor>{' '}
or seek support.
</Text>
</Modal>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Modal, Center, PinInput, Text, Group, Button } from '@mantine/core';
import { IconX, IconShieldQuestion } from '@tabler/icons-react';
export default function TotpModal({
state,
onPinChange,
onVerify,
onCancel,
}: {
state: { open: boolean; disabled: boolean; error: string; pin: string };
onPinChange: (val: string) => void;
onVerify: () => void;
onCancel: () => void;
}) {
return (
<Modal onClose={onCancel} title='Enter code' opened={state.open} withCloseButton={false}>
<Center>
<PinInput
length={6}
oneTimeCode
type='number'
onChange={onPinChange}
error={!!state.error}
disabled={state.disabled}
size='xl'
autoFocus
/>
</Center>
{state.error && (
<Text ta='center' size='sm' c='red' mt='xs'>
{state.error}
</Text>
)}
<Group mt='sm' grow>
<Button leftSection={<IconX size='1rem' />} color='red' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button leftSection={<IconShieldQuestion size='1rem' />} loading={state.disabled} onClick={onVerify}>
Verify
</Button>
</Group>
</Modal>
);
}
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+1 -1
View File
@@ -61,7 +61,7 @@ export function flameshot(token: string, type: 'file' | 'url', options: Generato
}
for (const [key, value] of Object.entries(toAddHeaders)) {
curl.push('-H', `${key}: ${value}`);
curl.push('-H', `'${key}: ${value}'`);
}
let script;
View File
+58 -46
View File
@@ -1,5 +1,6 @@
import RelativeDate from '@/components/RelativeDate';
import { fetchApi } from '@/lib/fetchApi';
import useObjectState from '@/lib/hooks/useObjectState';
import { useUserStore } from '@/lib/store/user';
import { UserPasskey } from '@/prisma/client';
import { ActionIcon, Button, Group, Modal, Paper, Stack, Text, TextInput } from '@mantine/core';
@@ -11,18 +12,27 @@ import {
startRegistration,
} from '@simplewebauthn/browser';
import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { mutate } from 'swr';
export default function PasskeyButton() {
const user = useUserStore((state) => state.user);
const [pkData, setPkData] = useObjectState<{
open: boolean;
error: string | null;
loading: boolean;
const [passkeyOpen, setPasskeyOpen] = useState(false);
const [passkeyError, setPasskeyError] = useState<string | null>(null);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [namerShown, setNamerShown] = useState(false);
const [savedKey, setSavedKey] = useState<RegistrationResponseJSON | null>(null);
const [name, setName] = useState('');
nameShown: boolean;
savedKey: RegistrationResponseJSON | null;
name: string;
}>({
open: false,
error: null,
loading: false,
nameShown: false,
savedKey: null,
name: '',
});
const handleRegisterPasskey = async () => {
try {
@@ -31,30 +41,40 @@ export default function PasskeyButton() {
'GET',
);
setPasskeyLoading(true);
setPkData('loading', true);
const res = await startRegistration({ optionsJSON: data! });
setNamerShown(true);
setSavedKey(res);
setPkData({
nameShown: true,
savedKey: res,
});
} catch (e: any) {
setPasskeyError(e.message ?? 'An error occurred while creating a passkey');
setPasskeyLoading(false);
setSavedKey(null);
setPkData({
error: e.message ?? 'An error occurred while creating a passkey',
loading: false,
savedKey: null,
});
setTimeout(() => {
setPkData('error', null);
}, 10000);
}
};
const handleSavePasskey = async () => {
if (!savedKey) return;
if (!pkData.savedKey) return;
const { error } = await fetchApi('/api/user/mfa/passkey', 'POST', {
response: savedKey,
name: name.trim(),
response: pkData.savedKey,
name: pkData.name.trim(),
});
if (error) {
setNamerShown(false);
setPasskeyError('');
setPasskeyLoading(false);
setSavedKey(null);
setPkData({
nameShown: false,
savedKey: null,
error: '',
loading: false,
});
notifications.show({
title: 'Error while saving passkey',
@@ -63,10 +83,12 @@ export default function PasskeyButton() {
icon: <IconKeyOff size='1rem' />,
});
} else {
setNamerShown(false);
setPasskeyLoading(false);
setSavedKey(null);
setPasskeyOpen(false);
setPkData({
nameShown: false,
loading: false,
savedKey: null,
open: false,
});
notifications.show({
title: 'Passkey saved!',
@@ -116,19 +138,9 @@ export default function PasskeyButton() {
});
};
useEffect(() => {
if (passkeyError) {
const timeout = setTimeout(() => {
setPasskeyError(null);
}, 10000);
return () => clearTimeout(timeout);
}
}, [passkeyError]);
return (
<>
<Modal title='Manage passkeys' opened={passkeyOpen} onClose={() => setPasskeyOpen(false)}>
<Modal title='Manage passkeys' opened={pkData.open} onClose={() => setPkData('open', false)}>
<Stack gap='sm'>
<>
{user?.passkeys?.map((passkey, i) => (
@@ -159,31 +171,31 @@ export default function PasskeyButton() {
<Button
size='sm'
leftSection={<IconKey size='1rem' />}
color={passkeyError ? 'red' : undefined}
color={pkData.error ? 'red' : undefined}
onClick={handleRegisterPasskey}
loading={passkeyLoading}
disabled={!!passkeyError}
loading={pkData.loading}
disabled={!!pkData.error}
>
{passkeyError
{pkData.error
? 'Error while creating a passkey - try again later'
: passkeyLoading
: pkData.loading
? 'Loading...'
: 'Create a passkey'}
</Button>
{passkeyError && (
{pkData.error && (
<Text size='xs' c='red'>
{passkeyError}
{pkData.error}
</Text>
)}
{namerShown && (
{pkData.nameShown && (
<>
<Text size='sm'>Assign a name to this passkey so you can remember it later.</Text>
<TextInput
placeholder='Passkey name'
value={name}
onChange={(e) => setName(e.currentTarget.value)}
value={pkData.name}
onChange={(e) => setPkData('name', e.currentTarget.value)}
/>
<Button
@@ -199,7 +211,7 @@ export default function PasskeyButton() {
</Stack>
</Modal>
<Button size='sm' leftSection={<IconKey size='1rem' />} onClick={() => setPasskeyOpen(true)}>
<Button size='sm' leftSection={<IconKey size='1rem' />} onClick={() => setPkData('open', true)}>
Manage passkeys
</Button>
</>
View File
View File
View File
View File
@@ -1,15 +1,20 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { Button, Paper, SimpleGrid, Skeleton, Text, Title } from '@mantine/core';
import { useLogout } from '@/lib/hooks/useLogout';
import { ActionIcon, Button, Modal, Paper, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
import { modals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconLogout } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { IconLogout, IconTrashFilled, IconUsers } from '@tabler/icons-react';
import { useState } from 'react';
import useSWR from 'swr';
export default function SettingsSessions() {
const logout = useLogout();
const { data, isLoading, mutate } = useSWR<Response['/api/user/sessions']>('/api/user/sessions');
const [open, setOpen] = useState(false);
const handleLogOutOfAllDevices = async () => {
modals.openConfirmModal({
title: 'Log out of all devices?',
@@ -36,35 +41,107 @@ export default function SettingsSessions() {
});
};
const handleLogOutOfDevice = async (sessionId: string) => {
modals.openConfirmModal({
title: 'Log out of device?',
children: 'Are you sure you want to log out of this device?',
onConfirm: async () => {
const { error } = await fetchApi('/api/user/sessions', 'DELETE', {
sessionId,
});
if (!error) {
showNotification({
message: 'Logged out of device',
color: 'blue',
icon: <IconLogout size='1rem' />,
});
}
mutate();
},
labels: {
cancel: 'Cancel',
confirm: 'Log out',
},
});
};
const tableRows = data?.other.map((element) => (
<Table.Tr key={element.id}>
<Table.Td>{element.client}</Table.Td>
<Table.Td>{element.device}</Table.Td>
<Table.Td>{new Date(element.createdAt).toLocaleString()}</Table.Td>
<Table.Td>
<ActionIcon color='red' onClick={() => handleLogOutOfDevice(element.id)}>
<IconTrashFilled size='1rem' />
</ActionIcon>
</Table.Td>
</Table.Tr>
));
return (
<Paper withBorder p='sm'>
<Title order={2}>Sessions</Title>
<>
<Modal title='Sessions' opened={open} onClose={() => setOpen(false)} size='lg'>
<Paper withBorder>
{data?.other?.length ? (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Client</Table.Th>
<Table.Th>Device</Table.Th>
<Table.Th>Logged in at</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{tableRows}</Table.Tbody>
</Table>
) : (
<Text c='dimmed' p='md'>
No other sessions found
</Text>
)}
</Paper>
<Skeleton visible={isLoading} animate mt='sm'>
<Text c='dimmed'>
You are currently logged into {isLoading ? '...' : (data?.other?.length ?? '...')} other devices
</Text>
</Skeleton>
<SimpleGrid
cols={{
xs: 1,
sm: 2,
}}
mt='sm'
>
<Button
color='red'
disabled={isLoading || !data?.other?.length}
fullWidth
mt='sm'
color='yellow'
onClick={handleLogOutOfAllDevices}
leftSection={<IconLogout size='1rem' />}
disabled={!data?.other?.length}
>
Log out everywhere
Log out of all devices
</Button>
<Button color='yellow' component={Link} to='/auth/logout' leftSection={<IconLogout size='1rem' />}>
Log out of this browser
</Button>
</SimpleGrid>
</Paper>
</Modal>
<Paper withBorder p='sm'>
<Title order={2}>Sessions</Title>
<Skeleton visible={isLoading} animate mt='sm'>
<Text c='dimmed'>
You are currently logged into {isLoading ? '...' : (data?.other?.length ?? '...')} other devices
</Text>
</Skeleton>
<SimpleGrid
cols={{
xs: 1,
sm: 2,
}}
mt='sm'
>
<Button
onClick={() => setOpen(true)}
disabled={isLoading || !data?.other?.length}
leftSection={<IconUsers size='1rem' />}
>
View sessions
</Button>
<Button color='yellow' onClick={logout} leftSection={<IconLogout size='1rem' />}>
Log out of this browser
</Button>
</SimpleGrid>
</Paper>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More