diff --git a/__tests__/email/admin-user-update.test.ts b/__tests__/email/admin-user-update.test.ts index 89b691fe..16105541 100644 --- a/__tests__/email/admin-user-update.test.ts +++ b/__tests__/email/admin-user-update.test.ts @@ -1,4 +1,4 @@ -import { POST, PUT } from '@/app/api/users/route' +import { GET, POST, PUT } from '@/app/api/users/route' import type { User } from '@prisma/client' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => ({ createUser: vi.fn(), invalidateEmailTokens: vi.fn(), db: { - user: { findUnique: vi.fn() }, + user: { findUnique: vi.fn(), findMany: vi.fn(), count: vi.fn() }, $transaction: vi.fn(), }, tx: { @@ -250,3 +250,93 @@ describe('administrator account update serialization', () => { expect(mocks.tx.user.update).not.toHaveBeenCalled() }) }) + +describe('user directory', () => { + it('keeps directory search behind the administrator guard', async () => { + mocks.requireAdmin.mockResolvedValue({ + response: new Response(null, { status: 403 }), + }) + const response = await GET( + new Request('https://flare.example/api/users?search=person') + ) + expect(response.status).toBe(403) + expect(mocks.db.user.findMany).not.toHaveBeenCalled() + expect(mocks.db.user.count).not.toHaveBeenCalled() + }) + + it('searches the complete directory with matching counts and safe public fields', async () => { + mocks.db.user.count.mockResolvedValue(26) + mocks.db.user.findMany.mockResolvedValue([]) + const response = await GET( + new Request( + 'https://flare.example/api/users?search=%20Jordan%20&role=USER&page=2&limit=25' + ) + ) + const body = await response.json() + expect(body.pagination).toEqual({ + total: 26, + pageCount: 2, + page: 2, + limit: 25, + }) + const query = mocks.db.user.findMany.mock.calls[0][0] + expect(query.where).toEqual({ + role: 'USER', + OR: [ + { name: { contains: 'Jordan', mode: 'insensitive' } }, + { email: { contains: 'Jordan', mode: 'insensitive' } }, + ], + }) + expect(mocks.db.user.count).toHaveBeenCalledWith({ where: query.where }) + expect(query.skip).toBe(25) + expect(query.take).toBe(25) + expect(query.select.password).toBeUndefined() + expect(query.select.uploadToken).toBeUndefined() + }) + + it.each([25, 0])( + 'returns to the first page after the last row on page 2 is removed (total %s)', + async (total) => { + mocks.db.user.count.mockResolvedValue(total) + mocks.db.user.findMany.mockResolvedValue([]) + const response = await GET( + new Request('https://flare.example/api/users?page=2&limit=25') + ) + expect(response.status).toBe(200) + expect((await response.json()).pagination.page).toBe(1) + expect(mocks.db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 0, take: 25 }) + ) + } + ) + + it('bounds an oversized requested page before calculating its database offset', async () => { + mocks.db.user.count.mockResolvedValue(26) + mocks.db.user.findMany.mockResolvedValue([]) + const response = await GET( + new Request( + 'https://flare.example/api/users?page=9007199254740991&limit=25' + ) + ) + expect(response.status).toBe(200) + expect((await response.json()).pagination.page).toBe(2) + expect(mocks.db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 25, take: 25 }) + ) + }) + + it.each(['page=NaN&limit=-4', 'page=-2&limit=Infinity'])( + 'recovers malformed pagination: %s', + async (query) => { + mocks.db.user.count.mockResolvedValue(0) + mocks.db.user.findMany.mockResolvedValue([]) + const response = await GET( + new Request(`https://flare.example/api/users?${query}`) + ) + expect(response.status).toBe(200) + expect(mocks.db.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 0, take: 25 }) + ) + } + ) +}) diff --git a/__tests__/files/direct-video-url.test.ts b/__tests__/files/direct-video-url.test.ts new file mode 100644 index 00000000..15bcffb7 --- /dev/null +++ b/__tests__/files/direct-video-url.test.ts @@ -0,0 +1,73 @@ +import { GET } from '@/app/(raw)/[userUrlId]/[filename]/direct/route' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + session: vi.fn(), + resolve: vi.fn(), + findUnique: vi.fn(), + access: vi.fn(), + publicUrl: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getAccessSession: mocks.session })) +vi.mock('@/lib/database/prisma', () => ({ + prisma: { file: { findUnique: mocks.findUnique } }, +})) +vi.mock('@/lib/files/access', () => ({ checkFileAccess: mocks.access })) +vi.mock('@/lib/files/resolve', () => ({ resolveFileUrlPath: mocks.resolve })) +vi.mock('@/lib/storage', () => ({ + getStorageProvider: async () => ({ getPublicUrl: mocks.publicUrl }), +})) + +beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue(null) + mocks.resolve.mockResolvedValue('/owner/preview.webm') + mocks.findUnique.mockResolvedValue({ + urlPath: '/owner/preview.webm', + path: 'preview.webm', + mimeType: 'video/webm', + }) + mocks.access.mockResolvedValue({ allowed: true }) + mocks.publicUrl.mockResolvedValue(null) +}) + +describe('direct video playback URL', () => { + it('preserves special characters in a protected local video password', async () => { + const password = 'watch+share&safe?<>#100%' + const query = new URLSearchParams({ password }) + const response = await GET( + new Request(`https://flare.example/owner/preview.webm/direct?${query}`), + { + params: Promise.resolve({ + userUrlId: 'owner', + filename: 'preview.webm', + }), + } + ) + expect(response.status).toBe(200) + const playback = new URL( + (await response.json()).url, + 'https://flare.example' + ) + expect(playback.pathname).toBe('/owner/preview.webm/raw') + expect([...playback.searchParams]).toEqual([['password', password]]) + expect(playback.hash).toBe('') + expect(mocks.access).toHaveBeenCalledWith(expect.anything(), null, password) + }) + + it('does not disclose a playback URL when file access is denied', async () => { + mocks.access.mockResolvedValue({ allowed: false, status: 403 }) + const response = await GET( + new Request('https://flare.example/owner/preview.webm/direct'), + { + params: Promise.resolve({ + userUrlId: 'owner', + filename: 'preview.webm', + }), + } + ) + expect(response.status).toBe(403) + expect(mocks.publicUrl).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/files/library-date-range.test.ts b/__tests__/files/library-date-range.test.ts new file mode 100644 index 00000000..8aecf22b --- /dev/null +++ b/__tests__/files/library-date-range.test.ts @@ -0,0 +1,73 @@ +import { GET } from '@/app/api/files/route' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + requireAuth: vi.fn(), + file: { count: vi.fn(), findMany: vi.fn() }, +})) + +vi.mock('@/lib/auth/api-auth', () => ({ requireAuth: mocks.requireAuth })) +vi.mock('@/lib/database/prisma', () => ({ prisma: { file: mocks.file } })) +vi.mock('@/lib/logger', () => ({ loggers: { files: { error: vi.fn() } } })) +vi.mock('@/lib/config', () => ({ getConfig: vi.fn() })) +vi.mock('@/lib/events/handlers/file-expiry', () => ({ + getFileExpirationInfo: vi.fn(), +})) +vi.mock('@/lib/files/streaming-upload', () => ({ + parseSingleFileUpload: vi.fn(), +})) +vi.mock('@/lib/security/rate-limit', () => ({ + rateLimit: vi.fn(), + uploadLimiter: {}, +})) +vi.mock('@/lib/storage', () => ({ getStorageProvider: vi.fn() })) +vi.mock('@/lib/uploads/finalize', () => ({ + cleanupUncommittedUpload: vi.fn(), + finalizeUpload: vi.fn(), + prepareUploadDestination: vi.fn(), +})) +vi.mock('@/lib/uploads/links', () => ({ uploadLinks: vi.fn() })) +vi.mock('@/lib/uploads/options', () => ({})) + +beforeEach(() => { + vi.clearAllMocks() + mocks.requireAuth.mockResolvedValue({ user: { id: 'library-owner' } }) + mocks.file.count.mockResolvedValue(0) + mocks.file.findMany.mockResolvedValue([]) +}) + +describe('file library date boundaries', () => { + it('keeps date-only API requests inclusive of the final day', async () => { + const response = await GET( + new Request('https://flare.example/api/files?dateTo=2026-09-13') + ) + const end = new Date('2026-09-13') + end.setHours(23, 59, 59, 999) + expect(response.status).toBe(200) + expect(mocks.file.count).toHaveBeenCalledWith({ + where: { userId: 'library-owner', AND: [{ uploadedAt: { lte: end } }] }, + }) + }) + it.each([ + ['UTC', '2026-09-13T00:00:00.000Z', '2026-09-13T23:59:59.999Z'], + ['UTC+14', '2026-09-12T10:00:00.000Z', '2026-09-13T09:59:59.999Z'], + ['UTC-7', '2026-09-13T07:00:00.000Z', '2026-09-14T06:59:59.999Z'], + ])( + 'preserves the selected local day for a user in %s', + async (_timezone, start, end) => { + const query = new URLSearchParams({ dateFrom: start, dateTo: end }) + const response = await GET( + new Request(`https://flare.example/api/files?${query}`) + ) + expect(response.status).toBe(200) + const expectedWhere = { + userId: 'library-owner', + AND: [{ uploadedAt: { gte: new Date(start), lte: new Date(end) } }], + } + expect(mocks.file.count).toHaveBeenCalledWith({ where: expectedWhere }) + expect(mocks.file.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expectedWhere }) + ) + } + ) +}) diff --git a/__tests__/hooks/user-management-loading.test.ts b/__tests__/hooks/user-management-loading.test.ts new file mode 100644 index 00000000..01c0a368 --- /dev/null +++ b/__tests__/hooks/user-management-loading.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useUserManagement } from '@/hooks/use-user-management' + +// Keep hook state across explicit renders without requiring a browser renderer. +// Network completion and abort timing remain under the test's control. +const harness = vi.hoisted(() => ({ + slots: [] as unknown[], + cursor: 0, + effects: [] as (() => void)[], + fetch: vi.fn(), + toast: vi.fn(), + refresh: vi.fn(), +})) + +vi.mock('react', () => ({ + useState: (initial: unknown) => { + const index = harness.cursor++ + if (!(index in harness.slots)) harness.slots[index] = initial + return [ + harness.slots[index], + (next: unknown) => { + harness.slots[index] = + typeof next === 'function' ? next(harness.slots[index]) : next + }, + ] + }, + useRef: (initial: unknown) => { + const index = harness.cursor++ + if (!(index in harness.slots)) harness.slots[index] = { current: initial } + return harness.slots[index] + }, + useCallback: (callback: unknown) => callback, + useEffect: (effect: () => void) => { + harness.effects.push(effect) + }, +})) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: harness.refresh }), +})) +vi.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ toast: harness.toast }), +})) + +function render(search = '') { + harness.cursor = 0 + harness.effects = [] + const result = useUserManagement({ search }) + harness.effects.forEach((effect) => effect()) + return result +} + +function listResponse(name: string) { + return Response.json({ + data: [{ id: name, name }], + pagination: { page: 1, pageCount: 1, total: 1, limit: 25 }, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + harness.slots = [] + vi.stubGlobal('fetch', harness.fetch) +}) + +afterEach(() => vi.unstubAllGlobals()) + +describe('user-list loading ownership', () => { + it('refreshes the latest filter when it changes before a mutation returns', async () => { + harness.fetch.mockResolvedValueOnce(listResponse('Existing user')) + await render().fetchUsers() + let completeCreate!: (response: Response) => void + harness.fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + completeCreate = resolve + }) + ) + const mutation = render().createUser({ + name: 'New user', + email: 'new@example.test', + role: 'USER', + }) + harness.fetch.mockResolvedValueOnce(listResponse('Matching user')) + await render('Matching').fetchUsers() + harness.fetch.mockResolvedValueOnce(listResponse('Matching user')) + completeCreate(Response.json({ data: { id: 'created' } })) + await mutation + expect(harness.fetch).toHaveBeenLastCalledWith( + expect.stringContaining('search=Matching'), + expect.anything() + ) + expect(render('Matching').isLoading).toBe(false) + }) + + it('keeps overlapping avatar mutations loading until both finish', async () => { + harness.fetch.mockResolvedValueOnce(listResponse('Existing user')) + await render().fetchUsers() + let finishFirst!: (response: Response) => void + let finishSecond!: (response: Response) => void + harness.fetch + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSecond = resolve + }) + ) + const first = render().removeUserAvatar('first') + const second = render().removeUserAvatar('second') + finishFirst(new Response(null, { status: 204 })) + await first + expect(render().isLoading).toBe(true) + finishSecond(new Response(null, { status: 204 })) + await second + expect(render().isLoading).toBe(false) + }) + + it.each(['create', 'update', 'delete'] as const)( + 'keeps a replacement query loading after an aborted %s refresh settles', + async (operation) => { + harness.fetch.mockResolvedValueOnce(listResponse('Existing user')) + await render().fetchUsers() + expect(render().isLoading).toBe(false) + + harness.fetch.mockResolvedValueOnce( + Response.json({ data: { id: 'created' } }) + ) + let refreshStarted!: () => void + const started = new Promise((resolve) => { + refreshStarted = resolve + }) + harness.fetch.mockImplementationOnce( + (_url: string, options: RequestInit) => + new Promise((_resolve, reject) => { + options.signal!.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')) + ) + refreshStarted() + }) + ) + const hook = render() + const form = { + name: 'New user', + email: 'new@example.test', + role: 'USER' as const, + } + const mutation = + operation === 'create' + ? hook.createUser(form) + : operation === 'update' + ? hook.updateUser('existing', form) + : hook.deleteUser('existing') + await started + + let completeReplacement!: (response: Response) => void + harness.fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + completeReplacement = resolve + }) + ) + const replacement = render('Matching').fetchUsers() + await mutation + + expect(harness.fetch).toHaveBeenLastCalledWith( + expect.stringContaining('search=Matching'), + expect.anything() + ) + expect(render('Matching').isLoading).toBe(true) + completeReplacement(listResponse('Matching user')) + await replacement + expect(render('Matching').isLoading).toBe(false) + expect(render('Matching').users[0].name).toBe('Matching user') + } + ) +}) diff --git a/__tests__/uploads/response.test.ts b/__tests__/uploads/response.test.ts new file mode 100644 index 00000000..1a34f71e --- /dev/null +++ b/__tests__/uploads/response.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' + +import { parseUploadResponse } from '@/lib/uploads/response' + +const response = { + url: 'https://files.example/alice/note.txt', + name: 'note.txt', + size: 25, + type: 'text/plain', +} + +describe('upload completion responses', () => { + it('accepts both wrapped API responses and legacy raw responses', () => { + expect(parseUploadResponse({ data: response })).toEqual(response) + expect(parseUploadResponse({ ...response, id: 'legacy-file' })).toEqual( + response + ) + }) + + it('preserves zero-byte files, separate open URLs, and formatted copy text', () => { + const withLinks = { + ...response, + size: 0, + url: 'https://files.example/api/files/alice/note.txt', + pageUrl: response.url, + copyText: `[note](${response.url})`, + } + expect(parseUploadResponse({ data: withLinks })).toEqual(withLinks) + }) + + it.each([null, undefined, [], 'uploaded', {}, { data: {} }, { data: null }])( + 'rejects a malformed successful body instead of completing the queue: %j', + (value) => { + expect(() => parseUploadResponse(value)).toThrow( + 'Could not read the upload response.' + ) + } + ) + + it.each(['name', 'url', 'size', 'type'] as const)( + 'requires the %s field in direct and multipart completion responses', + (field) => { + const incomplete: Record = { ...response } + delete incomplete[field] + expect(() => parseUploadResponse(incomplete)).toThrow() + expect(() => parseUploadResponse({ data: incomplete })).toThrow() + } + ) + + it.each([ + { name: 7 }, + { name: '' }, + { url: '' }, + { url: 'not-a-link' }, + { url: 'javascript:alert(1)' }, + { size: '25' }, + { size: -1 }, + { size: 1.5 }, + { size: Infinity }, + { size: NaN }, + { type: null }, + { type: '' }, + { pageUrl: null }, + { pageUrl: '/relative-path' }, + { copyText: { url: response.url } }, + ])('rejects invalid fields: %j', (invalid) => { + expect(() => + parseUploadResponse({ data: { ...response, ...invalid } }) + ).toThrow() + }) + + it('does not use outer fields to hide an invalid data envelope', () => { + expect(() => parseUploadResponse({ ...response, data: false })).toThrow() + }) +}) diff --git a/app/(main)/[userUrlId]/[filename]/page.tsx b/app/(main)/[userUrlId]/[filename]/page.tsx index 73333cd5..181738ef 100644 --- a/app/(main)/[userUrlId]/[filename]/page.tsx +++ b/app/(main)/[userUrlId]/[filename]/page.tsx @@ -1,16 +1,14 @@ import type { Metadata } from 'next' import { headers } from 'next/headers' -import Link from 'next/link' import { notFound } from 'next/navigation' -import { InstanceBrand } from '@/components/customization/instance-brand' +import { PublicState } from '@/components/auth/public-state' import { ShareLayout } from '@/components/customization/share-layout' import { ProtectedFile } from '@/components/file/protected-file' -import { DynamicBackground } from '@/components/layout/dynamic-background' import { Footer } from '@/components/layout/footer' import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { getAccessSession } from '@/lib/auth' import { getConfig } from '@/lib/config' @@ -266,53 +264,46 @@ export default async function FilePage({ access.reason === 'password_invalid' ? 'Try Again' : 'Access File' return ( -
- -
-
-
-
- - - -
+ : undefined} + > +
+
+ + + {access.reason === 'password_invalid' && ( + + )}
-
-
-
-
- -
-

