mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 10:06:17 +08:00
feat(admin): add self-host setup and user management page (#7537)
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { UserDropdown } from './user-dropdown';
|
||||
|
||||
export interface NavProp {
|
||||
title: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export function Nav({
|
||||
links,
|
||||
activeTab,
|
||||
}: {
|
||||
links: NavProp[];
|
||||
activeTab: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex flex-col gap-4 py-2 justify-between flex-grow">
|
||||
<nav className="grid gap-1 px-2">
|
||||
{links.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
to={link.to}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === link.title ? 'default' : 'ghost',
|
||||
size: 'sm',
|
||||
}),
|
||||
activeTab === link.title &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start'
|
||||
)}
|
||||
>
|
||||
<link.icon className="mr-2 h-4 w-4" />
|
||||
{link.title}
|
||||
{link.label && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
activeTab === link.title && 'text-background dark:text-white'
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<UserDropdown />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -8,9 +13,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { FeatureType, getCurrentUserFeaturesQuery } from '@affine/graphql';
|
||||
import { CircleUser } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
FeatureType,
|
||||
getCurrentUserFeaturesQuery,
|
||||
serverConfigQuery,
|
||||
} from '@affine/graphql';
|
||||
import { CircleUser, MoreVertical } from 'lucide-react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -20,9 +29,32 @@ export function UserDropdown() {
|
||||
} = useQuery({
|
||||
query: getCurrentUserFeaturesQuery,
|
||||
});
|
||||
|
||||
const {
|
||||
data: { serverConfig },
|
||||
} = useQuery({
|
||||
query: serverConfigQuery,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
fetch('/api/auth/sign-out', {
|
||||
method: 'POST',
|
||||
})
|
||||
.then(() => {
|
||||
navigate('/admin/auth');
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error(`Failed to logout: ${err.message}`);
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverConfig.initialized === false) {
|
||||
navigate('/admin/setup');
|
||||
return;
|
||||
}
|
||||
if (!currentUser) {
|
||||
navigate('/admin/auth');
|
||||
return;
|
||||
@@ -32,28 +64,47 @@ export function UserDropdown() {
|
||||
navigate('/admin/auth');
|
||||
return;
|
||||
}
|
||||
}, [currentUser, navigate]);
|
||||
const avatar = currentUser?.avatarUrl ? (
|
||||
<img src={currentUser?.avatarUrl} />
|
||||
) : (
|
||||
<CircleUser size={24} />
|
||||
);
|
||||
}, [currentUser, navigate, serverConfig.initialized]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="rounded-full">
|
||||
{avatar}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{currentUser?.name}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem>Support</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Logout</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center justify-between px-4 py-3 flex-nowrap">
|
||||
<div className="flex items-center gap-2 font-medium text-ellipsis break-words overflow-hidden">
|
||||
<Avatar className="w-6 h-6">
|
||||
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<CircleUser size={24} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{currentUser?.name ? (
|
||||
<span className="text-sm text-nowrap text-ellipsis break-words overflow-hidden">
|
||||
{currentUser?.name}
|
||||
</span>
|
||||
) : (
|
||||
// Fallback to email prefix if name is not available
|
||||
<span className="text-sm">{currentUser?.email.split('@')[0]}</span>
|
||||
)}
|
||||
<span
|
||||
className="rounded p-1 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'rgba(30, 150, 235, 0.20)',
|
||||
color: 'rgba(30, 150, 235, 1)',
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="ml-2 p-1 h-6">
|
||||
<MoreVertical size={20} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{currentUser?.name}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleLogout}>Logout</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user