mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
feat: add admin layout with storage management UI (#248)
Separate admin backend from main frontend sidebar: - Remove admin nav items from main sidebar - Add user profile dropdown with "Admin Panel" entry (admin only) - Create admin layout at /admin with role guard and separate sidebar - Implement storage management page with CRUD table and form dialog - Add delete confirmation dialog following existing patterns - Add en/zh translations for all admin storage keys - Update E2E tests to verify sidebar separation Agent-Profile: https://agent-kanban.dev/agents/b724a773425e397c
This commit is contained in:
@@ -53,4 +53,27 @@ test.describe('Auth flow', () => {
|
||||
|
||||
await expect(page).toHaveURL(/files/, { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('sidebar shows only Files and Recycle Bin for regular users', async ({ page }) => {
|
||||
await page.goto('/sign-up')
|
||||
await page.getByLabel('Name').fill('Sidebar Test')
|
||||
await page.getByLabel('Email').fill(`sidebar-${Date.now()}@example.com`)
|
||||
await page.getByLabel('Password').fill('password123456')
|
||||
|
||||
const [signUpResp] = await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/api/auth/sign-up')),
|
||||
page.getByRole('button', { name: 'Sign up' }).click(),
|
||||
])
|
||||
expect(signUpResp.status()).toBe(200)
|
||||
await expect(page).toHaveURL(/files/, { timeout: 10000 })
|
||||
|
||||
// Main sidebar should show Files and Recycle Bin
|
||||
const sidebar = page.locator('[data-slot="sidebar"]')
|
||||
await expect(sidebar.getByText('Files')).toBeVisible()
|
||||
await expect(sidebar.getByText('Recycle Bin')).toBeVisible()
|
||||
|
||||
// Admin items should NOT be in the main sidebar
|
||||
await expect(sidebar.getByText('Storages')).not.toBeVisible()
|
||||
await expect(sidebar.getByText('Users')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ArrowLeft, Database, Users } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
const adminNavItems = [
|
||||
{ titleKey: 'admin.nav.storages', url: '/admin/storages', icon: Database },
|
||||
{ titleKey: 'admin.nav.users', url: '/admin/users', icon: Users },
|
||||
]
|
||||
|
||||
export function AdminSidebar() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader className="border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">{t('admin.title')}</span>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{t('admin.nav.management')}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{adminNavItems.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link to={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{t(item.titleKey)}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="border-t p-2">
|
||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||
<Link to="/files">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('admin.backToFiles')}
|
||||
</Link>
|
||||
</Button>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface DeleteStorageDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
storage: { id: string; title: string } | null
|
||||
}
|
||||
|
||||
export function DeleteStorageDialog({ open, onOpenChange, storage }: DeleteStorageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/storages/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.status === 409) {
|
||||
throw new Error(t('admin.storages.deleteHasFiles'))
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message ?? 'Failed to delete storage')
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
|
||||
onOpenChange(false)
|
||||
toast.success(t('admin.storages.deleted'))
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
if (!storage) return null
|
||||
|
||||
function handleDelete() {
|
||||
mutation.mutate(storage!.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.storages.deleteTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.storages.deleteConfirm', { title: storage.title })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? t('common.loading') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { Storage } from '@/types/storage'
|
||||
|
||||
const storageFormSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
mode: z.enum(['private', 'public']),
|
||||
bucket: z.string().min(1),
|
||||
endpoint: z.string().url(),
|
||||
region: z.string().min(1),
|
||||
accessKey: z.string().min(1),
|
||||
secretKey: z.string().min(1),
|
||||
filePath: z.string().min(1),
|
||||
customHost: z.string().optional(),
|
||||
})
|
||||
|
||||
type StorageFormValues = z.infer<typeof storageFormSchema>
|
||||
|
||||
const DEFAULT_VALUES: StorageFormValues = {
|
||||
title: '',
|
||||
mode: 'private',
|
||||
bucket: '',
|
||||
endpoint: '',
|
||||
region: 'auto',
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
filePath: '$UID/$RAW_NAME',
|
||||
customHost: '',
|
||||
}
|
||||
|
||||
interface StorageFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
storage: Storage | null
|
||||
}
|
||||
|
||||
export function StorageFormDialog({ open, onOpenChange, storage }: StorageFormDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [showSecret, setShowSecret] = useState(false)
|
||||
const isEditing = storage !== null
|
||||
|
||||
const form = useForm<StorageFormValues>({
|
||||
resolver: zodResolver(storageFormSchema),
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (storage) {
|
||||
form.reset({
|
||||
title: storage.title,
|
||||
mode: storage.mode,
|
||||
bucket: storage.bucket,
|
||||
endpoint: storage.endpoint,
|
||||
region: storage.region,
|
||||
accessKey: storage.accessKey,
|
||||
secretKey: storage.secretKey,
|
||||
filePath: storage.filePath,
|
||||
customHost: storage.customHost || '',
|
||||
})
|
||||
} else {
|
||||
form.reset(DEFAULT_VALUES)
|
||||
}
|
||||
setShowSecret(false)
|
||||
}, [open, storage, form])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: StorageFormValues) => {
|
||||
const url = isEditing ? `/api/storages/${storage.id}` : '/api/storages'
|
||||
const method = isEditing ? 'PUT' : 'POST'
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message ?? 'Failed to save storage')
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
|
||||
onOpenChange(false)
|
||||
toast.success(isEditing ? t('admin.storages.updated') : t('admin.storages.created'))
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(values: StorageFormValues) {
|
||||
mutation.mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? t('admin.storages.editTitle') : t('admin.storages.addTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<FormField label={t('admin.storages.fieldTitle')} error={form.formState.errors.title?.message}>
|
||||
<Input {...form.register('title')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldMode')} error={form.formState.errors.mode?.message}>
|
||||
<select
|
||||
{...form.register('mode')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="private">{t('admin.storages.modePrivate')}</option>
|
||||
<option value="public">{t('admin.storages.modePublic')}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldBucket')} error={form.formState.errors.bucket?.message}>
|
||||
<Input {...form.register('bucket')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldEndpoint')} error={form.formState.errors.endpoint?.message}>
|
||||
<Input {...form.register('endpoint')} placeholder="https://s3.amazonaws.com" />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldRegion')} error={form.formState.errors.region?.message}>
|
||||
<Input {...form.register('region')} placeholder="auto" />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldAccessKey')} error={form.formState.errors.accessKey?.message}>
|
||||
<Input {...form.register('accessKey')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldSecretKey')} error={form.formState.errors.secretKey?.message}>
|
||||
<div className="relative">
|
||||
<Input {...form.register('secretKey')} type={showSecret ? 'text' : 'password'} className="pr-10" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setShowSecret((v) => !v)}
|
||||
>
|
||||
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldFilePath')} error={form.formState.errors.filePath?.message}>
|
||||
<Input {...form.register('filePath')} placeholder="$UID/$RAW_NAME" />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldCustomHost')} error={form.formState.errors.customHost?.message}>
|
||||
<Input {...form.register('customHost')} placeholder={t('admin.storages.customHostPlaceholder')} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function FormField({ label, error, children }: { label: string; error?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Database, FolderOpen, HardDrive, LogOut, Settings, Trash2, Users } from 'lucide-react'
|
||||
import { FolderOpen, HardDrive, LogOut, Settings, ShieldCheck, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -14,23 +21,31 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { signOut } from '@/lib/auth-client'
|
||||
import { signOut, useSession } from '@/lib/auth-client'
|
||||
|
||||
const navItems = {
|
||||
main: [
|
||||
{ titleKey: 'nav.files', url: '/files', icon: FolderOpen },
|
||||
{ titleKey: 'nav.recycleBin', url: '/recycle-bin', icon: Trash2 },
|
||||
],
|
||||
admin: [
|
||||
{ titleKey: 'nav.storageBackends', url: '/storages', icon: Database },
|
||||
{ titleKey: 'nav.users', url: '/users', icon: Users },
|
||||
],
|
||||
secondary: [{ titleKey: 'nav.settings', url: '/settings', icon: Settings }],
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { data: session } = useSession()
|
||||
const user = session?.user as { name: string; role?: string } | undefined
|
||||
const isAdmin = user?.role === 'admin'
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
@@ -63,23 +78,6 @@ export function AppSidebar() {
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{t('nav.admin')}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{navItems.admin.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link to={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{t(item.titleKey)}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
@@ -98,10 +96,36 @@ export function AppSidebar() {
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="border-t p-2">
|
||||
<Button variant="ghost" className="w-full justify-start" onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t('auth.signOut')}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>{user?.name ? getInitials(user.name) : '?'}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate text-left font-medium">{user?.name ?? ''}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="w-56">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/storages">
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{t('nav.adminPanel')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t('auth.signOut')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import en from './locales/en.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
const enLocale = en as Record<string, string>
|
||||
const zhLocale = zh as Record<string, string>
|
||||
|
||||
const ADMIN_STORAGES_KEYS = [
|
||||
'admin.storages.title',
|
||||
'admin.storages.placeholder',
|
||||
'admin.storages.add',
|
||||
'admin.storages.addTitle',
|
||||
'admin.storages.editTitle',
|
||||
'admin.storages.deleteTitle',
|
||||
'admin.storages.deleteConfirm',
|
||||
'admin.storages.deleteHasFiles',
|
||||
'admin.storages.created',
|
||||
'admin.storages.updated',
|
||||
'admin.storages.deleted',
|
||||
'admin.storages.noStorages',
|
||||
'admin.storages.colTitle',
|
||||
'admin.storages.colMode',
|
||||
'admin.storages.colBucket',
|
||||
'admin.storages.colEndpoint',
|
||||
'admin.storages.colStatus',
|
||||
'admin.storages.colActions',
|
||||
'admin.storages.modePrivate',
|
||||
'admin.storages.modePublic',
|
||||
'admin.storages.statusActive',
|
||||
'admin.storages.statusInactive',
|
||||
'admin.storages.fieldTitle',
|
||||
'admin.storages.fieldMode',
|
||||
'admin.storages.fieldBucket',
|
||||
'admin.storages.fieldEndpoint',
|
||||
'admin.storages.fieldRegion',
|
||||
'admin.storages.fieldAccessKey',
|
||||
'admin.storages.fieldSecretKey',
|
||||
'admin.storages.fieldFilePath',
|
||||
'admin.storages.fieldCustomHost',
|
||||
'admin.storages.customHostPlaceholder',
|
||||
]
|
||||
|
||||
const ADMIN_NAV_KEYS = ['admin.nav.management', 'admin.nav.storages', 'admin.nav.users']
|
||||
|
||||
const SHARED_KEYS = ['nav.adminPanel', 'admin.title', 'admin.backToFiles', 'common.edit']
|
||||
|
||||
const ALL_KEYS = [...ADMIN_STORAGES_KEYS, ...ADMIN_NAV_KEYS, ...SHARED_KEYS]
|
||||
|
||||
// Keys that contain interpolation placeholders and the expected placeholder tokens
|
||||
const INTERPOLATED_KEYS: Record<string, string[]> = {
|
||||
'admin.storages.deleteConfirm': ['{{title}}'],
|
||||
}
|
||||
|
||||
describe('admin.storages locale keys — presence', () => {
|
||||
for (const key of ALL_KEYS) {
|
||||
it(`en.json contains key "${key}"`, () => {
|
||||
expect(Object.hasOwn(enLocale, key)).toBe(true)
|
||||
})
|
||||
|
||||
it(`zh.json contains key "${key}"`, () => {
|
||||
expect(Object.hasOwn(zhLocale, key)).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('admin.storages locale keys — non-empty values', () => {
|
||||
for (const key of ALL_KEYS) {
|
||||
it(`en.json value for "${key}" is not empty`, () => {
|
||||
expect(enLocale[key]).toBeTruthy()
|
||||
})
|
||||
|
||||
it(`zh.json value for "${key}" is not empty`, () => {
|
||||
expect(zhLocale[key]).toBeTruthy()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('admin.storages locale keys — interpolation placeholder parity', () => {
|
||||
for (const [key, placeholders] of Object.entries(INTERPOLATED_KEYS)) {
|
||||
for (const placeholder of placeholders) {
|
||||
it(`en.json key "${key}" contains placeholder ${placeholder}`, () => {
|
||||
expect(enLocale[key]).toContain(placeholder)
|
||||
})
|
||||
|
||||
it(`zh.json key "${key}" contains placeholder ${placeholder}`, () => {
|
||||
expect(zhLocale[key]).toContain(placeholder)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('admin.storages locale keys — English values contract', () => {
|
||||
it('admin.storages.title is "Storages"', () => {
|
||||
expect(enLocale['admin.storages.title']).toBe('Storages')
|
||||
})
|
||||
|
||||
it('admin.storages.add is "Add Storage"', () => {
|
||||
expect(enLocale['admin.storages.add']).toBe('Add Storage')
|
||||
})
|
||||
|
||||
it('admin.storages.addTitle is "Add Storage"', () => {
|
||||
expect(enLocale['admin.storages.addTitle']).toBe('Add Storage')
|
||||
})
|
||||
|
||||
it('admin.storages.editTitle is "Edit Storage"', () => {
|
||||
expect(enLocale['admin.storages.editTitle']).toBe('Edit Storage')
|
||||
})
|
||||
|
||||
it('admin.storages.deleteTitle is "Delete Storage"', () => {
|
||||
expect(enLocale['admin.storages.deleteTitle']).toBe('Delete Storage')
|
||||
})
|
||||
|
||||
it('admin.storages.created is "Storage created"', () => {
|
||||
expect(enLocale['admin.storages.created']).toBe('Storage created')
|
||||
})
|
||||
|
||||
it('admin.storages.updated is "Storage updated"', () => {
|
||||
expect(enLocale['admin.storages.updated']).toBe('Storage updated')
|
||||
})
|
||||
|
||||
it('admin.storages.deleted is "Storage deleted"', () => {
|
||||
expect(enLocale['admin.storages.deleted']).toBe('Storage deleted')
|
||||
})
|
||||
|
||||
it('admin.storages.noStorages is "No storages configured"', () => {
|
||||
expect(enLocale['admin.storages.noStorages']).toBe('No storages configured')
|
||||
})
|
||||
|
||||
it('admin.storages.modePrivate is "Private"', () => {
|
||||
expect(enLocale['admin.storages.modePrivate']).toBe('Private')
|
||||
})
|
||||
|
||||
it('admin.storages.modePublic is "Public"', () => {
|
||||
expect(enLocale['admin.storages.modePublic']).toBe('Public')
|
||||
})
|
||||
|
||||
it('admin.storages.statusActive is "Active"', () => {
|
||||
expect(enLocale['admin.storages.statusActive']).toBe('Active')
|
||||
})
|
||||
|
||||
it('admin.storages.statusInactive is "Inactive"', () => {
|
||||
expect(enLocale['admin.storages.statusInactive']).toBe('Inactive')
|
||||
})
|
||||
|
||||
it('admin.storages.deleteHasFiles is "Cannot delete storage that contains files."', () => {
|
||||
expect(enLocale['admin.storages.deleteHasFiles']).toBe('Cannot delete storage that contains files.')
|
||||
})
|
||||
|
||||
it('nav.adminPanel is "Admin Panel"', () => {
|
||||
expect(enLocale['nav.adminPanel']).toBe('Admin Panel')
|
||||
})
|
||||
|
||||
it('admin.title is "Admin"', () => {
|
||||
expect(enLocale['admin.title']).toBe('Admin')
|
||||
})
|
||||
|
||||
it('admin.backToFiles is "Back to Files"', () => {
|
||||
expect(enLocale['admin.backToFiles']).toBe('Back to Files')
|
||||
})
|
||||
|
||||
it('common.edit is "Edit"', () => {
|
||||
expect(enLocale['common.edit']).toBe('Edit')
|
||||
})
|
||||
|
||||
it('admin.nav.management is "Management"', () => {
|
||||
expect(enLocale['admin.nav.management']).toBe('Management')
|
||||
})
|
||||
|
||||
it('admin.nav.storages is "Storages"', () => {
|
||||
expect(enLocale['admin.nav.storages']).toBe('Storages')
|
||||
})
|
||||
|
||||
it('admin.nav.users is "Users"', () => {
|
||||
expect(enLocale['admin.nav.users']).toBe('Users')
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin.storages locale keys — i18n runtime translation', () => {
|
||||
it('translates admin.storages.title to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('admin.storages.title')).toBe('Storages')
|
||||
})
|
||||
|
||||
it('translates admin.storages.title to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.title')).toBe('存储')
|
||||
})
|
||||
|
||||
it('translates admin.storages.add to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('admin.storages.add')).toBe('Add Storage')
|
||||
})
|
||||
|
||||
it('translates admin.storages.add to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.add')).toBe('添加存储')
|
||||
})
|
||||
|
||||
it('translates admin.storages.modePrivate to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('admin.storages.modePrivate')).toBe('Private')
|
||||
})
|
||||
|
||||
it('translates admin.storages.modePrivate to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.modePrivate')).toBe('私有')
|
||||
})
|
||||
|
||||
it('translates admin.storages.modePublic to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.modePublic')).toBe('公开')
|
||||
})
|
||||
|
||||
it('translates admin.storages.statusActive to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.statusActive')).toBe('正常')
|
||||
})
|
||||
|
||||
it('translates admin.storages.statusInactive to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.statusInactive')).toBe('未启用')
|
||||
})
|
||||
|
||||
it('translates admin.storages.created to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.created')).toBe('存储已创建')
|
||||
})
|
||||
|
||||
it('translates admin.storages.updated to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.updated')).toBe('存储已更新')
|
||||
})
|
||||
|
||||
it('translates admin.storages.deleted to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.deleted')).toBe('存储已删除')
|
||||
})
|
||||
|
||||
it('translates admin.storages.deleteTitle to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.deleteTitle')).toBe('删除存储')
|
||||
})
|
||||
|
||||
it('interpolates admin.storages.deleteConfirm with title in English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
const result = i18n.t('admin.storages.deleteConfirm', { title: 'my-bucket' })
|
||||
expect(result).toContain('my-bucket')
|
||||
expect(result).toContain('cannot be undone')
|
||||
})
|
||||
|
||||
it('interpolates admin.storages.deleteConfirm with title in Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
const result = i18n.t('admin.storages.deleteConfirm', { title: 'my-bucket' })
|
||||
expect(result).toContain('my-bucket')
|
||||
})
|
||||
|
||||
it('translates nav.adminPanel to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('nav.adminPanel')).toBe('Admin Panel')
|
||||
})
|
||||
|
||||
it('translates nav.adminPanel to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('nav.adminPanel')).toBe('管理后台')
|
||||
})
|
||||
|
||||
it('translates admin.title to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('admin.title')).toBe('Admin')
|
||||
})
|
||||
|
||||
it('translates admin.title to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.title')).toBe('管理后台')
|
||||
})
|
||||
|
||||
it('translates admin.backToFiles to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('admin.backToFiles')).toBe('Back to Files')
|
||||
})
|
||||
|
||||
it('translates admin.backToFiles to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.backToFiles')).toBe('返回文件')
|
||||
})
|
||||
|
||||
it('translates common.edit to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('common.edit')).toBe('Edit')
|
||||
})
|
||||
|
||||
it('translates common.edit to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('common.edit')).toBe('编辑')
|
||||
})
|
||||
|
||||
it('translates admin.nav.storages to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.nav.storages')).toBe('存储')
|
||||
})
|
||||
|
||||
it('translates admin.nav.management to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.nav.management')).toBe('管理')
|
||||
})
|
||||
})
|
||||
@@ -14,16 +14,19 @@
|
||||
"auth.password": "Password",
|
||||
"auth.name": "Name",
|
||||
"nav.main": "Main",
|
||||
"nav.admin": "Admin",
|
||||
"nav.files": "Files",
|
||||
"nav.recycleBin": "Recycle Bin",
|
||||
"nav.storageBackends": "Storage Backends",
|
||||
"nav.users": "Users",
|
||||
"nav.settings": "Settings",
|
||||
"nav.adminPanel": "Admin Panel",
|
||||
"files.title": "Files",
|
||||
"files.placeholder": "File manager will be implemented here.",
|
||||
"recycleBin.title": "Recycle Bin",
|
||||
"recycleBin.placeholder": "Deleted files will appear here.",
|
||||
"admin.title": "Admin",
|
||||
"admin.backToFiles": "Back to Files",
|
||||
"admin.nav.management": "Management",
|
||||
"admin.nav.storages": "Storages",
|
||||
"admin.nav.users": "Users",
|
||||
"admin.users.title": "Users",
|
||||
"admin.users.placeholder": "User management will be implemented here.",
|
||||
"admin.users.searchPlaceholder": "Search by name or email",
|
||||
@@ -53,13 +56,44 @@
|
||||
"admin.users.pageInfo": "Page {{page}} of {{total}}",
|
||||
"admin.users.roleAdmin": "Admin",
|
||||
"admin.users.roleMember": "Member",
|
||||
"admin.storages.title": "Storage Backends",
|
||||
"admin.storages.title": "Storages",
|
||||
"admin.storages.placeholder": "Configure your S3-compatible storage backends here.",
|
||||
"admin.storages.add": "Add Storage",
|
||||
"admin.storages.addTitle": "Add Storage",
|
||||
"admin.storages.editTitle": "Edit Storage",
|
||||
"admin.storages.deleteTitle": "Delete Storage",
|
||||
"admin.storages.deleteConfirm": "Delete storage '{{title}}'? This cannot be undone.",
|
||||
"admin.storages.deleteHasFiles": "Cannot delete storage that contains files.",
|
||||
"admin.storages.created": "Storage created",
|
||||
"admin.storages.updated": "Storage updated",
|
||||
"admin.storages.deleted": "Storage deleted",
|
||||
"admin.storages.noStorages": "No storages configured",
|
||||
"admin.storages.colTitle": "Title",
|
||||
"admin.storages.colMode": "Mode",
|
||||
"admin.storages.colBucket": "Bucket",
|
||||
"admin.storages.colEndpoint": "Endpoint",
|
||||
"admin.storages.colStatus": "Status",
|
||||
"admin.storages.colActions": "Actions",
|
||||
"admin.storages.modePrivate": "Private",
|
||||
"admin.storages.modePublic": "Public",
|
||||
"admin.storages.statusActive": "Active",
|
||||
"admin.storages.statusInactive": "Inactive",
|
||||
"admin.storages.fieldTitle": "Title",
|
||||
"admin.storages.fieldMode": "Mode",
|
||||
"admin.storages.fieldBucket": "Bucket",
|
||||
"admin.storages.fieldEndpoint": "Endpoint",
|
||||
"admin.storages.fieldRegion": "Region",
|
||||
"admin.storages.fieldAccessKey": "Access Key",
|
||||
"admin.storages.fieldSecretKey": "Secret Key",
|
||||
"admin.storages.fieldFilePath": "File Path Template",
|
||||
"admin.storages.fieldCustomHost": "Custom Host",
|
||||
"admin.storages.customHostPlaceholder": "Optional",
|
||||
"settings.title": "Settings",
|
||||
"settings.placeholder": "Profile and appearance settings will be here.",
|
||||
"common.save": "Save",
|
||||
"common.cancel": "Cancel",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
"common.confirm": "Confirm",
|
||||
"common.loading": "Loading...",
|
||||
"common.error": "Error",
|
||||
|
||||
@@ -14,16 +14,19 @@
|
||||
"auth.password": "密码",
|
||||
"auth.name": "姓名",
|
||||
"nav.main": "主要",
|
||||
"nav.admin": "管理",
|
||||
"nav.files": "文件",
|
||||
"nav.recycleBin": "回收站",
|
||||
"nav.storageBackends": "存储后端",
|
||||
"nav.users": "用户",
|
||||
"nav.settings": "设置",
|
||||
"nav.adminPanel": "管理后台",
|
||||
"files.title": "文件",
|
||||
"files.placeholder": "文件管理器将在此实现。",
|
||||
"recycleBin.title": "回收站",
|
||||
"recycleBin.placeholder": "已删除的文件将显示在这里。",
|
||||
"admin.title": "管理后台",
|
||||
"admin.backToFiles": "返回文件",
|
||||
"admin.nav.management": "管理",
|
||||
"admin.nav.storages": "存储",
|
||||
"admin.nav.users": "用户",
|
||||
"admin.users.title": "用户",
|
||||
"admin.users.placeholder": "用户管理将在此实现。",
|
||||
"admin.users.searchPlaceholder": "按名称或邮箱搜索",
|
||||
@@ -53,13 +56,44 @@
|
||||
"admin.users.pageInfo": "第 {{page}} 页,共 {{total}} 页",
|
||||
"admin.users.roleAdmin": "管理员",
|
||||
"admin.users.roleMember": "成员",
|
||||
"admin.storages.title": "存储后端",
|
||||
"admin.storages.title": "存储",
|
||||
"admin.storages.placeholder": "在此配置您的 S3 兼容存储后端。",
|
||||
"admin.storages.add": "添加存储",
|
||||
"admin.storages.addTitle": "添加存储",
|
||||
"admin.storages.editTitle": "编辑存储",
|
||||
"admin.storages.deleteTitle": "删除存储",
|
||||
"admin.storages.deleteConfirm": "删除存储 '{{title}}'?此操作无法撤销。",
|
||||
"admin.storages.deleteHasFiles": "无法删除包含文件的存储。",
|
||||
"admin.storages.created": "存储已创建",
|
||||
"admin.storages.updated": "存储已更新",
|
||||
"admin.storages.deleted": "存储已删除",
|
||||
"admin.storages.noStorages": "暂无存储配置",
|
||||
"admin.storages.colTitle": "标题",
|
||||
"admin.storages.colMode": "模式",
|
||||
"admin.storages.colBucket": "存储桶",
|
||||
"admin.storages.colEndpoint": "端点",
|
||||
"admin.storages.colStatus": "状态",
|
||||
"admin.storages.colActions": "操作",
|
||||
"admin.storages.modePrivate": "私有",
|
||||
"admin.storages.modePublic": "公开",
|
||||
"admin.storages.statusActive": "正常",
|
||||
"admin.storages.statusInactive": "未启用",
|
||||
"admin.storages.fieldTitle": "标题",
|
||||
"admin.storages.fieldMode": "模式",
|
||||
"admin.storages.fieldBucket": "存储桶",
|
||||
"admin.storages.fieldEndpoint": "端点",
|
||||
"admin.storages.fieldRegion": "区域",
|
||||
"admin.storages.fieldAccessKey": "Access Key",
|
||||
"admin.storages.fieldSecretKey": "Secret Key",
|
||||
"admin.storages.fieldFilePath": "文件路径模板",
|
||||
"admin.storages.fieldCustomHost": "自定义域名",
|
||||
"admin.storages.customHostPlaceholder": "可选",
|
||||
"settings.title": "设置",
|
||||
"settings.placeholder": "个人资料和外观设置将在这里。",
|
||||
"common.save": "保存",
|
||||
"common.cancel": "取消",
|
||||
"common.delete": "删除",
|
||||
"common.edit": "编辑",
|
||||
"common.confirm": "确认",
|
||||
"common.loading": "加载中...",
|
||||
"common.error": "错误",
|
||||
|
||||
@@ -13,11 +13,13 @@ import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/
|
||||
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||
import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
|
||||
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
|
||||
import { Route as AuthenticatedAdminRouteRouteImport } from './routes/_authenticated/admin/route'
|
||||
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
|
||||
import { Route as AuthenticatedStoragesIndexRouteImport } from './routes/_authenticated/storages/index'
|
||||
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 AuthenticatedAdminStoragesIndexRouteImport } from './routes/_authenticated/admin/storages/index'
|
||||
|
||||
const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({
|
||||
id: '/_authenticated',
|
||||
@@ -38,6 +40,11 @@ const authSignInRoute = authSignInRouteImport.update({
|
||||
path: '/sign-in',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedAdminRouteRoute = AuthenticatedAdminRouteRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedUsersIndexRoute = AuthenticatedUsersIndexRouteImport.update({
|
||||
id: '/users/',
|
||||
path: '/users/',
|
||||
@@ -66,9 +73,16 @@ const AuthenticatedFilesIndexRoute = AuthenticatedFilesIndexRouteImport.update({
|
||||
path: '/files/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminStoragesIndexRoute =
|
||||
AuthenticatedAdminStoragesIndexRouteImport.update({
|
||||
id: '/storages/',
|
||||
path: '/storages/',
|
||||
getParentRoute: () => AuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/admin': typeof AuthenticatedAdminRouteRouteWithChildren
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/files/': typeof AuthenticatedFilesIndexRoute
|
||||
@@ -76,8 +90,10 @@ export interface FileRoutesByFullPath {
|
||||
'/settings/': typeof AuthenticatedSettingsIndexRoute
|
||||
'/storages/': typeof AuthenticatedStoragesIndexRoute
|
||||
'/users/': typeof AuthenticatedUsersIndexRoute
|
||||
'/admin/storages/': typeof AuthenticatedAdminStoragesIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/admin': typeof AuthenticatedAdminRouteRouteWithChildren
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
@@ -86,10 +102,12 @@ export interface FileRoutesByTo {
|
||||
'/settings': typeof AuthenticatedSettingsIndexRoute
|
||||
'/storages': typeof AuthenticatedStoragesIndexRoute
|
||||
'/users': typeof AuthenticatedUsersIndexRoute
|
||||
'/admin/storages': typeof AuthenticatedAdminStoragesIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_authenticated': typeof AuthenticatedRouteRouteWithChildren
|
||||
'/_authenticated/admin': typeof AuthenticatedAdminRouteRouteWithChildren
|
||||
'/(auth)/sign-in': typeof authSignInRoute
|
||||
'/(auth)/sign-up': typeof authSignUpRoute
|
||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||
@@ -98,11 +116,13 @@ export interface FileRoutesById {
|
||||
'/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute
|
||||
'/_authenticated/storages/': typeof AuthenticatedStoragesIndexRoute
|
||||
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
|
||||
'/_authenticated/admin/storages/': typeof AuthenticatedAdminStoragesIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/files/'
|
||||
@@ -110,8 +130,10 @@ export interface FileRouteTypes {
|
||||
| '/settings/'
|
||||
| '/storages/'
|
||||
| '/users/'
|
||||
| '/admin/storages/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/admin'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/'
|
||||
@@ -120,9 +142,11 @@ export interface FileRouteTypes {
|
||||
| '/settings'
|
||||
| '/storages'
|
||||
| '/users'
|
||||
| '/admin/storages'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_authenticated'
|
||||
| '/_authenticated/admin'
|
||||
| '/(auth)/sign-in'
|
||||
| '/(auth)/sign-up'
|
||||
| '/_authenticated/'
|
||||
@@ -131,6 +155,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/settings/'
|
||||
| '/_authenticated/storages/'
|
||||
| '/_authenticated/users/'
|
||||
| '/_authenticated/admin/storages/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -169,6 +194,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof authSignInRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated/admin': {
|
||||
id: '/_authenticated/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof AuthenticatedAdminRouteRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/users/': {
|
||||
id: '/_authenticated/users/'
|
||||
path: '/users'
|
||||
@@ -204,10 +236,32 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedFilesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/admin/storages/': {
|
||||
id: '/_authenticated/admin/storages/'
|
||||
path: '/storages'
|
||||
fullPath: '/admin/storages/'
|
||||
preLoaderRoute: typeof AuthenticatedAdminStoragesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedAdminRouteRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthenticatedAdminRouteRouteChildren {
|
||||
AuthenticatedAdminStoragesIndexRoute: typeof AuthenticatedAdminStoragesIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedAdminRouteRouteChildren: AuthenticatedAdminRouteRouteChildren =
|
||||
{
|
||||
AuthenticatedAdminStoragesIndexRoute: AuthenticatedAdminStoragesIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedAdminRouteRouteWithChildren =
|
||||
AuthenticatedAdminRouteRoute._addFileChildren(
|
||||
AuthenticatedAdminRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface AuthenticatedRouteRouteChildren {
|
||||
AuthenticatedAdminRouteRoute: typeof AuthenticatedAdminRouteRouteWithChildren
|
||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||
AuthenticatedFilesIndexRoute: typeof AuthenticatedFilesIndexRoute
|
||||
AuthenticatedRecycleBinIndexRoute: typeof AuthenticatedRecycleBinIndexRoute
|
||||
@@ -217,6 +271,7 @@ interface AuthenticatedRouteRouteChildren {
|
||||
}
|
||||
|
||||
const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||
AuthenticatedAdminRouteRoute: AuthenticatedAdminRouteRouteWithChildren,
|
||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||
AuthenticatedFilesIndexRoute: AuthenticatedFilesIndexRoute,
|
||||
AuthenticatedRecycleBinIndexRoute: AuthenticatedRecycleBinIndexRoute,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AdminSidebar } from '@/components/admin/admin-sidebar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/admin')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
const { user } = context as { user: { role: string } }
|
||||
if (user.role !== 'admin') throw redirect({ to: '/files' })
|
||||
},
|
||||
component: AdminLayout,
|
||||
})
|
||||
|
||||
function AdminLayout() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AdminSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<span className="text-sm font-medium">{t('admin.title')}</span>
|
||||
</header>
|
||||
<main className="flex-1 p-4">
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Database, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DeleteStorageDialog } from '@/components/admin/delete-storage-dialog'
|
||||
import { StorageFormDialog } from '@/components/admin/storage-form-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Storage } from '@/types/storage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/admin/storages/')({
|
||||
component: StoragesPage,
|
||||
})
|
||||
|
||||
const STORAGE_STATUS_ACTIVE = 1
|
||||
|
||||
function StoragesPage() {
|
||||
const { t } = useTranslation()
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editingStorage, setEditingStorage] = useState<Storage | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
|
||||
const storagesQuery = useQuery({
|
||||
queryKey: ['admin', 'storages'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/storages', { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message ?? 'Failed to fetch storages')
|
||||
}
|
||||
return res.json() as Promise<{ items: Storage[]; total: number }>
|
||||
},
|
||||
})
|
||||
|
||||
const storages = storagesQuery.data?.items ?? []
|
||||
|
||||
function handleEdit(storage: Storage) {
|
||||
setEditingStorage(storage)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
function handleAddNew() {
|
||||
setEditingStorage(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
function handleFormOpenChange(open: boolean) {
|
||||
setFormOpen(open)
|
||||
if (!open) setEditingStorage(null)
|
||||
}
|
||||
|
||||
if (storagesQuery.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.storages.title')}</h2>
|
||||
<Button size="sm" onClick={handleAddNew}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('admin.storages.add')}
|
||||
</Button>
|
||||
</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.storages.colTitle')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colMode')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colBucket')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colEndpoint')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colStatus')}</th>
|
||||
<th className="px-4 py-3 text-right font-medium">{t('admin.storages.colActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{storages.map((storage) => (
|
||||
<StorageTableRow
|
||||
key={storage.id}
|
||||
storage={storage}
|
||||
onEdit={() => handleEdit(storage)}
|
||||
onDelete={() => setDeleteTarget({ id: storage.id, title: storage.title })}
|
||||
/>
|
||||
))}
|
||||
{storages.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Database className="h-10 w-10" />
|
||||
<p>{t('admin.storages.noStorages')}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<StorageFormDialog open={formOpen} onOpenChange={handleFormOpenChange} storage={editingStorage} />
|
||||
|
||||
<DeleteStorageDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
storage={deleteTarget}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StorageTableRow({
|
||||
storage,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
storage: Storage
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isActive = storage.status === STORAGE_STATUS_ACTIVE
|
||||
|
||||
const modeBadge =
|
||||
storage.mode === 'public' ? 'bg-green-500/10 text-green-700 dark:text-green-400' : 'bg-primary/10 text-primary'
|
||||
|
||||
const statusBadge = isActive ? 'bg-green-500/10 text-green-700 dark:text-green-400' : 'bg-muted text-muted-foreground'
|
||||
|
||||
return (
|
||||
<tr className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium">{storage.title}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${modeBadge}`}>
|
||||
{storage.mode === 'public' ? t('admin.storages.modePublic') : t('admin.storages.modePrivate')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{storage.bucket}</td>
|
||||
<td className="max-w-48 truncate px-4 py-3 text-muted-foreground">{storage.endpoint}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusBadge}`}>
|
||||
{isActive ? t('admin.storages.statusActive') : t('admin.storages.statusInactive')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon-xs" onClick={onEdit} title={t('common.edit')}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={onDelete} title={t('common.delete')}>
|
||||
<Trash2 className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface Storage {
|
||||
id: string
|
||||
uid: string
|
||||
title: string
|
||||
mode: 'private' | 'public'
|
||||
bucket: string
|
||||
endpoint: string
|
||||
region: string
|
||||
accessKey: string
|
||||
secretKey: string
|
||||
filePath: string
|
||||
customHost: string
|
||||
status: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
Generated
+38
-20
@@ -100,6 +100,9 @@ importers:
|
||||
|
||||
packages/web:
|
||||
dependencies:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.72.0(react@19.2.4))
|
||||
'@radix-ui/react-avatar':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -1462,6 +1465,11 @@ packages:
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@hookform/resolvers@5.2.2':
|
||||
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
|
||||
peerDependencies:
|
||||
react-hook-form: ^7.55.0
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2790,6 +2798,9 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.21':
|
||||
resolution: {integrity: sha512-SA8SFg9dp0qKRH8goWsax6bptFE2EdmPf2YRAQW9WoHGf3XKM1bX0nd5UdwxmC5hXsBUZAYf7xSciCler6/oyA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5008,7 +5019,7 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)':
|
||||
'@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)':
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
@@ -5023,38 +5034,38 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 4.20260401.1
|
||||
|
||||
'@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.39.3(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.28.14))':
|
||||
'@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.39.3(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.28.14))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
optionalDependencies:
|
||||
drizzle-orm: 0.39.3(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.28.14)
|
||||
|
||||
'@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)':
|
||||
'@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
optionalDependencies:
|
||||
kysely: 0.28.14
|
||||
|
||||
'@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
'@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
|
||||
'@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
'@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
|
||||
'@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
'@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
|
||||
'@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))':
|
||||
'@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
|
||||
@@ -5569,6 +5580,11 @@ snapshots:
|
||||
dependencies:
|
||||
hono: 4.12.9
|
||||
|
||||
'@hookform/resolvers@5.2.2(react-hook-form@7.72.0(react@19.2.4))':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.72.0(react@19.2.4)
|
||||
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
@@ -6930,6 +6946,8 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.21':
|
||||
optional: true
|
||||
|
||||
@@ -7272,13 +7290,13 @@ snapshots:
|
||||
|
||||
better-auth@1.5.6(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-sqlite3@11.10.0)(drizzle-kit@0.30.6)(drizzle-orm@0.39.3(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.28.14))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.39.3(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.28.14))
|
||||
'@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)
|
||||
'@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))
|
||||
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)
|
||||
'@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.39.3(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.28.14))
|
||||
'@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)
|
||||
'@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)
|
||||
'@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260401.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@3.25.76))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))
|
||||
'@better-auth/utils': 0.3.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@noble/ciphers': 2.1.1
|
||||
|
||||
Reference in New Issue
Block a user