mirror of
https://github.com/diced/zipline.git
synced 2026-06-15 03:18:43 -07:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b1db04159 | |||
| 4735b102c3 | |||
| 5d48735dfb | |||
| ea9599a67a | |||
| 9bd22bd574 | |||
| 6fef46246e | |||
| 3f159b3509 | |||
| eb3a58e790 | |||
| 454b40501a | |||
| 4c6679b568 | |||
| 3c757374e1 | |||
| c0e1aa9ac6 | |||
| 40fd0b19eb | |||
| 41240b7aff | |||
| 01f177fbc3 | |||
| ab1d394a46 | |||
| d08f1ba5da | |||
| 641a7c9b7b | |||
| a467ffe861 | |||
| 33ff667990 | |||
| e96015f5e0 | |||
| d4d1cdc885 | |||
| a7d831934d | |||
| e9ef6a2d40 | |||
| 7520efa835 | |||
| cff8454ac7 | |||
| 847779601a | |||
| 49c2088ea3 |
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
@@ -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
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+3
-1
@@ -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"
|
||||
}
|
||||
|
||||
+8
@@ -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
Executable → Regular
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -60,7 +60,7 @@ export default function Root({
|
||||
}}
|
||||
modals={contextModals}
|
||||
>
|
||||
<Notifications zIndex={10000000} />
|
||||
<Notifications position='top-center' zIndex={10000000} />
|
||||
<Outlet />
|
||||
</ModalsProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
+112
-326
@@ -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 && (
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
+3
-6
@@ -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
Executable → Regular
Executable → Regular
Executable → Regular
+61
-33
@@ -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' />}
|
||||
/>
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+5
-9
@@ -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');
|
||||
}
|
||||
|
||||
Executable → Regular
@@ -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>
|
||||
|
||||
Executable → Regular
Executable → Regular
+67
-41
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+12
-11
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+2
@@ -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()}` : ''}`);
|
||||
|
||||
|
||||
Executable → Regular
Executable → Regular
+2
-1
@@ -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;
|
||||
src/components/pages/files/views/FileTable.tsx → src/components/pages/files/views/FilesTableView.tsx
Executable → Regular
+34
-30
@@ -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,
|
||||
{
|
||||
Executable → Regular
Executable → Regular
+23
-10
@@ -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>
|
||||
|
||||
Executable → Regular
+8
-2
@@ -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'));
|
||||
}
|
||||
|
||||
Executable → Regular
+75
-4
@@ -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}'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();
|
||||
}
|
||||
};
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
+96
-81
@@ -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'
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+1
-1
@@ -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;
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+58
-46
@@ -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>
|
||||
</>
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
@@ -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
Reference in New Issue
Block a user