mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +08:00
fix(web): fix 4 frontend bugs found during acceptance testing
- SVAR file manager: rewrite adapter to use path-based IDs with bidirectional PathMapper, fixing files not showing in the UI - Admin storages: fix API path /api/storages → /api/admin/storages - Admin users: add missing route file at admin/users/index.tsx - Storage status: fix type comparison (string not number) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ export function DeleteStorageDialog({ open, onOpenChange, storage }: DeleteStora
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/storages/${id}`, {
|
||||
const res = await fetch(`/api/admin/storages/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
@@ -78,7 +78,7 @@ export function StorageFormDialog({ open, onOpenChange, storage }: StorageFormDi
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: StorageFormValues) => {
|
||||
const url = isEditing ? `/api/storages/${storage.id}` : '/api/storages'
|
||||
const url = isEditing ? `/api/admin/storages/${storage.id}` : '/api/admin/storages'
|
||||
const method = isEditing ? 'PUT' : 'POST'
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
|
||||
@@ -22,7 +22,7 @@ function makeStorageObject(overrides: Partial<StorageObject> = {}): StorageObjec
|
||||
type: 'text/plain',
|
||||
size: 512,
|
||||
dirtype: DirType.FILE,
|
||||
parent: 'root',
|
||||
parent: '',
|
||||
object: 'path/to/obj',
|
||||
storageId: 'storage1',
|
||||
status: 'active',
|
||||
@@ -32,101 +32,105 @@ function makeStorageObject(overrides: Partial<StorageObject> = {}): StorageObjec
|
||||
}
|
||||
}
|
||||
|
||||
function makeListResponse(items: StorageObject[]) {
|
||||
return { items, total: items.length, page: 1, pageSize: 500 }
|
||||
}
|
||||
|
||||
describe('loadFolder', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('returns entities mapped from listObjects items', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce({
|
||||
items: [makeStorageObject()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
})
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject()]))
|
||||
|
||||
const result = await loadFolder('root')
|
||||
const result = await loadFolder('', '/')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'obj1',
|
||||
name: 'my-file.txt',
|
||||
size: 512,
|
||||
type: 'file',
|
||||
})
|
||||
expect(result[0].name).toBe('my-file.txt')
|
||||
expect(result[0].size).toBe(512)
|
||||
expect(result[0].type).toBe('file')
|
||||
})
|
||||
|
||||
it('maps file dirtype to type "file"', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce({
|
||||
items: [makeStorageObject({ dirtype: DirType.FILE })],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
})
|
||||
it('assigns path-based id for items in the root folder', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([makeStorageObject({ id: 'db-id-1', name: 'song.mp3' })]),
|
||||
)
|
||||
|
||||
const [entity] = await loadFolder('root')
|
||||
const [entity] = await loadFolder('', '/')
|
||||
|
||||
expect(entity.id).toBe('/song.mp3')
|
||||
})
|
||||
|
||||
it('assigns path-based id for items in a nested folder', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([makeStorageObject({ id: 'db-id-2', name: 'track.flac' })]),
|
||||
)
|
||||
|
||||
const [entity] = await loadFolder('db-music', '/Music')
|
||||
|
||||
expect(entity.id).toBe('/Music/track.flac')
|
||||
})
|
||||
|
||||
it('maps file dirtype to type "file" with lazy false', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject({ dirtype: DirType.FILE })]))
|
||||
|
||||
const [entity] = await loadFolder('', '/')
|
||||
|
||||
expect(entity.type).toBe('file')
|
||||
expect(entity.lazy).toBe(false)
|
||||
})
|
||||
|
||||
it('maps folder dirtype to type "folder" with lazy true', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce({
|
||||
items: [makeStorageObject({ dirtype: DirType.USER_FOLDER })],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
})
|
||||
it('maps user folder dirtype to type "folder" with lazy true', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([makeStorageObject({ dirtype: DirType.USER_FOLDER })]),
|
||||
)
|
||||
|
||||
const [entity] = await loadFolder('root')
|
||||
const [entity] = await loadFolder('', '/')
|
||||
|
||||
expect(entity.type).toBe('folder')
|
||||
expect(entity.lazy).toBe(true)
|
||||
})
|
||||
|
||||
it('converts updatedAt string to Date object', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce({
|
||||
items: [makeStorageObject({ updatedAt: '2024-06-01T00:00:00Z' })],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
})
|
||||
it('converts updatedAt string to a Date instance', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([makeStorageObject({ updatedAt: '2024-06-01T00:00:00Z' })]),
|
||||
)
|
||||
|
||||
const [entity] = await loadFolder('root')
|
||||
const [entity] = await loadFolder('', '/')
|
||||
|
||||
expect(entity.date).toBeInstanceOf(Date)
|
||||
expect(entity.date!.toISOString()).toBe('2024-06-01T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('preserves alias, status, and parent as custom fields', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce({
|
||||
items: [makeStorageObject({ alias: 'my-alias', status: 'active', parent: 'folder1' })],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
})
|
||||
|
||||
const [entity] = await loadFolder('root')
|
||||
|
||||
expect((entity as Record<string, unknown>)._alias).toBe('my-alias')
|
||||
expect((entity as Record<string, unknown>)._status).toBe('active')
|
||||
expect((entity as Record<string, unknown>)._parent).toBe('folder1')
|
||||
})
|
||||
|
||||
it('returns empty array when folder has no items', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce({ items: [], total: 0, page: 1, pageSize: 500 })
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([]))
|
||||
|
||||
const result = await loadFolder('empty-folder')
|
||||
const result = await loadFolder('empty-folder', '/Empty')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('passes parent argument to listObjects', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce({ items: [], total: 0, page: 1, pageSize: 500 })
|
||||
it('passes the dbParentId to listObjects', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([]))
|
||||
|
||||
await loadFolder('folder-abc')
|
||||
await loadFolder('db-folder-abc', '/Docs')
|
||||
|
||||
expect(api.listObjects).toHaveBeenCalledWith('folder-abc')
|
||||
expect(api.listObjects).toHaveBeenCalledWith('db-folder-abc')
|
||||
})
|
||||
|
||||
it('handles multiple items, all getting correct path-based ids', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([
|
||||
makeStorageObject({ id: 'id-a', name: 'alpha.txt' }),
|
||||
makeStorageObject({ id: 'id-b', name: 'beta.pdf' }),
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await loadFolder('', '/')
|
||||
|
||||
expect(result[0].id).toBe('/alpha.txt')
|
||||
expect(result[1].id).toBe('/beta.pdf')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -135,45 +139,55 @@ describe('refreshFolder', () => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('calls loadFolder and execs provide-data with the loaded entities', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce({
|
||||
items: [makeStorageObject()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
})
|
||||
it('calls provide-data with the parentPath as the id', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject()]))
|
||||
const execMock = vi.fn()
|
||||
const apiMock = { exec: execMock } as never
|
||||
|
||||
await refreshFolder(apiMock, 'root')
|
||||
await refreshFolder(apiMock, '', '/')
|
||||
|
||||
expect(api.listObjects).toHaveBeenCalledWith('root')
|
||||
expect(execMock).toHaveBeenCalledWith('provide-data', {
|
||||
id: 'root',
|
||||
id: '/',
|
||||
data: expect.any(Array),
|
||||
skipProvider: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the correct entities to provide-data', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce({
|
||||
items: [makeStorageObject({ id: 'f1', name: 'report.pdf' })],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
})
|
||||
it('passes the dbParentId to listObjects', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([]))
|
||||
const apiMock = { exec: vi.fn() } as never
|
||||
|
||||
await refreshFolder(apiMock, 'db-docs', '/Docs')
|
||||
|
||||
expect(api.listObjects).toHaveBeenCalledWith('db-docs')
|
||||
})
|
||||
|
||||
it('passes the loaded entities to provide-data', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([makeStorageObject({ id: 'fid', name: 'report.pdf' })]),
|
||||
)
|
||||
const execMock = vi.fn()
|
||||
const apiMock = { exec: execMock } as never
|
||||
|
||||
await refreshFolder(apiMock, 'docs')
|
||||
await refreshFolder(apiMock, '', '/')
|
||||
|
||||
const [, payload] = execMock.mock.calls[0] as [string, { data: unknown[] }]
|
||||
expect(payload.data).toHaveLength(1)
|
||||
expect((payload.data[0] as { id: string }).id).toBe('f1')
|
||||
expect((payload.data[0] as { id: string }).id).toBe('/report.pdf')
|
||||
})
|
||||
|
||||
it('uses the nested parentPath as the provide-data id', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([]))
|
||||
const execMock = vi.fn()
|
||||
const apiMock = { exec: execMock } as never
|
||||
|
||||
await refreshFolder(apiMock, 'db-music', '/Music')
|
||||
|
||||
expect(execMock).toHaveBeenCalledWith('provide-data', expect.objectContaining({ id: '/Music' }))
|
||||
})
|
||||
})
|
||||
|
||||
// Helper to create an IApi mock with intercept recording
|
||||
// Helper that builds a fake IApi with intercept capture and trigger
|
||||
function makeApiMock() {
|
||||
const handlers: Record<string, (ev: unknown) => unknown> = {}
|
||||
return {
|
||||
@@ -190,7 +204,7 @@ describe('connectAdapter', () => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('registers intercepts for all file manager events', () => {
|
||||
it('registers intercepts for all required file manager events', () => {
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
@@ -205,71 +219,92 @@ describe('connectAdapter', () => {
|
||||
})
|
||||
|
||||
describe('request-data intercept', () => {
|
||||
it('loads folder and calls provide-data with loaded entities', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce({
|
||||
items: [makeStorageObject()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
})
|
||||
it('loads folder for the root path and calls provide-data', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject()]))
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
const returnValue = await apiMock.trigger('request-data', { id: 'root' })
|
||||
const returnValue = await apiMock.trigger('request-data', { id: '/' })
|
||||
|
||||
expect(api.listObjects).toHaveBeenCalledWith('root')
|
||||
// For root path "/" resolveDbId returns '' (empty string)
|
||||
expect(api.listObjects).toHaveBeenCalledWith('')
|
||||
expect(apiMock.exec).toHaveBeenCalledWith(
|
||||
'provide-data',
|
||||
expect.objectContaining({
|
||||
id: 'root',
|
||||
skipProvider: true,
|
||||
data: expect.any(Array),
|
||||
}),
|
||||
expect.objectContaining({ id: '/', skipProvider: true, data: expect.any(Array) }),
|
||||
)
|
||||
expect(returnValue).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves a registered path to its db id before loading folder', async () => {
|
||||
// First load root to register mapping for /Music -> db-music
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([makeStorageObject({ id: 'db-music', name: 'Music', dirtype: DirType.USER_FOLDER })]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
// Now trigger request-data for /Music — it must resolve to db-music
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([]))
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
await apiMock.trigger('request-data', { id: '/Music' })
|
||||
|
||||
expect(api.listObjects).toHaveBeenCalledWith('db-music')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename-file intercept', () => {
|
||||
it('calls updateObject with new name', async () => {
|
||||
vi.mocked(api.updateObject).mockResolvedValueOnce(makeStorageObject({ name: 'new-name.txt' }))
|
||||
it('resolves path id to db id and calls updateObject with new name', async () => {
|
||||
// Register mapping: /song.mp3 -> obj1
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([makeStorageObject({ id: 'obj1', name: 'song.mp3' })]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
vi.mocked(api.updateObject).mockResolvedValueOnce(makeStorageObject({ name: 'renamed.mp3' }))
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
await apiMock.trigger('rename-file', { id: 'id1', name: 'new-name.txt' })
|
||||
await apiMock.trigger('rename-file', { id: '/song.mp3', name: 'renamed.mp3' })
|
||||
|
||||
expect(api.updateObject).toHaveBeenCalledWith('id1', { name: 'new-name.txt' })
|
||||
expect(api.updateObject).toHaveBeenCalledWith('obj1', { name: 'renamed.mp3' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('create-file intercept', () => {
|
||||
it('creates a folder object and returns newId', async () => {
|
||||
const created = makeStorageObject({ id: 'new-folder', dirtype: DirType.USER_FOLDER })
|
||||
it('creates a folder and returns path-based newId', async () => {
|
||||
// Register mapping for parent: /Docs -> db-docs
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([makeStorageObject({ id: 'db-docs', name: 'Docs', dirtype: DirType.USER_FOLDER })]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
const created = makeStorageObject({ id: 'new-folder-id', name: 'Projects', dirtype: DirType.USER_FOLDER })
|
||||
vi.mocked(api.createObject).mockResolvedValueOnce(created)
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
const result = await apiMock.trigger('create-file', {
|
||||
file: { name: 'My Folder', type: 'folder' },
|
||||
parent: 'root',
|
||||
file: { name: 'Projects', type: 'folder' },
|
||||
parent: '/Docs',
|
||||
})
|
||||
|
||||
expect(api.createObject).toHaveBeenCalledWith({
|
||||
name: 'My Folder',
|
||||
name: 'Projects',
|
||||
type: 'folder',
|
||||
parent: 'root',
|
||||
parent: 'db-docs',
|
||||
dirtype: DirType.USER_FOLDER,
|
||||
})
|
||||
expect(result).toEqual({ newId: 'new-folder' })
|
||||
expect(result).toEqual({ newId: '/Docs/Projects' })
|
||||
})
|
||||
|
||||
it('does nothing and returns undefined for non-folder file type', async () => {
|
||||
it('does not create anything for non-folder file type', async () => {
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
const result = await apiMock.trigger('create-file', {
|
||||
file: { name: 'doc.pdf', type: 'application/pdf' },
|
||||
parent: 'root',
|
||||
parent: '/',
|
||||
})
|
||||
|
||||
expect(api.createObject).not.toHaveBeenCalled()
|
||||
@@ -278,17 +313,25 @@ describe('connectAdapter', () => {
|
||||
})
|
||||
|
||||
describe('delete-files intercept', () => {
|
||||
it('deletes all provided ids', async () => {
|
||||
it('resolves path ids to db ids and deletes them all', async () => {
|
||||
// Register: /a.txt -> id-a, /b.txt -> id-b
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([
|
||||
makeStorageObject({ id: 'id-a', name: 'a.txt' }),
|
||||
makeStorageObject({ id: 'id-b', name: 'b.txt' }),
|
||||
]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
vi.mocked(api.deleteObject).mockResolvedValue({ id: 'any', deleted: true })
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
await apiMock.trigger('delete-files', { ids: ['id1', 'id2', 'id3'] })
|
||||
await apiMock.trigger('delete-files', { ids: ['/a.txt', '/b.txt'] })
|
||||
|
||||
expect(api.deleteObject).toHaveBeenCalledTimes(3)
|
||||
expect(api.deleteObject).toHaveBeenCalledWith('id1')
|
||||
expect(api.deleteObject).toHaveBeenCalledWith('id2')
|
||||
expect(api.deleteObject).toHaveBeenCalledWith('id3')
|
||||
expect(api.deleteObject).toHaveBeenCalledTimes(2)
|
||||
expect(api.deleteObject).toHaveBeenCalledWith('id-a')
|
||||
expect(api.deleteObject).toHaveBeenCalledWith('id-b')
|
||||
})
|
||||
|
||||
it('handles empty ids array without calling deleteObject', async () => {
|
||||
@@ -301,16 +344,35 @@ describe('connectAdapter', () => {
|
||||
})
|
||||
|
||||
it('throws with failure count when one delete fails', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([
|
||||
makeStorageObject({ id: 'id-c', name: 'c.txt' }),
|
||||
makeStorageObject({ id: 'id-d', name: 'd.txt' }),
|
||||
]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
vi.mocked(api.deleteObject)
|
||||
.mockResolvedValueOnce({ id: 'id1', deleted: true })
|
||||
.mockResolvedValueOnce({ id: 'id-c', deleted: true })
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
await expect(apiMock.trigger('delete-files', { ids: ['id1', 'id2'] })).rejects.toThrow('1 operation(s) failed')
|
||||
await expect(apiMock.trigger('delete-files', { ids: ['/c.txt', '/d.txt'] })).rejects.toThrow(
|
||||
'1 operation(s) failed',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws with total failure count when all deletes fail', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([
|
||||
makeStorageObject({ id: 'id-e', name: 'e.txt' }),
|
||||
makeStorageObject({ id: 'id-f', name: 'f.txt' }),
|
||||
makeStorageObject({ id: 'id-g', name: 'g.txt' }),
|
||||
]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
vi.mocked(api.deleteObject)
|
||||
.mockRejectedValueOnce(new Error('err1'))
|
||||
.mockRejectedValueOnce(new Error('err2'))
|
||||
@@ -318,89 +380,133 @@ describe('connectAdapter', () => {
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
await expect(apiMock.trigger('delete-files', { ids: ['a', 'b', 'c'] })).rejects.toThrow('3 operation(s) failed')
|
||||
await expect(apiMock.trigger('delete-files', { ids: ['/e.txt', '/f.txt', '/g.txt'] })).rejects.toThrow(
|
||||
'3 operation(s) failed',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('move-files intercept', () => {
|
||||
it('updates parent for all ids and returns newIds', async () => {
|
||||
it('resolves path ids to db ids and moves them to the target', async () => {
|
||||
// Register: /file1.txt -> db-id-1, /file2.txt -> db-id-2, /Dest -> db-dest
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([
|
||||
makeStorageObject({ id: 'db-id-1', name: 'file1.txt' }),
|
||||
makeStorageObject({ id: 'db-id-2', name: 'file2.txt' }),
|
||||
makeStorageObject({ id: 'db-dest', name: 'Dest', dirtype: DirType.USER_FOLDER }),
|
||||
]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
vi.mocked(api.updateObject)
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'id1', parent: 'folder2' }))
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'id2', parent: 'folder2' }))
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'db-id-1', name: 'file1.txt', parent: 'db-dest' }))
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'db-id-2', name: 'file2.txt', parent: 'db-dest' }))
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
const result = await apiMock.trigger('move-files', { ids: ['id1', 'id2'], target: 'folder2' })
|
||||
const result = await apiMock.trigger('move-files', { ids: ['/file1.txt', '/file2.txt'], target: '/Dest' })
|
||||
|
||||
expect(api.updateObject).toHaveBeenCalledWith('id1', { parent: 'folder2' })
|
||||
expect(api.updateObject).toHaveBeenCalledWith('id2', { parent: 'folder2' })
|
||||
expect(result).toEqual({ newIds: ['id1', 'id2'] })
|
||||
expect(api.updateObject).toHaveBeenCalledWith('db-id-1', { parent: 'db-dest' })
|
||||
expect(api.updateObject).toHaveBeenCalledWith('db-id-2', { parent: 'db-dest' })
|
||||
expect(result).toEqual({ newIds: ['/Dest/file1.txt', '/Dest/file2.txt'] })
|
||||
})
|
||||
|
||||
it('returns empty newIds for empty ids array', async () => {
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
const result = await apiMock.trigger('move-files', { ids: [], target: 'folder2' })
|
||||
const result = await apiMock.trigger('move-files', { ids: [], target: '/' })
|
||||
|
||||
expect(result).toEqual({ newIds: [] })
|
||||
})
|
||||
|
||||
it('throws with failure count when one move fails', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([
|
||||
makeStorageObject({ id: 'mv-a', name: 'mv-a.txt' }),
|
||||
makeStorageObject({ id: 'mv-b', name: 'mv-b.txt' }),
|
||||
]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
vi.mocked(api.updateObject)
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'id1', parent: 'folder2' }))
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'mv-a', name: 'mv-a.txt' }))
|
||||
.mockRejectedValueOnce(new Error('forbidden'))
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
await expect(apiMock.trigger('move-files', { ids: ['id1', 'id2'], target: 'folder2' })).rejects.toThrow(
|
||||
await expect(apiMock.trigger('move-files', { ids: ['/mv-a.txt', '/mv-b.txt'], target: '/' })).rejects.toThrow(
|
||||
'1 operation(s) failed',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy-files intercept', () => {
|
||||
it('copies all ids to target and returns newIds', async () => {
|
||||
it('resolves path ids to db ids and copies them to the target', async () => {
|
||||
// Register: /orig1.txt -> db-orig1, /orig2.txt -> db-orig2, /CopyDest -> db-copy-dest
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([
|
||||
makeStorageObject({ id: 'db-orig1', name: 'orig1.txt' }),
|
||||
makeStorageObject({ id: 'db-orig2', name: 'orig2.txt' }),
|
||||
makeStorageObject({ id: 'db-copy-dest', name: 'CopyDest', dirtype: DirType.USER_FOLDER }),
|
||||
]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
vi.mocked(api.copyObject)
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'copy1' }))
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'copy2' }))
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'cp1', name: 'orig1.txt' }))
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'cp2', name: 'orig2.txt' }))
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
const result = await apiMock.trigger('copy-files', { ids: ['orig1', 'orig2'], target: 'dest' })
|
||||
const result = await apiMock.trigger('copy-files', { ids: ['/orig1.txt', '/orig2.txt'], target: '/CopyDest' })
|
||||
|
||||
expect(api.copyObject).toHaveBeenCalledWith('orig1', 'dest')
|
||||
expect(api.copyObject).toHaveBeenCalledWith('orig2', 'dest')
|
||||
expect(result).toEqual({ newIds: ['copy1', 'copy2'] })
|
||||
expect(api.copyObject).toHaveBeenCalledWith('db-orig1', 'db-copy-dest')
|
||||
expect(api.copyObject).toHaveBeenCalledWith('db-orig2', 'db-copy-dest')
|
||||
expect(result).toEqual({ newIds: ['/CopyDest/orig1.txt', '/CopyDest/orig2.txt'] })
|
||||
})
|
||||
|
||||
it('returns empty newIds for empty ids array', async () => {
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
const result = await apiMock.trigger('copy-files', { ids: [], target: 'dest' })
|
||||
const result = await apiMock.trigger('copy-files', { ids: [], target: '/' })
|
||||
|
||||
expect(result).toEqual({ newIds: [] })
|
||||
})
|
||||
|
||||
it('throws with failure count when one copy fails', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([
|
||||
makeStorageObject({ id: 'cp-src-a', name: 'cp-src-a.txt' }),
|
||||
makeStorageObject({ id: 'cp-src-b', name: 'cp-src-b.txt' }),
|
||||
]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
vi.mocked(api.copyObject)
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'copy1' }))
|
||||
.mockResolvedValueOnce(makeStorageObject({ id: 'cp-dst-a', name: 'cp-src-a.txt' }))
|
||||
.mockRejectedValueOnce(new Error('conflict'))
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
await expect(apiMock.trigger('copy-files', { ids: ['orig1', 'orig2'], target: 'dest' })).rejects.toThrow(
|
||||
'1 operation(s) failed',
|
||||
)
|
||||
await expect(
|
||||
apiMock.trigger('copy-files', { ids: ['/cp-src-a.txt', '/cp-src-b.txt'], target: '/' }),
|
||||
).rejects.toThrow('1 operation(s) failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('download-file intercept', () => {
|
||||
it('opens download URL in new tab when downloadUrl is present', async () => {
|
||||
it('resolves path id to db id and opens downloadUrl in a new tab', async () => {
|
||||
// Register: /report.pdf -> db-report
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([makeStorageObject({ id: 'db-report', name: 'report.pdf' })]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
vi.mocked(api.getObject).mockResolvedValueOnce({
|
||||
...makeStorageObject(),
|
||||
downloadUrl: 'https://s3/file.txt',
|
||||
...makeStorageObject({ id: 'db-report' }),
|
||||
downloadUrl: 'https://s3/report.pdf',
|
||||
})
|
||||
const openMock = vi.fn()
|
||||
vi.stubGlobal('window', { open: openMock })
|
||||
@@ -408,24 +514,29 @@ describe('connectAdapter', () => {
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
const result = await apiMock.trigger('download-file', { id: 'id1' })
|
||||
const result = await apiMock.trigger('download-file', { id: '/report.pdf' })
|
||||
|
||||
expect(api.getObject).toHaveBeenCalledWith('id1')
|
||||
expect(openMock).toHaveBeenCalledWith('https://s3/file.txt', '_blank', 'noopener,noreferrer')
|
||||
expect(api.getObject).toHaveBeenCalledWith('db-report')
|
||||
expect(openMock).toHaveBeenCalledWith('https://s3/report.pdf', '_blank', 'noopener,noreferrer')
|
||||
expect(result).toBe(false)
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not open window when downloadUrl is absent', async () => {
|
||||
vi.mocked(api.getObject).mockResolvedValueOnce(makeStorageObject())
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(
|
||||
makeListResponse([makeStorageObject({ id: 'db-nodl', name: 'nodl.bin' })]),
|
||||
)
|
||||
await loadFolder('', '/')
|
||||
|
||||
vi.mocked(api.getObject).mockResolvedValueOnce(makeStorageObject({ id: 'db-nodl' }))
|
||||
const openMock = vi.fn()
|
||||
vi.stubGlobal('window', { open: openMock })
|
||||
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
await apiMock.trigger('download-file', { id: 'id1' })
|
||||
await apiMock.trigger('download-file', { id: '/nodl.bin' })
|
||||
|
||||
expect(openMock).not.toHaveBeenCalled()
|
||||
|
||||
@@ -433,3 +544,50 @@ describe('connectAdapter', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPath (via loadFolder)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('builds root-level path as /name when parent is /', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject({ name: 'hello.txt' })]))
|
||||
|
||||
const [entity] = await loadFolder('', '/')
|
||||
|
||||
expect(entity.id).toBe('/hello.txt')
|
||||
})
|
||||
|
||||
it('builds nested path as parentPath/name when parent is not /', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject({ name: 'nested.txt' })]))
|
||||
|
||||
const [entity] = await loadFolder('db-photos', '/Photos/Vacation')
|
||||
|
||||
expect(entity.id).toBe('/Photos/Vacation/nested.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDbId (via connectAdapter request-data)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('returns empty string for the root path "/"', async () => {
|
||||
vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([]))
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
await apiMock.trigger('request-data', { id: '/' })
|
||||
|
||||
expect(api.listObjects).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('throws when no mapping is registered for the path', async () => {
|
||||
const apiMock = makeApiMock()
|
||||
connectAdapter(apiMock as never)
|
||||
|
||||
await expect(apiMock.trigger('request-data', { id: '/UnknownPath' })).rejects.toThrow(
|
||||
'No DB mapping for path: /UnknownPath',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,28 +3,79 @@ import { DirType } from '@zpan/shared/constants'
|
||||
import type { StorageObject } from '@zpan/shared/types'
|
||||
import { copyObject, createObject, deleteObject, getObject, listObjects, updateObject } from './api'
|
||||
|
||||
function toEntity(obj: StorageObject): IEntity {
|
||||
// SVAR uses path-based IDs (e.g. "/Music/song.mp3").
|
||||
// ZPan uses database IDs. This class manages the bidirectional mapping.
|
||||
class PathMapper {
|
||||
private pathToDb = new Map<string, string>()
|
||||
private dbToPath = new Map<string, string>()
|
||||
|
||||
register(path: string, dbId: string) {
|
||||
this.pathToDb.set(path, dbId)
|
||||
this.dbToPath.set(dbId, path)
|
||||
}
|
||||
|
||||
toDbId(path: string): string {
|
||||
if (path === '/') return ''
|
||||
const dbId = this.pathToDb.get(path)
|
||||
if (!dbId) throw new Error(`No DB mapping for path: ${path}`)
|
||||
return dbId
|
||||
}
|
||||
|
||||
toPath(dbId: string): string | undefined {
|
||||
return this.dbToPath.get(dbId)
|
||||
}
|
||||
|
||||
remove(path: string) {
|
||||
const dbId = this.pathToDb.get(path)
|
||||
this.pathToDb.delete(path)
|
||||
if (dbId) this.dbToPath.delete(dbId)
|
||||
}
|
||||
|
||||
rename(oldPath: string, newPath: string) {
|
||||
const dbId = this.pathToDb.get(oldPath)
|
||||
if (!dbId) return
|
||||
this.pathToDb.delete(oldPath)
|
||||
this.pathToDb.set(newPath, dbId)
|
||||
this.dbToPath.set(dbId, newPath)
|
||||
}
|
||||
}
|
||||
|
||||
const mapper = new PathMapper()
|
||||
|
||||
export function pathToDbId(path: string): string {
|
||||
return mapper.toDbId(path)
|
||||
}
|
||||
|
||||
function buildPath(parentPath: string, name: string): string {
|
||||
return parentPath === '/' ? `/${name}` : `${parentPath}/${name}`
|
||||
}
|
||||
|
||||
function parentOfPath(path: string): string {
|
||||
const idx = path.lastIndexOf('/')
|
||||
return idx <= 0 ? '/' : path.slice(0, idx)
|
||||
}
|
||||
|
||||
function toEntity(obj: StorageObject, parentPath: string): IEntity {
|
||||
const path = buildPath(parentPath, obj.name)
|
||||
mapper.register(path, obj.id)
|
||||
return {
|
||||
id: obj.id,
|
||||
id: path,
|
||||
name: obj.name,
|
||||
size: obj.size,
|
||||
date: new Date(obj.updatedAt),
|
||||
type: obj.dirtype === DirType.FILE ? 'file' : 'folder',
|
||||
lazy: obj.dirtype !== DirType.FILE,
|
||||
_alias: obj.alias,
|
||||
_status: obj.status,
|
||||
_parent: obj.parent,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFolder(parent: string): Promise<IEntity[]> {
|
||||
const res = await listObjects(parent)
|
||||
return res.items.map(toEntity)
|
||||
export async function loadFolder(dbParentId: string, parentPath: string): Promise<IEntity[]> {
|
||||
const res = await listObjects(dbParentId)
|
||||
return res.items.map((obj) => toEntity(obj, parentPath))
|
||||
}
|
||||
|
||||
export function refreshFolder(api: IApi, parent: string): Promise<void> {
|
||||
return loadFolder(parent).then((entities) => {
|
||||
api.exec('provide-data', { id: parent, data: entities, skipProvider: true })
|
||||
export function refreshFolder(api: IApi, dbParentId: string, parentPath: string): Promise<void> {
|
||||
return loadFolder(dbParentId, parentPath).then((entities) => {
|
||||
api.exec('provide-data', { id: parentPath, data: entities, skipProvider: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,43 +90,74 @@ async function settledAll<T>(ids: TID[], fn: (id: string) => Promise<T>): Promis
|
||||
|
||||
export function connectAdapter(api: IApi) {
|
||||
api.intercept('request-data', async (ev: { id: TID }) => {
|
||||
const data = await loadFolder(ev.id as string)
|
||||
api.exec('provide-data', { id: ev.id, data, skipProvider: true })
|
||||
const pathId = ev.id as string
|
||||
const dbId = mapper.toDbId(pathId)
|
||||
const data = await loadFolder(dbId, pathId)
|
||||
api.exec('provide-data', { id: pathId, data, skipProvider: true })
|
||||
return false
|
||||
})
|
||||
|
||||
api.intercept('rename-file', async (ev: { id: TID; name: string }) => {
|
||||
await updateObject(ev.id as string, { name: ev.name })
|
||||
const pathId = ev.id as string
|
||||
const dbId = mapper.toDbId(pathId)
|
||||
await updateObject(dbId, { name: ev.name })
|
||||
const newPath = buildPath(parentOfPath(pathId), ev.name)
|
||||
mapper.rename(pathId, newPath)
|
||||
})
|
||||
|
||||
api.intercept('create-file', async (ev: { file: { name: string; type?: string }; parent: TID }) => {
|
||||
if (ev.file.type === 'folder') {
|
||||
const parentPath = ev.parent as string
|
||||
const parentDbId = mapper.toDbId(parentPath)
|
||||
const created = await createObject({
|
||||
name: ev.file.name,
|
||||
type: 'folder',
|
||||
parent: ev.parent as string,
|
||||
parent: parentDbId,
|
||||
dirtype: DirType.USER_FOLDER,
|
||||
})
|
||||
return { newId: created.id }
|
||||
const newPath = buildPath(parentPath, ev.file.name)
|
||||
mapper.register(newPath, created.id)
|
||||
return { newId: newPath }
|
||||
}
|
||||
})
|
||||
|
||||
api.intercept('delete-files', async (ev: { ids: TID[] }) => {
|
||||
await settledAll(ev.ids, deleteObject)
|
||||
const paths = ev.ids as string[]
|
||||
const dbIds = paths.map((p) => mapper.toDbId(p))
|
||||
await settledAll(dbIds as TID[], deleteObject)
|
||||
for (const p of paths) mapper.remove(p)
|
||||
})
|
||||
|
||||
api.intercept('move-files', async (ev: { ids: TID[]; target: TID }) => {
|
||||
const moved = await settledAll(ev.ids, (id) => updateObject(id, { parent: ev.target as string }))
|
||||
return { newIds: moved.map((m) => m.id) }
|
||||
const targetPath = ev.target as string
|
||||
const targetDbId = mapper.toDbId(targetPath)
|
||||
const paths = ev.ids as string[]
|
||||
const dbIds = paths.map((p) => mapper.toDbId(p))
|
||||
const moved = await settledAll(dbIds as TID[], (id) => updateObject(id, { parent: targetDbId }))
|
||||
const newIds = moved.map((m, i) => {
|
||||
const newPath = buildPath(targetPath, m.name)
|
||||
mapper.rename(paths[i], newPath)
|
||||
return newPath
|
||||
})
|
||||
return { newIds }
|
||||
})
|
||||
|
||||
api.intercept('copy-files', async (ev: { ids: TID[]; target: TID }) => {
|
||||
const copies = await settledAll(ev.ids, (id) => copyObject(id, ev.target as string))
|
||||
return { newIds: copies.map((c) => c.id) }
|
||||
const targetPath = ev.target as string
|
||||
const targetDbId = mapper.toDbId(targetPath)
|
||||
const dbIds = (ev.ids as string[]).map((p) => mapper.toDbId(p))
|
||||
const copies = await settledAll(dbIds as TID[], (id) => copyObject(id, targetDbId))
|
||||
const newIds = copies.map((c) => {
|
||||
const newPath = buildPath(targetPath, c.name)
|
||||
mapper.register(newPath, c.id)
|
||||
return newPath
|
||||
})
|
||||
return { newIds }
|
||||
})
|
||||
|
||||
api.intercept('download-file', async (ev: { id: TID }) => {
|
||||
const obj = await getObject(ev.id as string)
|
||||
const dbId = mapper.toDbId(ev.id as string)
|
||||
const obj = await getObject(dbId)
|
||||
if (obj.downloadUrl) {
|
||||
window.open(obj.downloadUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
@@ -19,8 +19,9 @@ import { Route as AuthenticatedStoragesIndexRouteImport } from './routes/_authen
|
||||
import { Route as AuthenticatedSettingsIndexRouteImport } from './routes/_authenticated/settings/index'
|
||||
import { Route as AuthenticatedRecycleBinIndexRouteImport } from './routes/_authenticated/recycle-bin/index'
|
||||
import { Route as AuthenticatedFilesIndexRouteImport } from './routes/_authenticated/files/index'
|
||||
import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_authenticated/admin/settings/index'
|
||||
import { Route as AuthenticatedAdminUsersIndexRouteImport } from './routes/_authenticated/admin/users/index'
|
||||
import { Route as AuthenticatedAdminStoragesIndexRouteImport } from './routes/_authenticated/admin/storages/index'
|
||||
import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_authenticated/admin/settings/index'
|
||||
|
||||
const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({
|
||||
id: '/_authenticated',
|
||||
@@ -74,10 +75,10 @@ const AuthenticatedFilesIndexRoute = AuthenticatedFilesIndexRouteImport.update({
|
||||
path: '/files/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminSettingsIndexRoute =
|
||||
AuthenticatedAdminSettingsIndexRouteImport.update({
|
||||
id: '/settings/',
|
||||
path: '/settings/',
|
||||
const AuthenticatedAdminUsersIndexRoute =
|
||||
AuthenticatedAdminUsersIndexRouteImport.update({
|
||||
id: '/users/',
|
||||
path: '/users/',
|
||||
getParentRoute: () => AuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminStoragesIndexRoute =
|
||||
@@ -86,6 +87,12 @@ const AuthenticatedAdminStoragesIndexRoute =
|
||||
path: '/storages/',
|
||||
getParentRoute: () => AuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminSettingsIndexRoute =
|
||||
AuthenticatedAdminSettingsIndexRouteImport.update({
|
||||
id: '/settings/',
|
||||
path: '/settings/',
|
||||
getParentRoute: () => AuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
@@ -99,6 +106,7 @@ export interface FileRoutesByFullPath {
|
||||
'/users/': typeof AuthenticatedUsersIndexRoute
|
||||
'/admin/settings/': typeof AuthenticatedAdminSettingsIndexRoute
|
||||
'/admin/storages/': typeof AuthenticatedAdminStoragesIndexRoute
|
||||
'/admin/users/': typeof AuthenticatedAdminUsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/admin': typeof AuthenticatedAdminRouteRouteWithChildren
|
||||
@@ -112,6 +120,7 @@ export interface FileRoutesByTo {
|
||||
'/users': typeof AuthenticatedUsersIndexRoute
|
||||
'/admin/settings': typeof AuthenticatedAdminSettingsIndexRoute
|
||||
'/admin/storages': typeof AuthenticatedAdminStoragesIndexRoute
|
||||
'/admin/users': typeof AuthenticatedAdminUsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -127,6 +136,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
|
||||
'/_authenticated/admin/settings/': typeof AuthenticatedAdminSettingsIndexRoute
|
||||
'/_authenticated/admin/storages/': typeof AuthenticatedAdminStoragesIndexRoute
|
||||
'/_authenticated/admin/users/': typeof AuthenticatedAdminUsersIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -142,6 +152,7 @@ export interface FileRouteTypes {
|
||||
| '/users/'
|
||||
| '/admin/settings/'
|
||||
| '/admin/storages/'
|
||||
| '/admin/users/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/admin'
|
||||
@@ -155,6 +166,7 @@ export interface FileRouteTypes {
|
||||
| '/users'
|
||||
| '/admin/settings'
|
||||
| '/admin/storages'
|
||||
| '/admin/users'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_authenticated'
|
||||
@@ -169,6 +181,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/users/'
|
||||
| '/_authenticated/admin/settings/'
|
||||
| '/_authenticated/admin/storages/'
|
||||
| '/_authenticated/admin/users/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -249,11 +262,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedFilesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/admin/settings/': {
|
||||
id: '/_authenticated/admin/settings/'
|
||||
path: '/settings'
|
||||
fullPath: '/admin/settings/'
|
||||
preLoaderRoute: typeof AuthenticatedAdminSettingsIndexRouteImport
|
||||
'/_authenticated/admin/users/': {
|
||||
id: '/_authenticated/admin/users/'
|
||||
path: '/users'
|
||||
fullPath: '/admin/users/'
|
||||
preLoaderRoute: typeof AuthenticatedAdminUsersIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_authenticated/admin/storages/': {
|
||||
@@ -263,18 +276,27 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedAdminStoragesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_authenticated/admin/settings/': {
|
||||
id: '/_authenticated/admin/settings/'
|
||||
path: '/settings'
|
||||
fullPath: '/admin/settings/'
|
||||
preLoaderRoute: typeof AuthenticatedAdminSettingsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedAdminRouteRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthenticatedAdminRouteRouteChildren {
|
||||
AuthenticatedAdminSettingsIndexRoute: typeof AuthenticatedAdminSettingsIndexRoute
|
||||
AuthenticatedAdminStoragesIndexRoute: typeof AuthenticatedAdminStoragesIndexRoute
|
||||
AuthenticatedAdminUsersIndexRoute: typeof AuthenticatedAdminUsersIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedAdminRouteRouteChildren: AuthenticatedAdminRouteRouteChildren =
|
||||
{
|
||||
AuthenticatedAdminSettingsIndexRoute: AuthenticatedAdminSettingsIndexRoute,
|
||||
AuthenticatedAdminStoragesIndexRoute: AuthenticatedAdminStoragesIndexRoute,
|
||||
AuthenticatedAdminUsersIndexRoute: AuthenticatedAdminUsersIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedAdminRouteRouteWithChildren =
|
||||
|
||||
@@ -12,7 +12,7 @@ export const Route = createFileRoute('/_authenticated/admin/storages/')({
|
||||
component: StoragesPage,
|
||||
})
|
||||
|
||||
const STORAGE_STATUS_ACTIVE = 1
|
||||
const STORAGE_STATUS_ACTIVE = 'active'
|
||||
|
||||
function StoragesPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -23,7 +23,7 @@ function StoragesPage() {
|
||||
const storagesQuery = useQuery({
|
||||
queryKey: ['admin', 'storages'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/storages', { credentials: 'include' })
|
||||
const res = await fetch('/api/admin/storages', { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message ?? 'Failed to fetch storages')
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Search, Settings2, ShieldCheck, Trash2, UserX } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { DeleteUserDialog } from '@/components/admin/delete-user-dialog'
|
||||
import { UserQuotaDialog } from '@/components/admin/user-quota-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/admin/users/')({
|
||||
component: UsersPage,
|
||||
})
|
||||
|
||||
interface UserWithOrg {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string | null
|
||||
banned: boolean
|
||||
createdAt: number
|
||||
orgId: string | null
|
||||
orgName: string | null
|
||||
}
|
||||
|
||||
interface QuotaItem {
|
||||
orgId: string
|
||||
quota: number
|
||||
used: number
|
||||
}
|
||||
|
||||
interface UserRow extends UserWithOrg {
|
||||
quotaUsed: number
|
||||
quotaTotal: number
|
||||
}
|
||||
|
||||
function UsersPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 20
|
||||
|
||||
const [quotaDialogUser, setQuotaDialogUser] = useState<UserRow | null>(null)
|
||||
const [deleteDialogUser, setDeleteDialogUser] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin', 'users', page, pageSize],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/admin/users?page=${page}&pageSize=${pageSize}`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error('Failed to fetch users')
|
||||
return res.json() as Promise<{ items: UserWithOrg[]; total: number }>
|
||||
},
|
||||
})
|
||||
|
||||
const quotasQuery = useQuery({
|
||||
queryKey: ['admin', 'quotas'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/admin/quotas', { credentials: 'include' })
|
||||
if (!res.ok) throw new Error('Failed to fetch quotas')
|
||||
return res.json() as Promise<{ items: QuotaItem[]; total: number }>
|
||||
},
|
||||
})
|
||||
|
||||
const toggleStatusMutation = useMutation({
|
||||
mutationFn: async ({ userId, status }: { userId: string; status: string }) => {
|
||||
const res = await fetch(`/api/admin/users/${userId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update status')
|
||||
return res.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
toast.success(t('admin.users.statusUpdated'))
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const quotaMap = useMemo(() => {
|
||||
const map = new Map<string, QuotaItem>()
|
||||
for (const q of quotasQuery.data?.items ?? []) {
|
||||
map.set(q.orgId, q)
|
||||
}
|
||||
return map
|
||||
}, [quotasQuery.data])
|
||||
|
||||
const users: UserRow[] = useMemo(() => {
|
||||
const items = usersQuery.data?.items ?? []
|
||||
return items.map((u) => {
|
||||
const quota = u.orgId ? quotaMap.get(u.orgId) : undefined
|
||||
return { ...u, quotaUsed: quota?.used ?? 0, quotaTotal: quota?.quota ?? 0 }
|
||||
})
|
||||
}, [usersQuery.data, quotaMap])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return users
|
||||
const term = search.toLowerCase()
|
||||
return users.filter((u) => u.name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term))
|
||||
}, [users, search])
|
||||
|
||||
const total = usersQuery.data?.total ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const isLoading = usersQuery.isLoading || quotasQuery.isLoading
|
||||
|
||||
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{t('admin.users.title')}</h2>
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('admin.users.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.users.colName')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.users.colEmail')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.users.colRole')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.users.colStatus')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.users.colQuota')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.users.colCreatedAt')}</th>
|
||||
<th className="px-4 py-3 text-right font-medium">{t('admin.users.colActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((user) => (
|
||||
<UserTableRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
isToggling={toggleStatusMutation.isPending}
|
||||
onSetQuota={() => setQuotaDialogUser(user)}
|
||||
onToggleStatus={() =>
|
||||
toggleStatusMutation.mutate({
|
||||
userId: user.id,
|
||||
status: user.banned ? 'active' : 'disabled',
|
||||
})
|
||||
}
|
||||
onDelete={() => setDeleteDialogUser({ id: user.id, name: user.name })}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
|
||||
{t('admin.users.noUsers')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t('admin.users.prevPage')}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('admin.users.pageInfo', { page, total: totalPages })}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t('admin.users.nextPage')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UserQuotaDialog
|
||||
open={quotaDialogUser !== null}
|
||||
onOpenChange={(open) => !open && setQuotaDialogUser(null)}
|
||||
user={
|
||||
quotaDialogUser?.orgId
|
||||
? {
|
||||
name: quotaDialogUser.name,
|
||||
orgId: quotaDialogUser.orgId,
|
||||
quotaUsed: quotaDialogUser.quotaUsed,
|
||||
quotaTotal: quotaDialogUser.quotaTotal,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<DeleteUserDialog
|
||||
open={deleteDialogUser !== null}
|
||||
onOpenChange={(open) => !open && setDeleteDialogUser(null)}
|
||||
user={deleteDialogUser}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserTableRow({
|
||||
user,
|
||||
isToggling,
|
||||
onSetQuota,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: {
|
||||
user: UserRow
|
||||
isToggling: boolean
|
||||
onSetQuota: () => void
|
||||
onToggleStatus: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const roleBadge = user.role === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
const statusBadge = user.banned
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
: 'bg-green-500/10 text-green-700 dark:text-green-400'
|
||||
|
||||
const roleLabel = user.role === 'admin' ? t('admin.users.roleAdmin') : t('admin.users.roleMember')
|
||||
const quotaLabel = formatQuota(user.quotaUsed, user.quotaTotal)
|
||||
|
||||
return (
|
||||
<tr className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium">{user.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadge}`}>{roleLabel}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusBadge}`}>
|
||||
{user.banned ? t('admin.users.disabled') : t('admin.users.active')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{quotaLabel}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{formatDate(user.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={!user.orgId}
|
||||
onClick={onSetQuota}
|
||||
title={t('admin.users.setQuota')}
|
||||
>
|
||||
<Settings2 />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={isToggling}
|
||||
onClick={onToggleStatus}
|
||||
title={user.banned ? t('admin.users.enable') : t('admin.users.disable')}
|
||||
>
|
||||
{user.banned ? <ShieldCheck /> : <UserX />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={onDelete} title={t('common.delete')}>
|
||||
<Trash2 className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
const BYTES_PER_GB = 1024 * 1024 * 1024
|
||||
|
||||
function formatQuota(used: number, total: number): string {
|
||||
const usedGB = (used / BYTES_PER_GB).toFixed(1)
|
||||
if (total <= 0) return `${usedGB} GB / --`
|
||||
const totalGB = (total / BYTES_PER_GB).toFixed(1)
|
||||
return `${usedGB} / ${totalGB} GB`
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
const d = new Date(timestamp)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString()
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { UploadDropzone } from '../../../components/upload/upload-dropzone'
|
||||
import { connectAdapter, loadFolder, refreshFolder } from '../../../lib/file-manager-adapter'
|
||||
import { connectAdapter, loadFolder, pathToDbId, refreshFolder } from '../../../lib/file-manager-adapter'
|
||||
|
||||
interface FilesSearch {
|
||||
folder?: string
|
||||
@@ -28,11 +28,11 @@ function FilesPage() {
|
||||
const apiRef = useRef<IApi>(null)
|
||||
const [data, setData] = useState<IEntity[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentParent, setCurrentParent] = useState('')
|
||||
const [currentPath, setCurrentPath] = useState('/')
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
loadFolder('')
|
||||
loadFolder('', '/')
|
||||
.then(setData)
|
||||
.catch(() => toast.error(t('common.error')))
|
||||
.finally(() => setLoading(false))
|
||||
@@ -44,7 +44,7 @@ function FilesPage() {
|
||||
|
||||
const handleSetPath = useCallback(
|
||||
({ id }: { id: string }) => {
|
||||
setCurrentParent(id ?? '')
|
||||
setCurrentPath(id || '/')
|
||||
navigate({ to: '/files', search: { folder: id || undefined } })
|
||||
},
|
||||
[navigate],
|
||||
@@ -52,9 +52,10 @@ function FilesPage() {
|
||||
|
||||
const handleUploadComplete = useCallback(() => {
|
||||
if (apiRef.current) {
|
||||
refreshFolder(apiRef.current, currentParent).catch(() => toast.error(t('common.error')))
|
||||
const dbId = pathToDbId(currentPath)
|
||||
refreshFolder(apiRef.current, dbId, currentPath).catch(() => toast.error(t('common.error')))
|
||||
}
|
||||
}, [currentParent, t])
|
||||
}, [currentPath, t])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -66,7 +67,7 @@ function FilesPage() {
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<UploadDropzone parent={currentParent} onUploadComplete={handleUploadComplete}>
|
||||
<UploadDropzone parent={pathToDbId(currentPath)} onUploadComplete={handleUploadComplete}>
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-20 text-muted-foreground">
|
||||
<FolderOpen className="h-16 w-16" />
|
||||
<h2 className="text-xl font-medium">{t('files.title')}</h2>
|
||||
@@ -77,7 +78,7 @@ function FilesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<UploadDropzone parent={currentParent} onUploadComplete={handleUploadComplete}>
|
||||
<UploadDropzone parent={pathToDbId(currentPath)} onUploadComplete={handleUploadComplete}>
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<Willow>
|
||||
<Filemanager ref={apiRef} data={data} init={handleInit} onsetpath={handleSetPath} />
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface Storage {
|
||||
secretKey: string
|
||||
filePath: string
|
||||
customHost: string
|
||||
status: number
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -17,3 +17,7 @@ binding = "DB"
|
||||
database_name = "zpan-db"
|
||||
database_id = "local"
|
||||
migrations_dir = "./migrations"
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "R2_BUCKET"
|
||||
bucket_name = "zpan-test"
|
||||
|
||||
Reference in New Issue
Block a user