update ui layout

This commit is contained in:
rustmailer
2026-05-10 03:19:25 +08:00
parent 213c6452a8
commit 2f8ba5ad40
41 changed files with 337 additions and 628 deletions
-2
View File
@@ -49,7 +49,6 @@
"@tanstack/react-router": "^1.86.1",
"@tanstack/react-table": "^8.20.5",
"@tanstack/react-virtual": "^3.11.2",
"ace-builds": "^1.37.1",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -62,7 +61,6 @@
"lucide-react": "^0.468.0",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-ace": "^13.0.0",
"react-day-picker": "9.13.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.0",
-79
View File
@@ -1,79 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from 'react';
import AceEditor from 'react-ace';
import { cn } from '@/lib/utils';
import 'ace-builds/src-noconflict/mode-handlebars';
import 'ace-builds/src-noconflict/mode-json';
import 'ace-builds/src-noconflict/mode-python';
import 'ace-builds/src-noconflict/mode-markdown';
import 'ace-builds/src-noconflict/theme-kuroir';
import 'ace-builds/src-noconflict/theme-monokai';
import 'ace-builds/src-noconflict/ext-language_tools';
interface ReactAceEditorProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
readOnly?: boolean;
theme?: 'kuroir' | 'monokai';
mode?: 'handlebars' | 'json' | 'markdown' | 'python';
}
const ReactAceEditor: React.FC<ReactAceEditorProps> = ({
value,
onChange,
placeholder,
className,
readOnly = false,
theme = 'github',
mode = 'handlebars'
}) => {
return (
<div className={cn('w-full h-[100px]', className)}>
<AceEditor
mode={mode}
theme={theme}
readOnly={readOnly}
value={value || ''}
onChange={onChange}
placeholder={placeholder}
fontSize={14}
showPrintMargin={false}
showGutter={true}
highlightActiveLine={true}
width="100%"
height="100%"
setOptions={{
useWorker: false,
enableBasicAutocompletion: true,
enableMobileMenu: true,
enableLiveAutocompletion: false,
enableSnippets: false,
showLineNumbers: true,
tabSize: 2,
}}
/>
</div>
);
};
export default ReactAceEditor;
-113
View File
@@ -1,113 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from 'react'
import { useNavigate } from '@tanstack/react-router'
import {
IconArrowRightDashed,
IconMoon,
IconSun,
} from '@tabler/icons-react'
import { useSearch } from '@/context/search-context'
import { useTheme } from '@/context/theme-context'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { useSidebarData } from './layout/data/sidebar-data'
import { ScrollArea } from './ui/scroll-area'
import { useTranslation } from 'react-i18next'
export function CommandMenu() {
const navigate = useNavigate()
const { setTheme } = useTheme()
const { open, setOpen } = useSearch()
const sidebarData = useSidebarData()
const { t } = useTranslation()
const runCommand = React.useCallback(
(command: () => unknown) => {
setOpen(false)
command()
},
[setOpen]
)
return (
<CommandDialog modal open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t('command.typeCommandOrSearch')} />
<CommandList>
<ScrollArea type='hover' className='h-72 pr-1'>
<CommandEmpty>{t('command.noResultsFound')}</CommandEmpty>
{sidebarData.navGroups.map((group) => (
<CommandGroup key={group.title} heading={group.title}>
{group.items.map((navItem, i) => {
if (navItem.url)
return (
<CommandItem
key={`${navItem.url}-${i}`}
value={navItem.title}
onSelect={() => {
runCommand(() => navigate({ to: navItem.url }))
}}
>
<div className='mr-2 flex h-4 w-4 items-center justify-center'>
<IconArrowRightDashed className='size-2 text-muted-foreground/80' />
</div>
{navItem.title}
</CommandItem>
)
return navItem.items?.map((subItem, i) => (
<CommandItem
key={`${subItem.url}-${i}`}
value={subItem.title}
onSelect={() => {
runCommand(() => navigate({ to: subItem.url }))
}}
>
<div className='mr-2 flex h-4 w-4 items-center justify-center'>
<IconArrowRightDashed className='size-2 text-muted-foreground/80' />
</div>
{subItem.title}
</CommandItem>
))
})}
</CommandGroup>
))}
<CommandSeparator />
<CommandGroup heading={t('command.theme')}>
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
<IconSun /> <span>{t('command.light')}</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
<IconMoon className='scale-90' />
<span>{t('command.dark')}</span>
</CommandItem>
</CommandGroup>
</ScrollArea>
</CommandList>
</CommandDialog>
)
}
-110
View File
@@ -1,110 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useEffect, useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
import AceEditor from './ace-editor';
interface CodeEditorWithDraftProps {
value: string | undefined;
onChange: (value: string) => void;
localStorageKey: string;
mode?: 'handlebars' | 'json' | 'markdown' | 'python';
theme?: 'kuroir' | 'monokai';
placeholder?: string;
className?: string;
}
export function CodeEditorWithDraft({
value,
onChange,
localStorageKey,
mode,
theme,
placeholder,
className,
}: CodeEditorWithDraftProps) {
const [hasDraft, setHasDraft] = useState(false);
useEffect(() => {
const saved = localStorage.getItem(localStorageKey);
if (saved && !value) {
setHasDraft(true);
}
}, []);
useEffect(() => {
const save = () => {
if (value) {
localStorage.setItem(localStorageKey, value);
}
};
const handle = setInterval(save, 2000);
return () => {
clearInterval(handle);
}
}, [value]);
const handleRestore = () => {
const saved = localStorage.getItem(localStorageKey);
if (saved) {
onChange(saved);
setHasDraft(false);
}
};
const handleDiscard = () => {
localStorage.removeItem(localStorageKey);
setHasDraft(false);
};
return (
<div>
{hasDraft && (
<Alert variant="destructive" className="mt-2 flex items-start gap-2">
<AlertTriangle className="w-5 h-5 text-red-600 mt-1" />
<div>
<AlertTitle>Unsaved draft detected</AlertTitle>
<AlertDescription>
A draft was found in your browser. Would you like to restore it?
<div className="mt-2 flex gap-2">
<Button size="sm" onClick={handleRestore}>
Restore
</Button>
<Button size="sm" variant="outline" onClick={handleDiscard}>
Discard
</Button>
</div>
</AlertDescription>
</div>
</Alert>
)}
<AceEditor
placeholder={placeholder}
value={value}
onChange={onChange}
className={className}
mode={mode}
theme={theme}
/>
</div>
);
}
+2 -2
View File
@@ -24,7 +24,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Languages } from 'lucide-react'
import { Globe } from 'lucide-react'
const LANGUAGES = [
{ code: 'ar', label: 'العربية' },
@@ -59,7 +59,7 @@ export function LanguageSwitch() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Languages className="h-4 w-4" />
<Globe/>
<span className="sr-only">Change language</span>
</Button>
</DropdownMenuTrigger>
@@ -27,7 +27,6 @@ import { LanguageSwitch } from "../language-switch";
export const FixedHeader = () => {
return (
<Header fixed>
{/* <Search /> */}
<div className='ml-auto flex items-center space-x-4'>
<NotificationPopover />
<GithubLinkButton />
+23 -8
View File
@@ -17,32 +17,47 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import React, { useEffect, useState } from "react";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import { Star } from "lucide-react";
interface GithubLinkButtonProps {
/** GitHub repository or profile URL */
href?: string;
/** Icon size (default: 20) */
repo?: string;
size?: number;
/** Optional tooltip title */
title?: string;
}
export const GithubLinkButton: React.FC<GithubLinkButtonProps> = ({
href = "https://github.com/rustmailer/bichon",
size = 20,
repo = "rustmailer/bichon",
size = 18,
title = "View on GitHub",
}) => {
const [stars, setStars] = useState<number | null>(null);
useEffect(() => {
fetch(`https://api.github.com/repos/${repo}`)
.then(res => res.json())
.then(data => setStars(data.stargazers_count))
.catch(() => { });
}, [repo]);
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
title={title}
className="inline-flex items-center justify-center rounded-full p-2 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors text-xs font-medium"
>
<GitHubLogoIcon className="w-5 h-5" style={{ width: size, height: size }} />
<GitHubLogoIcon style={{ width: size, height: size }} />
{stars !== null && (
<>
<Star className="h-3 w-3 fill-current" />
<span>{stars >= 1000 ? `${(stars / 1000).toFixed(1)}k` : stars}</span>
</>
)}
</a>
);
};
};
-2
View File
@@ -19,7 +19,6 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
interface HeaderProps extends React.HTMLAttributes<HTMLElement> {
@@ -58,7 +57,6 @@ export const Header = ({
{...props}
>
<SidebarTrigger variant='outline' className='scale-125 sm:scale-100' />
<Separator orientation='vertical' className='h-6' />
{children}
</header>
)
-126
View File
@@ -1,126 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
LogOut,
Sparkles,
} from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
export function NavUser({
user,
}: {
user: {
name: string
email: string
avatar: string
}
}) {
const { isMobile } = useSidebar()
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
<Avatar className='h-8 w-8 rounded-lg'>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className='rounded-lg'>SN</AvatarFallback>
</Avatar>
<div className='grid flex-1 text-left text-sm leading-tight'>
<span className='truncate font-semibold'>{user.name}</span>
<span className='truncate text-xs'>{user.email}</span>
</div>
<ChevronsUpDown className='ml-auto size-4' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg'
side={isMobile ? 'bottom' : 'right'}
align='end'
sideOffset={4}
>
<DropdownMenuLabel className='p-0 font-normal'>
<div className='flex items-center gap-2 px-1 py-1.5 text-left text-sm'>
<Avatar className='h-8 w-8 rounded-lg'>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className='rounded-lg'>SN</AvatarFallback>
</Avatar>
<div className='grid flex-1 text-left text-sm leading-tight'>
<span className='truncate font-semibold'>{user.name}</span>
<span className='truncate text-xs'>{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+9 -5
View File
@@ -31,6 +31,7 @@ import { get_notifications } from "@/api/system/api";
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
interface Release {
tag_name: string;
@@ -58,6 +59,9 @@ export function NotificationPopover() {
staleTime: 1000 * 60 * 30, // 30 minutes
});
const {t} = useTranslation();
const activeNotifications = useMemo((): ActiveNotification[] => {
if (!data) return [];
@@ -107,8 +111,8 @@ export function NotificationPopover() {
</PopoverTrigger>
<PopoverContent className="w-[32rem] p-0" align="end">
<div className="p-4 border-b">
<h4 className="font-medium">
System Notifications
<h4 className="font-medium text-sm">
{t('system.notifications')}
{showNotificationBadge && ` (${activeNotifications.length})`}
</h4>
</div>
@@ -144,7 +148,7 @@ function ReleaseNotificationView({ data }: { data: Release }) {
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
<h3 className="text-sm font-semibold">
{data.tag_name}
</h3>
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
@@ -156,7 +160,7 @@ function ReleaseNotificationView({ data }: { data: Release }) {
</p>
</div>
<div className="prose prose-xs dark:prose-invert max-w-none text-sm">
<div className="prose prose-xs dark:prose-invert max-w-none text-xs">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{data.body}
</ReactMarkdown>
@@ -168,7 +172,7 @@ function ReleaseNotificationView({ data }: { data: Release }) {
href={data.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center"
className="text-xs text-primary hover:underline inline-flex items-center"
>
View full release notes <ExternalLinkIcon className="ml-1 h-3 w-3" />
</a>
+2 -3
View File
@@ -26,8 +26,7 @@ import { Button } from '@/components/ui/button'
export function ThemeSwitch() {
const { theme, setTheme } = useTheme()
/* Update theme-color meta tag
* when theme is updated */
useEffect(() => {
const themeColor = theme === 'dark' ? '#020817' : '#fff'
const metaThemeColor = document.querySelector("meta[name='theme-color']")
@@ -39,7 +38,7 @@ export function ThemeSwitch() {
}, [theme])
return (
<Button variant='ghost' size='icon' className='scale-95 rounded-full' onClick={toggleTheme}>
<Button variant='ghost' size='icon' className='scale-95' onClick={toggleTheme}>
<IconSun className='size-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
<IconMoon className='absolute size-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' />
<span className='sr-only'>Toggle theme</span>
-2
View File
@@ -18,7 +18,6 @@
import React from 'react'
import { CommandMenu } from '@/components/command-menu'
interface SearchContextType {
open: boolean
@@ -48,7 +47,6 @@ export function SearchProvider({ children }: Props) {
return (
<SearchContext.Provider value={{ open, setOpen }}>
{children}
<CommandMenu />
</SearchContext.Provider>
)
}
@@ -59,7 +59,11 @@ export function RunningStateCellAction({ row }: Props) {
})
}
}}>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">{t('accounts.viewDetails')}</span>
<span
className="text-xs text-primary cursor-pointer underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('accounts.viewDetails')}
</span>
</Button>
)
}
+2 -20
View File
@@ -13,7 +13,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, BarChart, Bar } from 'recharts';
import { Mail, Users, Inbox, Zap } from 'lucide-react';
import { Mail, Users, Inbox, Zap, Paperclip } from 'lucide-react';
import { formatBytes, formatNumber } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { get_dashboard_stats, INITIAL_DASHBOARD_STATS, TimeBucket } from '@/api/system/api';
@@ -30,24 +30,6 @@ interface DailyActivity {
timestamp_ms: number;
}
const GithubIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
);
function convertRecentActivity(timeBuckets: TimeBucket[], locale: string): DailyActivity[] {
const dateFormatter = new Intl.DateTimeFormat(locale, {
month: 'short',
@@ -223,7 +205,7 @@ export default function MailArchiveDashboard() {
<Card className="md:col-span-2 lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-wider">{t('dashboard.totalAttachments')}</CardTitle>
<Mail className="h-4 w-4 text-muted-foreground" />
<Paperclip className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-xl font-bold">{formatNumber(stats1.attachment_count)}</div>
+120
View File
@@ -0,0 +1,120 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTranslation } from 'react-i18next'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { useCurrentUser } from '@/hooks/use-current-user'
import { PermissionsDialog } from './permissions-dialog'
import Logo from '@/assets/logo.svg'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export function AccountAccessList() {
const { t } = useTranslation()
const { data: user } = useCurrentUser()
const { getEmailById } = useMinimalAccountList()
const [permissionsOpen, setPermissionsOpen] = useState(false)
const [permissionsAccountId, setPermissionsAccountId] = useState<number | undefined>(undefined)
if (!user) return null
const accessibleAccountIds = user.account_access_map instanceof Map
? Array.from(user.account_access_map.keys())
: Object.keys(user.account_access_map || {}).map(Number)
const roleSummary = user.account_roles_summary || {}
if (accessibleAccountIds.length === 0) {
return (
<div className="flex h-[450px] items-center justify-center rounded-md border border-dashed mt-4">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center px-4">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('settings.access.empty.title')}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{t('settings.access.empty.description')}
</p>
</div>
</div>
)
}
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{accessibleAccountIds.map((accountId) => {
const email = getEmailById(accountId)
const roleName = roleSummary[accountId]
if (!email) return null
return (
<Card key={accountId} className="group hover:bg-accent/40 transition-all h-fit">
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10 text-primary text-sm font-bold shrink-0">
{email.charAt(0).toUpperCase()}
</div>
<div className="flex flex-col min-w-0">
<CardTitle className="text-sm font-semibold truncate">{email}</CardTitle>
<CardDescription className="text-[10px] font-mono">
{t('settings.profile.account.id', { id: accountId })}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex items-center justify-between pt-3 border-t">
{roleName ? (
<Badge variant="secondary" className="text-[11px]">
{roleName}
</Badge>
) : (
<span />
)}
<Button
type="button"
variant="outline"
size="sm"
className="text-xs h-7"
onClick={() => {
setPermissionsAccountId(accountId)
setPermissionsOpen(true)
}}
>
{t('settings.profile.button.permissions')}
</Button>
</CardContent>
</Card>
)
})}
</div>
<PermissionsDialog
currentRow={user}
open={permissionsOpen}
onOpenChange={setPermissionsOpen}
mode="account"
accountId={permissionsAccountId}
/>
</>
)
}
@@ -25,11 +25,12 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { getPermissions, User } from '@/api/users/api'
import { CheckCircle2, XCircle } from 'lucide-react'
import { CheckCircle, XCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTranslation } from 'react-i18next'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
interface Props {
currentRow?: User
@@ -95,6 +96,7 @@ export function PermissionsDialog({
}: Props) {
const { t } = useTranslation()
const { getEmailById } = useMinimalAccountList();
const ownedPermissions = React.useMemo<string[]>(() => {
if (!currentRow) return []
@@ -120,7 +122,7 @@ export function PermissionsDialog({
mode === 'global'
? getGlobalCategories(t)
: getAccountCategories(t)
const title =
mode === 'global'
? t('permission.dialog.global_title')
@@ -140,56 +142,51 @@ export function PermissionsDialog({
<Badge variant="outline" className="text-[10px]">
{mode === 'global'
? t('permission.scope.global')
: t('permission.scope.account', { id: accountId })}
: t('permission.scope.account', { id: getEmailById(accountId!) })}
</Badge>
</div>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
<div className="grid gap-6 px-1">
<div className="flex flex-col gap-6 px-1">
{categories.map((cat) => (
<div key={cat.title} className="flex flex-col">
<h3 className="text-[11px] font-bold text-slate-500 border-l-4 border-blue-500 pl-2 mb-4 uppercase tracking-widest">
<h3 className="text-[11px] font-bold text-muted-foreground border-l-2 border-primary pl-2 mb-3 uppercase tracking-widest">
{cat.title}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{cat.keys.map((key) => {
const item = permissions.get(key)
if (!item) return null
const hasPermission =
ownedPermissions.includes(item.value)
const hasPermission = ownedPermissions.includes(item.value)
return (
<div
key={item.value}
className={cn(
'flex items-center gap-2.5 p-2 rounded-md transition-all text-xs border',
'flex items-center gap-2.5 p-2.5 rounded-md border text-xs transition-all',
hasPermission
? 'bg-green-50/40 border-green-100 text-green-800 shadow-sm'
: 'bg-slate-50/30 border-transparent text-slate-400 opacity-60',
? 'bg-primary/5 border-primary/20'
: 'bg-muted/30 border-border opacity-50',
)}
>
{hasPermission ? (
<CheckCircle2 className="w-3.5 h-3.5 text-green-600 shrink-0" />
<CheckCircle className="w-4 h-4 text-primary shrink-0" />
) : (
<XCircle className="w-3.5 h-3.5 text-slate-300 shrink-0" />
<XCircle className="w-4 h-4 text-muted-foreground/40 shrink-0" />
)}
<div className="flex flex-col min-w-0 flex-1">
<span
className={cn(
'font-semibold text-sm leading-none truncate',
hasPermission
? 'text-slate-900'
: 'text-slate-500',
)}
>
<span className={cn(
'font-medium text-xs leading-none truncate',
hasPermission ? 'text-foreground' : 'text-muted-foreground',
)}>
{item.label}
</span>
<span className="text-xs opacity-70 font-mono mt-1 truncate">
<span className="text-[10px] text-muted-foreground font-mono mt-1 truncate">
{item.value}
</span>
</div>
+6 -1
View File
@@ -20,7 +20,7 @@
import { Outlet } from '@tanstack/react-router'
import { Main } from '@/components/layout/main'
import SidebarNav from './components/sidebar-nav'
import { KeyRound, Palette, SettingsIcon, UserCog, Waypoints } from 'lucide-react'
import { KeyRound, Palette, SettingsIcon, ShieldCheck, UserCog, Waypoints } from 'lucide-react'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useTranslation } from 'react-i18next'
@@ -36,6 +36,11 @@ export default function Settings() {
href: '/settings/profile',
icon: <UserCog size={18} />,
},
{
title: t('settings.sidebar.access'),
href: '/settings/access',
icon: <ShieldCheck size={18} />
},
{
title: t('settings.appearance.title'),
href: '/settings/appearance',
@@ -35,17 +35,13 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { toast } from '@/hooks/use-toast'
import { PasswordInput } from '@/components/password-input'
import { update_user, User } from '@/api/users/api'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { FileWithPreview } from '@/hooks/use-file-upload'
import AvatarUpload from './avatar-upload'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { PermissionsDialog } from './permissions-dialog'
import { PermissionsDialog } from '../access/permissions-dialog'
const profileSchema = (t: (key: string) => string) => z.object({
username: z
@@ -104,10 +100,7 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
const [avatarFile, setAvatarFile] = useState<FileWithPreview | null>(null)
const [permissionsOpen, setPermissionsOpen] = useState(false)
const [permissionsMode, setPermissionsMode] =
useState<'global' | 'account'>('global')
const [permissionsAccountId, setPermissionsAccountId] =
useState<number | undefined>(undefined)
const [permissionsAccountId, setPermissionsAccountId] = useState<number | undefined>(undefined)
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema(t)),
@@ -149,15 +142,7 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
},
})
const { getEmailById } = useMinimalAccountList()
const accessibleAccountIds = user.account_access_map instanceof Map
? Array.from(user.account_access_map.keys())
: Object.keys(user.account_access_map || {}).map(Number)
const hasAccess = accessibleAccountIds.length > 0
const roleNames = user.global_roles_names
const roleSummary = user.account_roles_summary || {}
return (
<>
@@ -188,7 +173,6 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
size="sm"
className="text-xs"
onClick={() => {
setPermissionsMode('global')
setPermissionsAccountId(undefined)
setPermissionsOpen(true)
}}
@@ -260,77 +244,6 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
)}
/>
</div>
{hasAccess && (
<Separator orientation="vertical" className="hidden lg:block" />
)}
{hasAccess && (
<div className="space-y-4">
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t('settings.profile.section.accounts', {
count: accessibleAccountIds.length,
})}
</h2>
<ScrollArea className="h-[calc(100vh-16rem)] pr-4">
<div className="grid grid-cols-1 gap-3">
{accessibleAccountIds.map((accountId) => {
const email = getEmailById(accountId)
const roleName = roleSummary[accountId]
if (!email) return null
return (
<div
key={accountId}
className="group flex items-center justify-between p-3 rounded-xl border bg-card hover:bg-accent/40 transition-all shadow-sm"
>
<div className="flex items-center min-w-0">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10 text-primary text-sm font-bold mr-4 shrink-0">
{email.charAt(0).toUpperCase()}
</div>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate">
{email}
</span>
<span className="text-[10px] text-muted-foreground font-mono">
{t('settings.profile.account.id', {
id: accountId,
})}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{roleName && (
<Badge
variant="outline"
className="text-[11px]"
>
{roleName}
</Badge>
)}
<Button
type="button"
variant="ghost"
className="text-[10px]"
onClick={() => {
setPermissionsMode('account')
setPermissionsAccountId(accountId)
setPermissionsOpen(true)
}}
>
{t('settings.profile.button.permissions')}
</Button>
</div>
</div>
)
})}
</div>
</ScrollArea>
</div>
)}
</div>
<div className="flex justify-start pt-4">
@@ -346,12 +259,11 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
</div>
</form>
</Form>
<PermissionsDialog
currentRow={user}
open={permissionsOpen}
onOpenChange={setPermissionsOpen}
mode={permissionsMode}
mode="global"
accountId={permissionsAccountId}
/>
</>
@@ -41,7 +41,11 @@ export function PermissionsCellAction({ row }: Props) {
setCurrentRow(row.original)
setOpen('permissions')
}}>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">{t('roles.details.view_permissions')}</span>
<span
className="text-xs text-primary cursor-pointer underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('roles.details.view_permissions')}
</span>
</Button>
)
}
@@ -24,7 +24,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { getPermissions, UserRole } from '@/api/users/api'
import { CheckCircle2, XCircle } from 'lucide-react'
import { CheckCircle, XCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -71,7 +71,7 @@ export function PermissionsDialog({ currentRow, open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl w-[90vw] overflow-hidden flex flex-col max-h-[90vh]">
<DialogContent className="max-w-7xl w-[40vw] overflow-hidden flex flex-col max-h-[90vh]">
<DialogHeader className="pb-4 border-b">
<div className="flex items-center gap-3">
<DialogTitle>
@@ -87,19 +87,15 @@ export function PermissionsDialog({ currentRow, open, onOpenChange }: Props) {
: t('roles.details.desc_account')}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
<div className={cn(
"grid gap-6 px-1",
roleType === 'Global' ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1"
)}>
<div className="flex flex-col gap-6 px-1">
{categories.map((cat) => (
<div key={cat.titleKey} className="flex flex-col">
<h3 className="text-[11px] font-bold text-slate-500 border-l-4 border-blue-500 pl-2 mb-4 uppercase tracking-widest">
<h3 className="text-[11px] font-bold text-muted-foreground border-l-2 border-primary pl-2 mb-3 uppercase tracking-widest">
{t(cat.titleKey)}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{cat.keys.map((key) => {
const item = getPermissions(t).find(p => p.value === key);
if (!item) return null;
@@ -110,26 +106,23 @@ export function PermissionsDialog({ currentRow, open, onOpenChange }: Props) {
<div
key={item.value}
className={cn(
"flex items-center gap-2.5 p-2 rounded-md transition-all text-xs border",
"flex items-center gap-2.5 p-2.5 rounded-md border text-xs transition-all",
hasPermission
? "bg-green-50/40 border-green-100 text-green-800 shadow-sm"
: "bg-slate-50/30 border-transparent text-slate-400 opacity-60"
? "bg-primary/5 border-primary/20 text-foreground"
: "bg-muted/30 border-border text-muted-foreground opacity-50"
)}
>
{hasPermission ? (
<CheckCircle2 className="w-3.5 h-3.5 text-green-600 shrink-0" />
<CheckCircle className="w-4 h-4 text-primary shrink-0" />
) : (
<XCircle className="w-3.5 h-3.5 text-slate-300 shrink-0" />
<XCircle className="w-4 h-4 text-muted-foreground/30 shrink-0" />
)}
<div className="flex flex-col min-w-0 flex-1">
<span className={cn(
"font-semibold text-sm leading-none truncate",
hasPermission ? "text-slate-900" : "text-slate-500"
)}>
<span className="font-medium text-xs leading-none truncate">
{item.label}
</span>
<span className="text-xs opacity-70 font-mono mt-1 truncate">
<span className="text-[10px] text-muted-foreground font-mono mt-1 truncate">
{item.value}
</span>
</div>
@@ -141,7 +134,6 @@ export function PermissionsDialog({ currentRow, open, onOpenChange }: Props) {
))}
</div>
</div>
<div className="flex justify-end pt-4 border-t mt-auto">
<Button
variant="ghost"
@@ -223,7 +223,7 @@ export function UserActionDialog({ currentRow, open, onOpenChange }: Props) {
<DialogHeader className="p-6 pb-0 shrink-0">
<div className="flex items-center gap-4 mb-4">
{isEdit && currentRow?.avatar ? (
<img src={`data:image/png;base64,${currentRow.avatar}`} className="h-12 w-12 rounded-full border shadow-sm" alt="" />
<img src={`data:image/png;base64,${currentRow.avatar}`} className="h-12 w-12 rounded-full border shadow-sm object-cover" alt="" />
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<UserIcon className="h-6 w-6" />
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "دور {{type}}",
"title": "الصلاحيات: {{name}}",
"unknown": "دور غير معروف",
"view_permissions": "عرض التفاصيل"
"view_permissions": "التفاصيل"
},
"empty": {
"desc": "لم تقم بإنشاء أي أدوار مخصصة بعد.",
@@ -1272,6 +1272,7 @@
"rootTitle": "المستخدم الجذر",
"selectAll": "تحديد الكل",
"sidebar": {
"access": "صلاحيات الوصول",
"apiTokens": "رموز API",
"configurations": "تكوينات النظام",
"profile": "الملف الشخصي",
@@ -1300,6 +1301,9 @@
"desc": "هل أنت متأكد أنك تريد تسجيل الخروج؟ ستحتاج إلى تسجيل الدخول مرة أخرى للوصول إلى حسابك.",
"title": "تسجيل الخروج"
},
"system": {
"notifications": "إشعارات النظام"
},
"systemConfig": {
"pageDescription": "يتم تهيئة هذه المعلمات عبر علامات CLI أو متغيرات البيئة. وهي حاليًا للقراءة فقط لضمان اتساق البيئة.",
"pageTitle": "تكوينات مستوى النظام",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}} rolle",
"title": "Rettigheder: {{name}}",
"unknown": "Ukendt rolle",
"view_permissions": "Se detaljer"
"view_permissions": "Detaljer"
},
"empty": {
"desc": "Du har ikke oprettet nogen brugerdefinerede roller endnu.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root",
"selectAll": "Vælg alle",
"sidebar": {
"access": "Adgang",
"apiTokens": "API-tokens",
"configurations": "Systemkonfigurationer",
"profile": "Profil",
@@ -1300,6 +1301,9 @@
"desc": "Er du sikker på, at du vil logge ud? Du skal logge ind igen for at få adgang til din konto.",
"title": "Log ud"
},
"system": {
"notifications": "Systemmeddelelser"
},
"systemConfig": {
"pageDescription": "Disse parametre initialiseres via CLI-flag eller miljøvariabler. De er skrivebeskyttede.",
"pageTitle": "Systemkonfigurationer",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}}-Rolle",
"title": "Berechtigungen: {{name}}",
"unknown": "Unbekannte Rolle",
"view_permissions": "Details anzeigen"
"view_permissions": "Details"
},
"empty": {
"desc": "Sie haben noch keine benutzerdefinierten Rollen erstellt.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root-Benutzer",
"selectAll": "Alle auswählen",
"sidebar": {
"access": "Zugriffsrechte",
"apiTokens": "API-Token",
"configurations": "Systemkonfigurationen",
"profile": "Profil",
@@ -1300,6 +1301,9 @@
"desc": "Möchten Sie sich wirklich abmelden? Sie müssen sich erneut anmelden, um auf Ihr Konto zuzugreifen.",
"title": "Abmelden"
},
"system": {
"notifications": "Systembenachrichtigungen"
},
"systemConfig": {
"pageDescription": "Diese Parameter werden über CLI-Flags oder Umgebungsvariablen initialisiert. Sie sind schreibgeschützt, um die Umgebungskonsistenz zu gewährleisten.",
"pageTitle": "Systemkonfigurationen",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}} Role",
"title": "Role Permissions: {{name}}",
"unknown": "Unknown Role",
"view_permissions": "Permission Details"
"view_permissions": "Details"
},
"empty": {
"desc": "You haven't created any custom roles yet.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root",
"selectAll": "Select all",
"sidebar": {
"access": "Access Permissions",
"apiTokens": "API Tokens",
"configurations": "System Configurations",
"profile": "Profile",
@@ -1300,6 +1301,9 @@
"desc": "Are you sure you want to sign out? You will need to sign in again to access your account.",
"title": "Sign out"
},
"system": {
"notifications": "System Notifications"
},
"systemConfig": {
"pageDescription": "These parameters are initialized via CLI flags or Environment Variables. They are currently read-only to ensure environment consistency.",
"pageTitle": "System-level Configurations",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "Rol {{type}}",
"title": "Permisos: {{name}}",
"unknown": "Rol desconocido",
"view_permissions": "Ver detalles"
"view_permissions": "Detalles"
},
"empty": {
"desc": "Aún no ha creado ningún rol personalizado.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Usuario raíz",
"selectAll": "Seleccionar todo",
"sidebar": {
"access": "Permisos de acceso",
"apiTokens": "Tokens API",
"configurations": "Configuraciones del sistema",
"profile": "Perfil",
@@ -1300,6 +1301,9 @@
"desc": "¿Está seguro de que desea cerrar sesión? Necesitará iniciar sesión nuevamente para acceder a su cuenta.",
"title": "Cerrar sesión"
},
"system": {
"notifications": "Notificaciones del sistema"
},
"systemConfig": {
"pageDescription": "Estos parámetros se inicializan mediante indicadores CLI o variables de entorno. Actualmente son de solo lectura.",
"pageTitle": "Configuraciones a nivel de sistema",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}}-rooli",
"title": "Oikeudet: {{name}}",
"unknown": "Tuntematon rooli",
"view_permissions": "Katso yksityiskohdat"
"view_permissions": "Tiedot"
},
"empty": {
"desc": "Et ole vielä luonut mukautettuja rooleja.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Pääkäyttäjä",
"selectAll": "Valitse kaikki",
"sidebar": {
"access": "Käyttöoikeudet",
"apiTokens": "API-tunnukset",
"configurations": "Järjestelmäasetukset",
"profile": "Profiili",
@@ -1300,6 +1301,9 @@
"desc": "Haluatko varmasti kirjautua ulos? Sinun täytyy kirjautua uudelleen päästäksesi tilillesi.",
"title": "Kirjaudu ulos"
},
"system": {
"notifications": "Järjestelmäilmoitukset"
},
"systemConfig": {
"pageDescription": "Nämä parametrit alustetaan CLI-lippujen tai ympäristömuuttujien kautta. Ne ovat vain luku -tilassa.",
"pageTitle": "Järjestelmätason konfiguraatiot",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "Rôle {{type}}",
"title": "Permissions : {{name}}",
"unknown": "Rôle inconnu",
"view_permissions": "Détails des permissions"
"view_permissions": "Détails"
},
"empty": {
"desc": "Vous n'avez pas encore créé de rôles personnalisés.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root",
"selectAll": "Tout sélectionner",
"sidebar": {
"access": "Droits d'accès",
"apiTokens": "Jetons API",
"configurations": "Configurations système",
"profile": "Profil",
@@ -1300,6 +1301,9 @@
"desc": "Êtes-vous sûr de vouloir vous déconnecter ? Vous devrez vous reconnecter pour accéder à votre compte.",
"title": "Déconnexion"
},
"system": {
"notifications": "Notifications système"
},
"systemConfig": {
"pageDescription": "Ces paramètres sont initialisés via des drapeaux CLI ou des variables d'environnement. Ils sont en lecture seule pour garantir la cohérence de l'environnement.",
"pageTitle": "Configurations au niveau système",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "Ruolo {{type}}",
"title": "Permessi: {{name}}",
"unknown": "Ruolo sconosciuto",
"view_permissions": "Dettagli permessi"
"view_permissions": "Dettagli"
},
"empty": {
"desc": "Non hai ancora creato alcun ruolo personalizzato.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root",
"selectAll": "Seleziona Tutto",
"sidebar": {
"access": "Permessi di accesso",
"apiTokens": "Token API",
"configurations": "Configurazioni di sistema",
"profile": "Profilo",
@@ -1300,6 +1301,9 @@
"desc": "Sei sicuro di voler disconnetterti? Dovrai accedere di nuovo per usare il tuo account.",
"title": "Disconnetti"
},
"system": {
"notifications": "Notifiche di sistema"
},
"systemConfig": {
"pageDescription": "Questi parametri sono inizializzati tramite flag CLI o variabili d'ambiente. Sono di sola lettura per garantire la coerenza.",
"pageTitle": "Configurazioni a livello di sistema",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}} ロール",
"title": "ロール権限: {{name}}",
"unknown": "不明なロール",
"view_permissions": "権限の詳細"
"view_permissions": "詳細"
},
"empty": {
"desc": "カスタムロールがまだ作成されていません。",
@@ -1272,6 +1272,7 @@
"rootTitle": "ルート",
"selectAll": "すべて選択",
"sidebar": {
"access": "アクセス権限",
"apiTokens": "APIトークン",
"configurations": "システム設定",
"profile": "プロフィール",
@@ -1300,6 +1301,9 @@
"desc": "本当にサインアウトしますか? アカウントにアクセスするには再度サインインが必要です。",
"title": "サインアウト"
},
"system": {
"notifications": "システム通知"
},
"systemConfig": {
"pageDescription": "これらのパラメータは、CLIフラグまたは環境変数を介して初期化されます。環境の一貫性を確保するため、現在は読み取り専用です。",
"pageTitle": "システムレベルの設定",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}} 역할",
"title": "역할 권한: {{name}}",
"unknown": "알 수 없는 역할",
"view_permissions": "권한 상세 정보"
"view_permissions": "상세 정보"
},
"empty": {
"desc": "사용자 정의 역할이 아직 생성되지 않았습니다.",
@@ -1272,6 +1272,7 @@
"rootTitle": "루트",
"selectAll": "모두 선택",
"sidebar": {
"access": "액세스 권한",
"apiTokens": "API 토큰",
"configurations": "시스템 설정",
"profile": "프로필",
@@ -1300,6 +1301,9 @@
"desc": "로그아웃하시겠습니까? 계정에 접근하려면 다시 로그인해야 합니다.",
"title": "로그아웃"
},
"system": {
"notifications": "시스템 알림"
},
"systemConfig": {
"pageDescription": "이 매개변수는 CLI 플래그 또는 환경 변수를 통해 초기화됩니다. 환경의 일관성을 위해 현재 읽기 전용입니다.",
"pageTitle": "시스템 레벨 구성",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}} rol",
"title": "Permissies: {{name}}",
"unknown": "Onbekende rol",
"view_permissions": "Details bekijken"
"view_permissions": "Details"
},
"empty": {
"desc": "U heeft nog geen aangepaste rollen gemaakt.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root",
"selectAll": "Alles selecteren",
"sidebar": {
"access": "Toegangsrechten",
"apiTokens": "API-tokens",
"configurations": "Systeemconfiguraties",
"profile": "Profiel",
@@ -1300,6 +1301,9 @@
"desc": "Weet je zeker dat je wilt uitloggen? Je moet opnieuw inloggen om toegang te krijgen tot je account.",
"title": "Uitloggen"
},
"system": {
"notifications": "Systeemmeldingen"
},
"systemConfig": {
"pageDescription": "Deze parameters worden geïnitialiseerd via CLI-flags of omgevingsvariabelen. Ze zijn alleen-lezen.",
"pageTitle": "Systeemconfiguraties",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}}-rolle",
"title": "Tillatelser: {{name}}",
"unknown": "Ukjent rolle",
"view_permissions": "Se detaljer"
"view_permissions": "Detaljer"
},
"empty": {
"desc": "Du har ikke opprettet noen egendefinerte roller ennå.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root",
"selectAll": "Velg alle",
"sidebar": {
"access": "Tilgang",
"apiTokens": "API-tokener",
"configurations": "Systemkonfigurasjoner",
"profile": "Profil",
@@ -1300,6 +1301,9 @@
"desc": "Er du sikker på at du vil logge ut? Du må logge inn igjen for å få tilgang til kontoen din.",
"title": "Logg ut"
},
"system": {
"notifications": "Systemvarsler"
},
"systemConfig": {
"pageDescription": "Disse parametrene initialiseres via CLI-flagg eller miljøvariabler. De er skrivebeskyttede.",
"pageTitle": "Systemkonfigurasjoner",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "Rola {{type}}",
"title": "Uprawnienia: {{name}}",
"unknown": "Nieznana rola",
"view_permissions": "Zobacz szczegóły"
"view_permissions": "Szczegóły"
},
"empty": {
"desc": "Nie utworzyłeś jeszcze żadnych własnych ról.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root",
"selectAll": "Zaznacz wszystko",
"sidebar": {
"access": "Uprawnienia dostępu",
"apiTokens": "Tokeny API",
"configurations": "Konfiguracja systemu",
"profile": "Profil",
@@ -1300,6 +1301,9 @@
"desc": "Czy na pewno chcesz się wylogować? Aby uzyskać dostęp do konta, będziesz musiał zalogować się ponownie.",
"title": "Wyloguj się"
},
"system": {
"notifications": "Powiadomienia systemowe"
},
"systemConfig": {
"pageDescription": "Parametry te są inicjowane za pomocą flag CLI lub zmiennych środowiskowych. Są one obecnie tylko do odczytu.",
"pageTitle": "Konfiguracje na poziomie systemu",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "Função {{type}}",
"title": "Permissões: {{name}}",
"unknown": "Função desconhecida",
"view_permissions": "Ver detalhes"
"view_permissions": "Detalhes"
},
"empty": {
"desc": "Você ainda não criou funções personalizadas.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root",
"selectAll": "Selecionar Todos",
"sidebar": {
"access": "Permissões de acesso",
"apiTokens": "Tokens API",
"configurations": "Configurações do sistema",
"profile": "Perfil",
@@ -1300,6 +1301,9 @@
"desc": "Tem certeza de que deseja sair? Você precisará entrar novamente para acessar sua conta.",
"title": "Sair"
},
"system": {
"notifications": "Notificações do sistema"
},
"systemConfig": {
"pageDescription": "Estes parâmetros são inicializados via flags CLI ou variáveis de ambiente. Estão em modo somente leitura.",
"pageTitle": "Configurações a nível de sistema",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "Роль: {{type}}",
"title": "Права роли: {{name}}",
"unknown": "Неизвестная роль",
"view_permissions": "Детали прав"
"view_permissions": "Подробнее"
},
"empty": {
"desc": "Вы еще не создали ни одной кастомной роли.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root",
"selectAll": "Выбрать все",
"sidebar": {
"access": "Права доступа",
"apiTokens": "API-токены",
"configurations": "Системные конфигурации",
"profile": "Профиль",
@@ -1300,6 +1301,9 @@
"desc": "Вы уверены, что хотите выйти? Вам нужно будет снова войти, чтобы получить доступ к аккаунту.",
"title": "Выйти"
},
"system": {
"notifications": "Системные уведомления"
},
"systemConfig": {
"pageDescription": "Эти параметры инициализируются через флаги CLI или переменные окружения. Они доступны только для чтения.",
"pageTitle": "Конфигурации системного уровня",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}} Roll",
"title": "Rollbehörigheter: {{name}}",
"unknown": "Okänd roll",
"view_permissions": "Visa behörigheter"
"view_permissions": "Detaljer"
},
"empty": {
"desc": "Du har inte skapat några anpassade roller än.",
@@ -1272,6 +1272,7 @@
"rootTitle": "Root",
"selectAll": "Välj alla",
"sidebar": {
"access": "Behörigheter",
"apiTokens": "API-tokens",
"configurations": "Systemkonfigurationer",
"profile": "Profil",
@@ -1300,6 +1301,9 @@
"desc": "Är du säker på att du vill logga ut? Du måste logga in igen för att få åtkomst till ditt konto.",
"title": "Logga ut"
},
"system": {
"notifications": "Systemaviseringar"
},
"systemConfig": {
"pageDescription": "Dessa parametrar initieras via CLI-flaggor eller miljövariabler. De är skrivskyddade.",
"pageTitle": "Systemkonfigurationer",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}} 角色",
"title": "角色權限: {{name}}",
"unknown": "未知角色",
"view_permissions": "權限詳情"
"view_permissions": "詳情"
},
"empty": {
"desc": "您尚未建立任何自定義角色。點擊下方按鈕開始新增新角色。",
@@ -1272,6 +1272,7 @@
"rootTitle": "根目錄",
"selectAll": "全選",
"sidebar": {
"access": "訪問權限",
"apiTokens": "API 權杖",
"configurations": "系統配置",
"profile": "個人資料",
@@ -1300,6 +1301,9 @@
"desc": "您確定要登出嗎?您需要重新登入才能存取帳戶。",
"title": "登出"
},
"system": {
"notifications": "系統通知"
},
"systemConfig": {
"pageDescription": "這些參數透過 CLI 標誌或環境變數初始化。它們目前為唯讀,以確保環境一致性。",
"pageTitle": "系統級配置",
+5 -1
View File
@@ -915,7 +915,7 @@
"role_badge": "{{type}} 角色",
"title": "角色权限: {{name}}",
"unknown": "未知角色",
"view_permissions": "权限详情"
"view_permissions": "详情"
},
"empty": {
"desc": "您尚未创建任何自定义角色。点击下方按钮开始添加新角色。",
@@ -1272,6 +1272,7 @@
"rootTitle": "管理员账户",
"selectAll": "全选",
"sidebar": {
"access": "访问权限",
"apiTokens": "API 令牌",
"configurations": "系统配置",
"profile": "个人资料",
@@ -1300,6 +1301,9 @@
"desc": "您确定要退出登录吗?您需要重新登录才能访问账户。",
"title": "退出登录"
},
"system": {
"notifications": "系统通知"
},
"systemConfig": {
"pageDescription": "这些参数通过 CLI 标志或环境变量初始化。它们当前为只读,以确保环境一致性。",
"pageTitle": "系统级配置",
+32
View File
@@ -72,6 +72,9 @@ const AuthenticatedSettingsAppearanceLazyImport = createFileRoute(
const AuthenticatedSettingsApiTokensLazyImport = createFileRoute(
'/_authenticated/settings/api-tokens',
)()
const AuthenticatedSettingsAccessLazyImport = createFileRoute(
'/_authenticated/settings/access',
)()
// Create/Update Routes
@@ -298,6 +301,15 @@ const AuthenticatedSettingsApiTokensLazyRoute =
),
)
const AuthenticatedSettingsAccessLazyRoute =
AuthenticatedSettingsAccessLazyImport.update({
id: '/access',
path: '/access',
getParentRoute: () => AuthenticatedSettingsRouteLazyRoute,
} as any).lazy(() =>
import('./routes/_authenticated/settings/access.lazy').then((d) => d.Route),
)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@@ -379,6 +391,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedIndexImport
parentRoute: typeof AuthenticatedRouteImport
}
'/_authenticated/settings/access': {
id: '/_authenticated/settings/access'
path: '/access'
fullPath: '/settings/access'
preLoaderRoute: typeof AuthenticatedSettingsAccessLazyImport
parentRoute: typeof AuthenticatedSettingsRouteLazyImport
}
'/_authenticated/settings/api-tokens': {
id: '/_authenticated/settings/api-tokens'
path: '/api-tokens'
@@ -490,6 +509,7 @@ declare module '@tanstack/react-router' {
// Create and export the route tree
interface AuthenticatedSettingsRouteLazyRouteChildren {
AuthenticatedSettingsAccessLazyRoute: typeof AuthenticatedSettingsAccessLazyRoute
AuthenticatedSettingsApiTokensLazyRoute: typeof AuthenticatedSettingsApiTokensLazyRoute
AuthenticatedSettingsAppearanceLazyRoute: typeof AuthenticatedSettingsAppearanceLazyRoute
AuthenticatedSettingsConfigurationsLazyRoute: typeof AuthenticatedSettingsConfigurationsLazyRoute
@@ -500,6 +520,7 @@ interface AuthenticatedSettingsRouteLazyRouteChildren {
const AuthenticatedSettingsRouteLazyRouteChildren: AuthenticatedSettingsRouteLazyRouteChildren =
{
AuthenticatedSettingsAccessLazyRoute: AuthenticatedSettingsAccessLazyRoute,
AuthenticatedSettingsApiTokensLazyRoute:
AuthenticatedSettingsApiTokensLazyRoute,
AuthenticatedSettingsAppearanceLazyRoute:
@@ -576,6 +597,7 @@ export interface FileRoutesByFullPath {
'/404': typeof errors404LazyRoute
'/503': typeof errors503LazyRoute
'/': typeof AuthenticatedIndexRoute
'/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
'/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
'/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute
@@ -601,6 +623,7 @@ export interface FileRoutesByTo {
'/404': typeof errors404LazyRoute
'/503': typeof errors503LazyRoute
'/': typeof AuthenticatedIndexRoute
'/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
'/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
'/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute
@@ -631,6 +654,7 @@ export interface FileRoutesById {
'/(errors)/500': typeof errors500LazyRoute
'/(errors)/503': typeof errors503LazyRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
'/_authenticated/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
'/_authenticated/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute
@@ -661,6 +685,7 @@ export interface FileRouteTypes {
| '/404'
| '/503'
| '/'
| '/settings/access'
| '/settings/api-tokens'
| '/settings/appearance'
| '/settings/configurations'
@@ -685,6 +710,7 @@ export interface FileRouteTypes {
| '/404'
| '/503'
| '/'
| '/settings/access'
| '/settings/api-tokens'
| '/settings/appearance'
| '/settings/configurations'
@@ -713,6 +739,7 @@ export interface FileRouteTypes {
| '/(errors)/500'
| '/(errors)/503'
| '/_authenticated/'
| '/_authenticated/settings/access'
| '/_authenticated/settings/api-tokens'
| '/_authenticated/settings/appearance'
| '/_authenticated/settings/configurations'
@@ -797,6 +824,7 @@ export const routeTree = rootRoute
"filePath": "_authenticated/settings/route.lazy.tsx",
"parent": "/_authenticated",
"children": [
"/_authenticated/settings/access",
"/_authenticated/settings/api-tokens",
"/_authenticated/settings/appearance",
"/_authenticated/settings/configurations",
@@ -833,6 +861,10 @@ export const routeTree = rootRoute
"filePath": "_authenticated/index.tsx",
"parent": "/_authenticated"
},
"/_authenticated/settings/access": {
"filePath": "_authenticated/settings/access.lazy.tsx",
"parent": "/_authenticated/settings"
},
"/_authenticated/settings/api-tokens": {
"filePath": "_authenticated/settings/api-tokens.lazy.tsx",
"parent": "/_authenticated/settings"
@@ -0,0 +1,6 @@
import { AccountAccessList } from '@/features/settings/access'
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/_authenticated/settings/access')({
component: AccountAccessList,
})