- {title} -

-

- {description} -

- -
- -
- - -
-
-
- {showFooter && ( -
-
-
- )} -
-
+ + + ) } diff --git a/app/(main)/auth/login/page.tsx b/app/(main)/auth/login/page.tsx index c62db429..a0e14141 100644 --- a/app/(main)/auth/login/page.tsx +++ b/app/(main)/auth/login/page.tsx @@ -1,7 +1,6 @@ +import { AuthShell } from '@/components/auth/auth-shell' import { LoginForm } from '@/components/auth/login-form' import { OidcAutoRedirect } from '@/components/auth/oidc-auto-redirect' -import { InstanceBrand } from '@/components/customization/instance-brand' -import { DynamicBackground } from '@/components/layout/dynamic-background' import { isOidcProviderConfigured } from '@/lib/auth' import { getConfig } from '@/lib/config' @@ -23,47 +22,32 @@ export default async function LoginPage({ const oidcAutoRedirect = oidcConfigured && oidc.enforceSso && local !== '1' return ( -
- - -
-
- {} -
-
-
-
- -
-
-
- - {} -
-
-
- {oidcAutoRedirect ? ( - - ) : ( - - )} -
-
-
-
-
+ + {oidcAutoRedirect ? ( + + ) : ( + + )} + ) } diff --git a/app/(main)/auth/register/page.tsx b/app/(main)/auth/register/page.tsx index 6af24772..e5fbaba4 100644 --- a/app/(main)/auth/register/page.tsx +++ b/app/(main)/auth/register/page.tsx @@ -1,8 +1,7 @@ import { notFound } from 'next/navigation' +import { AuthShell } from '@/components/auth/auth-shell' import { RegisterForm } from '@/components/auth/register-form' -import { InstanceBrand } from '@/components/customization/instance-brand' -import { DynamicBackground } from '@/components/layout/dynamic-background' import { getConfig } from '@/lib/config' @@ -16,33 +15,11 @@ export default async function RegisterPage() { } return ( -
- - -
-
- {} -
-
-
-
- -
-
-
- - {} -
-
-
- -
-
-
-
-
+ + + ) } diff --git a/app/(main)/dashboard/paste/page.tsx b/app/(main)/dashboard/paste/page.tsx index 82c180dd..e902ac8a 100644 --- a/app/(main)/dashboard/paste/page.tsx +++ b/app/(main)/dashboard/paste/page.tsx @@ -13,21 +13,16 @@ export default async function PastePage() { return (
-
-
-
-

Create New Paste

-

- Create text pastes with syntax highlighting -

-
-
- -
-
-
- -
+
+

+ Create New Paste +

+

+ Create text pastes with syntax highlighting +

+
+
+
) diff --git a/app/(main)/dashboard/upload/page.tsx b/app/(main)/dashboard/upload/page.tsx index 9447dbd9..a99415bf 100644 --- a/app/(main)/dashboard/upload/page.tsx +++ b/app/(main)/dashboard/upload/page.tsx @@ -31,29 +31,21 @@ export default async function UploadPage() { const { value, unit } = config.settings.general.storage.maxUploadSize const maxSizeBytes = value * (unit === 'GB' ? 1024 * 1024 * 1024 : 1024 * 1024) - const formattedMaxSize = formatBytes(maxSizeBytes) return (
-
-
-
-

Upload Files

-

- Upload and share files with optional password protection -

-
-
- -
-
-
- -
+
+

Upload Files

+

+ Upload and share files with optional password protection +

+
+
+
) diff --git a/app/(main)/dashboard/urls/page.tsx b/app/(main)/dashboard/urls/page.tsx index a036e7b6..95c4c75f 100644 --- a/app/(main)/dashboard/urls/page.tsx +++ b/app/(main)/dashboard/urls/page.tsx @@ -13,21 +13,14 @@ export default async function URLsPage() { return (
-
-
-
-

URL Shortener

-

- Shorten long URLs and monitor their traffic -

-
-
- -
-
-
- -
+
+

URL Shortener

+

+ Shorten long URLs and monitor their traffic +

+
+
+
) diff --git a/app/(main)/dashboard/users/page.tsx b/app/(main)/dashboard/users/page.tsx index df893efb..daba522a 100644 --- a/app/(main)/dashboard/users/page.tsx +++ b/app/(main)/dashboard/users/page.tsx @@ -13,21 +13,14 @@ export default async function UsersPage() { return (
-
-
-
-

User Management

-

- Manage user accounts, roles, and permissions -

-
-
- -
-
-
- -
+
+

User Management

+

+ Manage user accounts, roles, and permissions. +

+
+
+
) diff --git a/app/(main)/error.tsx b/app/(main)/error.tsx index bf96f2dd..53c9c6e3 100644 --- a/app/(main)/error.tsx +++ b/app/(main)/error.tsx @@ -4,15 +4,8 @@ import Link from 'next/link' import { RefreshCcw } from 'lucide-react' -import { InstanceBrand } from '@/components/customization/instance-brand' +import { PublicState } from '@/components/auth/public-state' import { Button } from '@/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' export default function Error({ reset, @@ -21,29 +14,20 @@ export default function Error({ reset: () => void }) { return ( -
-
- - - + +
+ +
- -
- - - 500 - - Something went wrong - - - - - - -
-
+ ) } diff --git a/app/(main)/not-found.tsx b/app/(main)/not-found.tsx index e4181657..02281ec0 100644 --- a/app/(main)/not-found.tsx +++ b/app/(main)/not-found.tsx @@ -1,32 +1,26 @@ import Link from 'next/link' -import { InstanceBrand } from '@/components/customization/instance-brand' -import { - Card, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' +import { ArrowRight } from 'lucide-react' + +import { PublicState } from '@/components/auth/public-state' +import { Button } from '@/components/ui/button' export default function NotFound() { return ( -
-
- - + +
- -
- - - 404 - - Page Not Found - - - -
-
+ +

+ Expecting a shared file? Ask its owner for a new link. +

+ ) } diff --git a/app/(raw)/[userUrlId]/[filename]/direct/route.ts b/app/(raw)/[userUrlId]/[filename]/direct/route.ts index ab8d73ef..1b6efdbf 100644 --- a/app/(raw)/[userUrlId]/[filename]/direct/route.ts +++ b/app/(raw)/[userUrlId]/[filename]/direct/route.ts @@ -47,7 +47,7 @@ export async function GET( return NextResponse.json({ url: directUrl }) } - const rawUrl = `${file.urlPath}/raw${providedPassword ? `?password=${providedPassword}` : ''}` + const rawUrl = `${file.urlPath}/raw${providedPassword ? `?${new URLSearchParams({ password: providedPassword })}` : ''}` return NextResponse.json({ url: rawUrl }) } catch (error) { console.error('Direct URL error:', error) diff --git a/app/api/files/route.ts b/app/api/files/route.ts index 23907c1c..771a46c1 100644 --- a/app/api/files/route.ts +++ b/app/api/files/route.ts @@ -150,8 +150,13 @@ export async function GET(request: Request) { dateFilter.gte = startDate } if (dateTo) { + // The picker supplies the end of the selected day in the user's + // timezone. Preserve that instant instead of shifting it to server time. const endDate = new Date(dateTo) - endDate.setHours(23, 59, 59, 999) + // Keep date-only API requests inclusive of their final day. + if (/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) { + endDate.setHours(23, 59, 59, 999) + } dateFilter.lte = endDate } conditions.push({ uploadedAt: dateFilter }) diff --git a/app/api/users/route.ts b/app/api/users/route.ts index 399df0f4..0a6124eb 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -1,4 +1,5 @@ import { UserResponse, UserSchema } from '@/types/dto/user' +import type { Prisma } from '@prisma/client' import { hash } from 'bcryptjs' import { @@ -31,13 +32,35 @@ export async function GET(req: Request) { if (response) return response const { searchParams } = new URL(req.url) - const page = parseInt(searchParams.get('page') || '1') - const limit = parseInt(searchParams.get('limit') || '25') + const requestedPage = Number(searchParams.get('page') || '1') + const requestedLimit = Number(searchParams.get('limit') || '25') + const safePage = + Number.isSafeInteger(requestedPage) && requestedPage > 0 + ? requestedPage + : 1 + const limit = + Number.isSafeInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 100) + : 25 + const search = (searchParams.get('search') || '').trim().slice(0, 200) + const role = searchParams.get('role') + const where: Prisma.UserWhereInput = { + ...(role === 'ADMIN' || role === 'USER' ? { role } : {}), + ...(search + ? { + OR: [ + { name: { contains: search, mode: 'insensitive' as const } }, + { email: { contains: search, mode: 'insensitive' as const } }, + ], + } + : {}), + } + const total = await prisma.user.count({ where }) + const page = Math.min(safePage, Math.max(1, Math.ceil(total / limit))) const skip = (page - 1) * limit - const total = await prisma.user.count() - const users = await prisma.user.findMany({ + where, select: { id: true, name: true, diff --git a/components/auth/auth-shell.tsx b/components/auth/auth-shell.tsx new file mode 100644 index 00000000..4e1c2e69 --- /dev/null +++ b/components/auth/auth-shell.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react' + +import Link from 'next/link' + +import { InstanceBrand } from '@/components/customization/instance-brand' +import { DynamicBackground } from '@/components/layout/dynamic-background' + +export function AuthShell({ + title, + description, + children, +}: { + title: string + description: string + children: ReactNode +}) { + return ( +
+ +
+
+ + + +
+
+

+ {title} +

+

+ {description} +

+
+ {children} +
+
+
+
+ ) +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index f46cf205..212045f2 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -56,6 +56,7 @@ export function LoginForm({ try { await signIn('oidc', { callbackUrl: '/dashboard' }) } catch { + setError('Unable to start sign-in. Please try again.') setIsOidcLoading(false) } } @@ -96,25 +97,7 @@ export function LoginForm({ } return ( -
-
-

Welcome back

-

- {registrationsEnabled ? ( - <> - Don't have an account?{' '} - - Sign up now - - - ) : ( - disabledMessage || 'Registrations are currently disabled' - )} -

-
+
{error && ( -
- +
+
)} @@ -166,7 +155,7 @@ export function LoginForm({
)} +
+ {registrationsEnabled ? ( + <> + New here?{' '} + + Create an account + + + ) : ( + disabledMessage || + 'Registration is closed. Contact your administrator for an account.' + )} +
) } diff --git a/components/auth/oidc-auto-redirect.tsx b/components/auth/oidc-auto-redirect.tsx index a5ac1d75..6bf521f8 100644 --- a/components/auth/oidc-auto-redirect.tsx +++ b/components/auth/oidc-auto-redirect.tsx @@ -34,17 +34,19 @@ export function OidcAutoRedirect({ }, []) return ( -
-
-

- {errorCode ? 'Sign-in failed' : 'Redirecting to sign-in'} -

-

- {errorCode - ? getOidcErrorMessage(errorCode) - : "Taking you to your organization's sign-in page..."} -

-
+
+

+ {errorCode + ? getOidcErrorMessage(errorCode) + : 'Taking you to your organization’s sign-in page…'} +

diff --git a/components/auth/public-state.tsx b/components/auth/public-state.tsx new file mode 100644 index 00000000..0a635088 --- /dev/null +++ b/components/auth/public-state.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from 'react' + +import Link from 'next/link' + +import type { LucideIcon } from 'lucide-react' + +import { InstanceBrand } from '@/components/customization/instance-brand' +import { DynamicBackground } from '@/components/layout/dynamic-background' + +export function PublicState({ + icon: Icon, + statusCode, + title, + description, + children, + footer, + compact = false, +}: { + icon?: LucideIcon + statusCode?: string + title: string + description: string + children: ReactNode + footer?: ReactNode + compact?: boolean +}) { + return ( +
+ +
+ + + +
+
+
+
+ {statusCode ? ( + + ) : Icon ? ( +
+ {children} +
+
+ {footer} +
+ ) +} diff --git a/components/auth/register-form.tsx b/components/auth/register-form.tsx index d7ee298c..20956e99 100644 --- a/components/auth/register-form.tsx +++ b/components/auth/register-form.tsx @@ -95,20 +95,12 @@ export function RegisterForm() { } return ( -
-
-

- Create an account -

-

- Enter your details to get started + + {verificationRequired && ( +

+ We’ll ask you to confirm your email before using your account.

- {verificationRequired && ( -

- You will confirm your email address before using your account. -

- )} -
+ )}
@@ -152,8 +144,13 @@ export function RegisterForm() { disabled={isLoading} className="h-11 bg-background/50 focus:bg-background transition-colors" autoComplete="new-password" - placeholder="Create a strong password" + placeholder="At least 8 characters" + minLength={8} + aria-describedby="password-hint" /> +

+ Use at least 8 characters. +

{error && ( -
- +
+
)} @@ -193,7 +196,7 @@ export function RegisterForm() { 'Create account' )} -
+
Already have an account?{' '} - {sharing.showFilename && ( -

+ {sharing.showFilename ? ( +

{filename}

+ ) : ( +

Shared file

)} {sharing.showSize && ( -

{size}

+

{size}

)} ) const author = sharing.showUploader && ( -
- Uploaded by - +
+ + Uploaded by + + - {uploader.name.charAt(0) || '?'} + + {uploader.name.charAt(0) || '?'} + - + {uploader.name}
) + const brand = ( + + + + ) if (style === 'minimal') return (
-
- - - +
+ {brand} {author}
-
- {(sharing.showFilename || sharing.showSize) && ( -
{details}
- )} +
+
+ {details} +
{children}
{showFooter &&