fixed members login and permissions issues

This commit is contained in:
2026-02-23 00:57:34 +00:00
parent ed0debfeac
commit 97da2e2736
42 changed files with 574 additions and 113 deletions
@@ -1,7 +1,7 @@
import { Link } from 'react-router';
import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, LogOut, Settings, Package, Sun, Moon } from 'lucide-react';
import { User, Users, LogOut, Settings, Package, Sun, Moon } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
import { useColorMode } from '@/components/ui/ThemeProvider';
@@ -16,6 +16,8 @@ export function UserMenu() {
const { settings, saveSettings } = useSettings();
if (isLoading) return null;
const isAdmin = user?.role !== 'Member';
const toggleColorMode = () => {
const next = colorMode === 'dark' ? 'light' : 'dark';
setColorMode(next);
@@ -41,18 +43,30 @@ export function UserMenu() {
{t('header.userMenu.profile')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/system">
<Settings className="mr-2 h-4 w-4" />
{t('header.userMenu.systemSettings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/resources">
<Package className="mr-2 h-4 w-4" />
{t('header.userMenu.resources')}
</Link>
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/system">
<Settings className="mr-2 h-4 w-4" />
{t('header.userMenu.systemSettings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/resources">
<Package className="mr-2 h-4 w-4" />
{t('header.userMenu.resources')}
</Link>
</DropdownMenuItem>
</>
)}
{user?.role === 'Super Admin' && (
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/users">
<Users className="mr-2 h-4 w-4" />
Users
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={toggleColorMode} className="cursor-pointer">
{colorMode === 'dark' ? (
@@ -102,6 +102,16 @@ export const UserData = () => {
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
value={user?.username ?? ''}
disabled
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span>
<Input
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { useForm } from 'hooks/useForm';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
type InviteUserDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
};
type InviteFormState = {
email: string;
role: string;
};
const ROLES = ['Member', 'Admin', 'Owner'] as const;
export const InviteUserDialog = ({ open, onOpenChange, onSuccess }: InviteUserDialogProps) => {
const client = useClient();
const { state, formRef, update, reset } = useForm<InviteFormState>({ email: '', role: 'Member' });
const [loading, setLoading] = useState(false);
const handleSubmit = async (ev: React.FormEvent) => {
ev.preventDefault();
const email = state.email?.trim();
if (!email) return;
setLoading(true);
try {
await client.post('/users/invite', { email, role: state.role });
toast.success(`Invitation sent to ${email}`);
reset();
onOpenChange(false);
onSuccess();
} catch {
toast.error('Failed to send invitation');
} finally {
setLoading(false);
}
};
const handleRoleChange = (value: string) => {
update((prev) => ({ ...prev, role: value }));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>Send an invitation email to a new user.</DialogDescription>
</DialogHeader>
<form ref={formRef} onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="invite-email">Email</Label>
<Input id="invite-email" type="email" name="email" placeholder="user@example.com" required />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="invite-role">Role</Label>
<Select value={state.role ?? 'Member'} onValueChange={handleRoleChange}>
<SelectTrigger id="invite-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLES.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={loading || !state.email?.trim()}>
{loading ? 'Sending...' : 'Send Invitation'}
</Button>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,95 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import type { UserSelect } from 'officerdb/types';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { DataTable, useDataControl } from '@/components/DataTable';
import { SearchInput } from '@/components/SearchInput';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { UserPlus, Trash2, MailPlus } from 'lucide-react';
import { InviteUserDialog } from './InviteUserDialog';
type SafeUser = Omit<UserSelect, 'password'>;
export const UsersTable = () => {
const client = useClient();
const { user: currentUser } = useAuth();
const [inviteOpen, setInviteOpen] = useState(false);
const { data: users, refetch } = useQuery({
queryKey: ['users'],
queryFn: () => client.get<SafeUser[]>('/users'),
});
const dataController = useDataControl<SafeUser>(users ?? []);
const handleResendInvite = async (user: SafeUser) => {
try {
await client.post(`/users/${user.id}/resend-invite`);
toast.success(`Invitation resent to ${user.email}`);
} catch {
toast.error('Failed to resend invitation');
}
};
const handleDelete = async (user: SafeUser) => {
if (!confirm(`Delete ${user.name || user.email}?`)) return;
try {
await client.delete(`/users/${user.id}`);
toast.success('User deleted');
refetch();
} catch {
toast.error('Failed to delete user');
}
};
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<SearchInput value={dataController.searchQuery} handleSearch={dataController.setSearchQuery} />
<Button size="sm" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-2 h-4 w-4" />
Invite User
</Button>
</div>
<DataTable<SafeUser>
dataController={dataController}
pageSize={20}
columns={[
{ field: 'name', label: 'Name', sortKey: 'name' },
{ field: 'email', label: 'Email', sortKey: 'email' },
{ field: 'role', label: 'Role', sortKey: 'role' },
{
field: 'status',
label: 'Status',
sortKey: 'status',
format: ({ value }) => (
<Badge variant={value === 'Active' ? 'default' : 'secondary'}>{value as string}</Badge>
),
},
{
label: '',
format: ({ item }) =>
item.id !== currentUser?.id ? (
<div className="flex items-center gap-1">
{item.status === 'Invited' && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleResendInvite(item)}>
<MailPlus className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : null,
},
]}
/>
<InviteUserDialog open={inviteOpen} onOpenChange={setInviteOpen} onSuccess={refetch} />
</div>
);
};
@@ -0,0 +1,46 @@
import { useMemo } from 'react';
import { Users } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { UsersTable } from './UsersTable';
const GLOBAL_KEY = 'USER_SETTINGS_SELECTED';
const sections: SettingsSection[] = [
{ key: 'all-users', icon: Users, title: 'All Users', description: 'View and manage users', content: <UsersTable /> },
];
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: GLOBAL_KEY,
sidebarIcon: Users,
sidebarLabel: 'Users',
sections,
});
const layout: LayoutNode = {
type: 'group',
id: 'users-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'users-left', appType: null }, size: 20 },
{ node: { type: 'panel', id: 'users-right', appType: null }, size: 80 },
],
};
export const UserSettings = () => {
const panelComponents: PanelComponents = useMemo(
() => ({
'users-left': Sidebar,
'users-right': Content,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
);
};
@@ -1,3 +1,4 @@
export * from './ProfileSettings';
export * from './SystemSettings';
export * from './ResourceSettings';
export * from './UserSettings';
@@ -19,7 +19,7 @@ const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition })
return (
<div className="h-full w-full">
<WorkspaceView workspace={wsState} cwd={workspace.cwd} />
<WorkspaceView workspace={wsState} cwd={workspace.cwd} root={workspace.root} />
</div>
);
};