remove the dead multi-user surface
Officer is single-user: the server owner is the only account, created once by /auth/bootstrap. Everything that existed to serve additional users was unreachable, so it is gone rather than left looking like it does something. Accounts: drop the invite / resend-invite / delete / list-users routes and the Users settings screen, the inert /auth/signup handler, and the account verification chain it fed (verify, resend-verification, VerifyScreen, the UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token survives for password resets only, and now requires a reset-password token rather than accepting any signed JWT. Roles: drop the users.role column and the four-value USER_ROLES enum. The permissions table granted every role identical methods, and every role === 'Super Admin' check was permanently true. The JWT no longer carries a role claim. Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected only for non-Super-Admin users, so it never ran. It was also not a usable agent jail as written — --share-net, the project root (with .env) bound read-only, and runuser dropping to the server's own uid. Rebuilding it for agent containment would be a different construction, and git history keeps this one. getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the owner's real login home, which is what terminals, chats and task runs use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
92de996412
commit
044aacf4d5
@@ -7,7 +7,7 @@ import { useServerEnvironment } from 'state/useServerEnvironment';
|
||||
import { useInitialData } from '@/state/useInitialData';
|
||||
|
||||
export function App() {
|
||||
const { isLoading, isAuthenticated, user } = useAuth();
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
const { onboardingComplete, plugins, isLoading: isServerSettingsLoading } = useServerSettings();
|
||||
useServerEnvironment();
|
||||
useInitialData();
|
||||
@@ -20,7 +20,6 @@ export function App() {
|
||||
<Authentication.AuthenticationLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Authentication.LandingPage />} />
|
||||
<Route path="/auth/verify" element={<Authentication.VerifyScreen />} />
|
||||
<Route path="/auth/forgot-password" element={<Authentication.ForgotPassword />} />
|
||||
<Route path="/auth/reset-password" element={<Authentication.ResetPassword />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
@@ -40,14 +39,7 @@ export function App() {
|
||||
<Route path="/" element={<Dashboard.HomeScreen />} />
|
||||
<Route path="/settings/profile" element={<Dashboard.ProfileSettings />} />
|
||||
<Route path="/settings/ai" element={<Dashboard.AISettings />} />
|
||||
<Route
|
||||
path="/settings/system"
|
||||
element={user?.role !== 'Member' ? <Dashboard.SystemSettings /> : <Navigate to="/" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/users"
|
||||
element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />}
|
||||
/>
|
||||
<Route path="/settings/system" element={<Dashboard.SystemSettings />} />
|
||||
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
|
||||
<Route path="/settings/apps" element={<Dashboard.AppsSettings />} />
|
||||
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
||||
@@ -72,10 +64,7 @@ export function App() {
|
||||
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
|
||||
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
||||
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||
<Route
|
||||
path="/desktop"
|
||||
element={user?.role === 'Super Admin' ? <Dashboard.DesktopScreen /> : <Navigate to="/" replace />}
|
||||
/>
|
||||
<Route path="/desktop" element={<Dashboard.DesktopScreen />} />
|
||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
export const Verify = () => {
|
||||
const isMounted = useMounted();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient('/api/auth');
|
||||
const { state, formRef, update } = useForm<VerifyFormState>({ email: '' });
|
||||
|
||||
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
||||
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
||||
const [flow, setFlow] = useState<'bootstrap' | 'invite' | 'verify'>('verify');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const verifyToken = async () => {
|
||||
if (!verificationCode) return;
|
||||
try {
|
||||
const data = await client.post<{ ok: boolean; email: string; flow: 'bootstrap' | 'invite' | 'verify' }>('/verify-token', { verificationCode });
|
||||
if (data.ok) {
|
||||
setTokenStatus('valid');
|
||||
setFlow(data.flow);
|
||||
requestAnimationFrame(() => update({ email: data.email }));
|
||||
} else {
|
||||
setTokenStatus('invalid');
|
||||
}
|
||||
} catch {
|
||||
setTokenStatus('invalid');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
if (!verificationCode) {
|
||||
setTokenStatus('invalid');
|
||||
return;
|
||||
}
|
||||
verifyToken();
|
||||
}, [isMounted]);
|
||||
|
||||
const isValid = validateForm(state);
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (flow === 'bootstrap') {
|
||||
await client.post('/bootstrap', {
|
||||
token: verificationCode,
|
||||
name: state.name,
|
||||
username: state.username,
|
||||
password: state.password,
|
||||
confirmPassword: state.confirmPassword,
|
||||
});
|
||||
} else {
|
||||
await client.post('/verify', {
|
||||
verificationCode,
|
||||
name: state.name,
|
||||
username: state.username,
|
||||
password: state.password,
|
||||
confirmPassword: state.confirmPassword,
|
||||
});
|
||||
}
|
||||
toast.success('Account created. Please sign in.');
|
||||
navigate('/');
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to create account. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loading = tokenStatus === 'loading';
|
||||
const invalid = tokenStatus === 'invalid';
|
||||
const hideForm = loading || invalid;
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center text-duck-dark/60">Verifying...</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invalid && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
||||
<div className="text-duck-dark/60">This verification link is invalid or has expired.</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className={cn('flex flex-col gap-6', hideForm && 'hidden')}>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">
|
||||
{flow === 'bootstrap' ? 'Create Your Account' : 'Accept Invitation'}
|
||||
</div>
|
||||
<div className="text-duck-dark/60">
|
||||
{flow === 'bootstrap' ? 'Set up your administrator profile' : 'Set up your profile to get started'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Name</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</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"
|
||||
name="username"
|
||||
placeholder="your-username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<div className="grid md:flex gap-4">
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const validateForm = (state: Partial<VerifyFormState>) => {
|
||||
const { name, username, password, confirmPassword } = state;
|
||||
if (!name || !username || !password || !confirmPassword) return false;
|
||||
if (password !== confirmPassword) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
type VerifyFormState = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useVerifyScreen } from './useVerifyScreen';
|
||||
|
||||
export const VerifyScreen = () => {
|
||||
const { formRef, isValid, tokenStatus, flow, isSubmitting, handleSubmit } = useVerifyScreen();
|
||||
const loading = tokenStatus === 'loading';
|
||||
const invalid = tokenStatus === 'invalid';
|
||||
const hideform = loading || invalid;
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center text-duck-dark/60">Verifying...</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invalid && (
|
||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
||||
<div className="text-duck-dark/60">This verification link is invalid or has expired.</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">
|
||||
{flow === 'bootstrap' ? 'Create Your Account' : 'Accept Invitation'}
|
||||
</div>
|
||||
<div className="text-duck-dark/60">
|
||||
{flow === 'bootstrap' ? 'Set up your administrator profile' : 'Set up your profile to get started'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
name="email"
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Name</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</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"
|
||||
name="username"
|
||||
placeholder="your-username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<div className="grid md:flex gap-4">
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Password</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Min 12 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2 flex-1">
|
||||
<span className="text-duck-dark/70">Confirm Password</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card >
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './VerifyScreen';
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
import { useForm } from 'hooks/useForm';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
export const useVerifyScreen = () => {
|
||||
const isMounted = useMounted();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient('/api/auth');
|
||||
const { state, formRef, update } = useForm<VerifyFormState>({ email: '' });
|
||||
|
||||
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
||||
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
|
||||
const [flow, setFlow] = useState<'bootstrap' | 'invite' | 'verify'>('bootstrap');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const verifyToken = async () => {
|
||||
if (!verificationCode) return;
|
||||
try {
|
||||
const data = await client.post<{ ok: boolean; email: string; flow: 'bootstrap' | 'invite' | 'verify' }>('/verify-token', { verificationCode });
|
||||
if (data.ok) {
|
||||
setTokenStatus('valid');
|
||||
setFlow(data.flow);
|
||||
requestAnimationFrame(() => update({ email: data.email }));
|
||||
} else {
|
||||
setTokenStatus('invalid');
|
||||
}
|
||||
} catch {
|
||||
setTokenStatus('invalid');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
if (!verificationCode) {
|
||||
setTokenStatus('invalid');
|
||||
return;
|
||||
}
|
||||
verifyToken();
|
||||
}, [isMounted]);
|
||||
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!isValid || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (flow === 'bootstrap') {
|
||||
await client.post('/bootstrap', {
|
||||
token: verificationCode,
|
||||
name: state.name,
|
||||
username: state.username,
|
||||
password: state.password,
|
||||
confirmPassword: state.confirmPassword,
|
||||
});
|
||||
} else {
|
||||
await client.post('/verify', {
|
||||
verificationCode,
|
||||
name: state.name,
|
||||
username: state.username,
|
||||
password: state.password,
|
||||
confirmPassword: state.confirmPassword,
|
||||
});
|
||||
}
|
||||
toast.success('Account created. Please sign in.');
|
||||
navigate('/');
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to create account. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = flow === 'bootstrap' ? validateBootstrap(state) : validateInvite(state);
|
||||
|
||||
return { formRef, state, isValid, tokenStatus, flow, isSubmitting, handleSubmit };
|
||||
};
|
||||
|
||||
type VerifyFormState = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||
|
||||
const validateBootstrap = (state: Partial<VerifyFormState>) => {
|
||||
const { name, username, password, confirmPassword } = state;
|
||||
if (!name || !username || !password || !confirmPassword) return false;
|
||||
if (password !== confirmPassword) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateInvite = (state: Partial<VerifyFormState>) => {
|
||||
const { name, username, password, confirmPassword } = state;
|
||||
if (!name || !username || !password || !confirmPassword) return false;
|
||||
if (password !== confirmPassword) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -1,14 +1,6 @@
|
||||
import { AuthenticationLayout } from './Layout';
|
||||
import { LandingPage } from './LandingPage';
|
||||
import { SignoutScreen } from './Signout';
|
||||
import { VerifyScreen } from './VerifyScreen';
|
||||
import { ForgotPassword, ResetPassword } from './ForgotPassword';
|
||||
|
||||
export {
|
||||
AuthenticationLayout,
|
||||
LandingPage,
|
||||
SignoutScreen,
|
||||
VerifyScreen,
|
||||
ForgotPassword,
|
||||
ResetPassword,
|
||||
};
|
||||
export { AuthenticationLayout, LandingPage, SignoutScreen, ForgotPassword, ResetPassword };
|
||||
|
||||
@@ -135,7 +135,7 @@ inputs: # optional — parameters the user fills in before runni
|
||||
- No triggers → task is only runnable from the Automation page
|
||||
|
||||
## Notes
|
||||
- Tasks run inside the user's sandboxed container
|
||||
- Tasks run on the host as the server owner
|
||||
- The markdown body after the frontmatter should contain step-by-step instructions for the agent
|
||||
</task-creation-guide>`;
|
||||
|
||||
@@ -243,7 +243,7 @@ inputs:
|
||||
- \`object\` — JSON object
|
||||
|
||||
## Notes
|
||||
- Tools run inside the user's sandboxed container
|
||||
- Tools run on the host as the server owner
|
||||
- The \`name\` field uses snake_case (this is the function name the agent calls)
|
||||
- The \`label\` field is the human-readable display name
|
||||
- Mark parameters as \`optional: true\` when they have sensible defaults
|
||||
|
||||
@@ -7,7 +7,6 @@ export type DockItem = {
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
type DockProps = {
|
||||
@@ -109,8 +108,21 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, ScrollText, FolderKanban, Monitor, Mail, Globe, MonitorSmartphone, Workflow } from 'lucide-react';
|
||||
import {
|
||||
Home,
|
||||
MessageCircle,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Code,
|
||||
LayoutGrid,
|
||||
ScrollText,
|
||||
FolderKanban,
|
||||
Monitor,
|
||||
Mail,
|
||||
Globe,
|
||||
MonitorSmartphone,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
||||
@@ -124,7 +136,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899', role: 'Super Admin' },
|
||||
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' },
|
||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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, Users, LogOut, Settings, Package, Puzzle, Rocket, Sun, Moon, Bot } from 'lucide-react';
|
||||
import { User, LogOut, Settings, Package, Puzzle, Rocket, Sun, Moon, Bot } from 'lucide-react';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||
@@ -16,8 +16,6 @@ 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);
|
||||
@@ -43,22 +41,18 @@ export function UserMenu() {
|
||||
{t('header.userMenu.profile')}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/ai">
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
@@ -77,14 +71,6 @@ export function UserMenu() {
|
||||
Apps
|
||||
</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' ? <Sun className="mr-2 h-4 w-4" /> : <Moon className="mr-2 h-4 w-4" />}
|
||||
|
||||
@@ -104,12 +104,8 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
// --- My Models Section (per-user hidden models) ---
|
||||
|
||||
function MyModelsSection() {
|
||||
const { user } = useAuth();
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const allModels = useModels();
|
||||
const policyModels = useVisibleModels();
|
||||
const isAdmin = user?.role !== 'Member';
|
||||
const visibleModels = isAdmin ? allModels : policyModels;
|
||||
const visibleModels = useModels();
|
||||
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
|
||||
|
||||
const hiddenModels = settings.chat.hiddenModels ?? [];
|
||||
@@ -411,12 +407,7 @@ function MemberModelsSection() {
|
||||
// // ... full implementation for future use
|
||||
// }
|
||||
|
||||
// --- Build groups based on role ---
|
||||
|
||||
function useAISettingsGroups(): SettingsSectionGroup[] {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role !== 'Member';
|
||||
|
||||
return useMemo(() => {
|
||||
const modelsGroup: SettingsSectionGroup = {
|
||||
label: 'Models',
|
||||
@@ -429,17 +420,13 @@ function useAISettingsGroups(): SettingsSectionGroup[] {
|
||||
description: 'Show or hide models for yourself',
|
||||
content: <MyModelsSection />,
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
key: 'member-models',
|
||||
icon: Eye,
|
||||
title: 'Member Models',
|
||||
description: 'Enable or disable models for members',
|
||||
content: <MemberModelsSection />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'member-models',
|
||||
icon: Eye,
|
||||
title: 'Channel Models',
|
||||
description: 'Models reachable from Telegram, WhatsApp and Discord',
|
||||
content: <MemberModelsSection />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -457,25 +444,22 @@ function useAISettingsGroups(): SettingsSectionGroup[] {
|
||||
],
|
||||
};
|
||||
|
||||
if (isAdmin) {
|
||||
const providersGroup: SettingsSectionGroup = {
|
||||
label: 'Providers',
|
||||
icon: Terminal,
|
||||
sections: [
|
||||
{
|
||||
key: 'ai-harnesses',
|
||||
icon: Terminal,
|
||||
title: 'Providers',
|
||||
description: 'Remote and local AI providers',
|
||||
content: <AIHarnessesSection />,
|
||||
},
|
||||
],
|
||||
};
|
||||
return [providersGroup, modelsGroup, defaultsGroup];
|
||||
}
|
||||
const providersGroup: SettingsSectionGroup = {
|
||||
label: 'Providers',
|
||||
icon: Terminal,
|
||||
sections: [
|
||||
{
|
||||
key: 'ai-harnesses',
|
||||
icon: Terminal,
|
||||
title: 'Providers',
|
||||
description: 'Remote and local AI providers',
|
||||
content: <AIHarnessesSection />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return [modelsGroup, defaultsGroup];
|
||||
}, [isAdmin]);
|
||||
return [providersGroup, modelsGroup, defaultsGroup];
|
||||
}, []);
|
||||
}
|
||||
|
||||
const layout: LayoutNode = {
|
||||
|
||||
@@ -108,9 +108,7 @@ const personalSections: SettingsSection[] = [
|
||||
];
|
||||
|
||||
const IntegrationsSidebar = () => {
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.role === 'Super Admin';
|
||||
const [tab, setTab] = useGlobal<string>(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal');
|
||||
const [tab, setTab] = useGlobal<string>(TAB_KEY, 'enterprise');
|
||||
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
|
||||
|
||||
return (
|
||||
@@ -121,29 +119,25 @@ const IntegrationsSidebar = () => {
|
||||
Integrations
|
||||
</div>
|
||||
</div>
|
||||
{isSuperAdmin && (
|
||||
<div className="px-3 pb-2">
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
|
||||
Enterprise
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="personal" className="flex-1 cursor-pointer">
|
||||
Personal
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2">
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
|
||||
Enterprise
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="personal" className="flex-1 cursor-pointer">
|
||||
Personal
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<SettingsSidebar globalKey={GLOBAL_KEY} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const IntegrationsContent = () => {
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.role === 'Super Admin';
|
||||
const [tab] = useGlobal<string>(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal');
|
||||
const [tab] = useGlobal<string>(TAB_KEY, 'enterprise');
|
||||
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
|
||||
|
||||
return <SettingsContent globalKey={GLOBAL_KEY} sections={sections} />;
|
||||
|
||||
@@ -124,13 +124,7 @@ const SystemTerminalPanel = () => {
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<TerminalView
|
||||
className="flex-1"
|
||||
sandboxed={false}
|
||||
command={session.command}
|
||||
sessionId={session.id}
|
||||
onCommandDone={onCommandDone}
|
||||
/>
|
||||
<TerminalView className="flex-1" command={session.command} sessionId={session.id} onCommandDone={onCommandDone} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
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,6 +1,5 @@
|
||||
export * from './ProfileSettings';
|
||||
export * from './SystemSettings';
|
||||
export * from './AISettings';
|
||||
export * from './UserSettings';
|
||||
export * from './IntegrationsSettings';
|
||||
export * from './AppsSettings';
|
||||
|
||||
Reference in New Issue
Block a user