fixed members login and permissions issues
This commit is contained in:
@@ -6,7 +6,7 @@ import { useServerSettings } from 'state/useServerSettings';
|
||||
import { useInitialData } from '@/state/useInitialData';
|
||||
|
||||
export function App() {
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
const { isLoading, isAuthenticated, user } = useAuth();
|
||||
const { onboardingComplete, plugins, isLoading: isServerSettingsLoading } = useServerSettings();
|
||||
useInitialData();
|
||||
|
||||
@@ -37,8 +37,9 @@ export function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard.HomeScreen />} />
|
||||
<Route path="/settings/profile" element={<Dashboard.ProfileSettings />} />
|
||||
<Route path="/settings/system" element={<Dashboard.SystemSettings />} />
|
||||
<Route path="/settings/resources" element={<Dashboard.ResourceSettings />} />
|
||||
<Route path="/settings/system" element={user?.role !== 'Member' ? <Dashboard.SystemSettings /> : <Navigate to="/" replace />} />
|
||||
<Route path="/settings/resources" element={user?.role !== 'Member' ? <Dashboard.ResourceSettings /> : <Navigate to="/" replace />} />
|
||||
<Route path="/settings/users" element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />} />
|
||||
<Route path="/automation" element={<Dashboard.Automation />} />
|
||||
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
||||
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useVerifyScreen } from './useVerifyScreen';
|
||||
|
||||
export const VerifyScreen = () => {
|
||||
const { formRef, isValid, tokenStatus, isSubmitting, handleSubmit } = useVerifyScreen();
|
||||
const { formRef, isValid, tokenStatus, flow, isSubmitting, handleSubmit } = useVerifyScreen();
|
||||
const loading = tokenStatus === 'loading';
|
||||
const invalid = tokenStatus === 'invalid';
|
||||
const hideform = loading || invalid;
|
||||
@@ -30,8 +30,12 @@ export const VerifyScreen = () => {
|
||||
|
||||
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Create Your Account</div>
|
||||
<div className="text-duck-dark/60">Set up your administrator profile</div>
|
||||
<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">
|
||||
|
||||
@@ -9,18 +9,20 @@ export const useVerifyScreen = () => {
|
||||
const isMounted = useMounted();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient('/api/auth');
|
||||
const { state, formRef, update, isValid } = useForm<VerifyFormState>({ email: '' }, validateForm);
|
||||
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 }>('/verify-token', { verificationCode });
|
||||
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');
|
||||
@@ -45,15 +47,25 @@ export const useVerifyScreen = () => {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await client.post('/bootstrap', {
|
||||
token: verificationCode,
|
||||
name: state.name,
|
||||
username: state.username,
|
||||
password: state.password,
|
||||
confirmPassword: state.confirmPassword,
|
||||
});
|
||||
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('/auth/login');
|
||||
navigate('/');
|
||||
} catch (ex) {
|
||||
const error = ex as { message?: string };
|
||||
toast.error(error.message || 'Failed to create account. Please try again.');
|
||||
@@ -62,7 +74,9 @@ export const useVerifyScreen = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return { formRef, state, isValid, tokenStatus, isSubmitting, handleSubmit };
|
||||
const isValid = flow === 'bootstrap' ? validateBootstrap(state) : validateInvite(state);
|
||||
|
||||
return { formRef, state, isValid, tokenStatus, flow, isSubmitting, handleSubmit };
|
||||
};
|
||||
|
||||
type VerifyFormState = {
|
||||
@@ -75,7 +89,14 @@ type VerifyFormState = {
|
||||
|
||||
type TokenStatus = 'loading' | 'valid' | 'invalid';
|
||||
|
||||
const validateForm = (state: Partial<VerifyFormState>) => {
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ export const Users = pgTable('users', {
|
||||
role: userRolesEnum('role').default(USER_ROLES[0]),
|
||||
status: userStatusEnum('status').default(USER_STATUSES[0]),
|
||||
name: varchar('name', { length: 128 }),
|
||||
username: varchar('username', { length: 128 }),
|
||||
username: varchar('username', { length: 128 }).unique(),
|
||||
avatar: varchar('avatar', { length: 512000 }),
|
||||
passwordChangedAt: bigint('password_changed_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
+2
-1
@@ -15,6 +15,7 @@ const { PORT = '5000' } = process.env;
|
||||
type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
provider: 'terminal' | 'pi' | 'dev-server';
|
||||
sandboxed: boolean;
|
||||
@@ -106,7 +107,7 @@ async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi')
|
||||
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
|
||||
const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
|
||||
const ok = server.upgrade(req, {
|
||||
data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd, command, cols, rows },
|
||||
data: { userId: user.id, email: user.email, username: user.username || user.email.split('@')[0]!, role: user.role, provider, sandboxed, sessionId, cwd, command, cols, rows },
|
||||
});
|
||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||
} catch {
|
||||
|
||||
@@ -49,20 +49,22 @@ export function rateLimiter(options: RateLimiterOptions): MiddlewareHandler {
|
||||
const now = Date.now();
|
||||
const entry = store.get(key);
|
||||
|
||||
if (entry && entry.resetAt > now) {
|
||||
if (entry.count >= max) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
throw errors.TOO_MANY_REQUESTS(message, retryAfter);
|
||||
}
|
||||
entry.count++;
|
||||
} else {
|
||||
store.set(key, {
|
||||
count: 1,
|
||||
resetAt: now + windowMs,
|
||||
});
|
||||
if (entry && entry.resetAt > now && entry.count >= max) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
throw errors.TOO_MANY_REQUESTS(message, retryAfter);
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
// Only count failed attempts (4xx status codes)
|
||||
const status = ctx.res.status;
|
||||
if (status >= 400 && status < 500) {
|
||||
if (entry && entry.resetAt > now) {
|
||||
entry.count++;
|
||||
} else {
|
||||
store.set(key, { count: 1, resetAt: now + windowMs });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { officerdb, eq, Users, TokenBlacklist } from 'officerdb';
|
||||
// Role permissions: which HTTP methods each role can use
|
||||
// Roles not listed here are denied by default (fail-safe)
|
||||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
Member: ['GET'],
|
||||
Member: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
Admin: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
Owner: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
'Super Admin': ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
|
||||
@@ -15,7 +15,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
|
||||
|
||||
const dbUser = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.id, reqUser.id),
|
||||
columns: { password: true },
|
||||
columns: { password: true, username: true },
|
||||
});
|
||||
|
||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||
@@ -31,7 +31,8 @@ export const changePasswordHandler: Handler = async function (ctx) {
|
||||
await officerdb.update(Users).set({ password: newPasswordHash, passwordChangedAt }).where(eq(Users.id, reqUser.id));
|
||||
|
||||
const { id, email, name, role } = reqUser;
|
||||
const token = await sign({ id, email, name, role });
|
||||
const username = dbUser.username ?? reqUser.username;
|
||||
const token = await sign({ id, email, name, username, role });
|
||||
|
||||
return ctx.json({ token });
|
||||
};
|
||||
|
||||
@@ -3,10 +3,9 @@ import { officerdb, eq, Users } from 'officerdb';
|
||||
import { sign } from '@@/jwt';
|
||||
import { sendMail } from 'emailer';
|
||||
|
||||
const { PUBLIC_URL } = process.env;
|
||||
|
||||
export const forgotPasswordHandler: Handler = async function (ctx) {
|
||||
const { email } = ctx.get('body');
|
||||
const origin = ctx.get('origin');
|
||||
|
||||
const dbUser = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.email, email),
|
||||
@@ -14,7 +13,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) {
|
||||
|
||||
if (!dbUser) return ctx.json({ ok: true });
|
||||
const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h');
|
||||
const url = `${PUBLIC_URL}/auth/reset-password?verificationCode=${verificationCode}`;
|
||||
const url = `${origin}/auth/reset-password?verificationCode=${verificationCode}`;
|
||||
|
||||
await sendMail({
|
||||
template: 'ForgotPassword',
|
||||
|
||||
@@ -200,12 +200,13 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
||||
|
||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||
|
||||
const { id, name, role } = dbUser;
|
||||
const { id, name, username, role } = dbUser;
|
||||
const passkeys = dbUser.passkeys?.length ?? 0;
|
||||
const token = await sign({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
username,
|
||||
role,
|
||||
passkeys,
|
||||
});
|
||||
@@ -216,6 +217,7 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
username,
|
||||
role,
|
||||
passkeys,
|
||||
},
|
||||
|
||||
@@ -27,12 +27,12 @@ export const signinHandler: Handler = async function (ctx) {
|
||||
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
|
||||
if (!isValidPassword) throw errors.UNAUTHORIZED();
|
||||
|
||||
const { id, name, role } = dbUser;
|
||||
const { id, name, username, role } = dbUser;
|
||||
|
||||
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
|
||||
syncUserPiConfig(email).catch(() => {});
|
||||
|
||||
const tokenUser = { id, email, name, role, passkeys: passkeys.length };
|
||||
const tokenUser = { id, email, name, username, role, passkeys: passkeys.length };
|
||||
|
||||
if (passkeys.length > 0 && !origin.startsWith('chrome-extension://') && !TEST_USERS.includes(dbUser.id)) {
|
||||
return ctx.json({ user: tokenUser });
|
||||
|
||||
@@ -17,7 +17,7 @@ export const verifyTokenHandler: Handler = async function (ctx) {
|
||||
|
||||
// Bootstrap token: has email but no id (user not yet created)
|
||||
if (userInfo?.email && !userInfo?.id) {
|
||||
return ctx.json({ ok: true, email: userInfo.email });
|
||||
return ctx.json({ ok: true, email: userInfo.email, flow: 'bootstrap' });
|
||||
}
|
||||
|
||||
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||
@@ -29,7 +29,7 @@ export const verifyTokenHandler: Handler = async function (ctx) {
|
||||
|
||||
// Reset-password tokens skip the verification status check
|
||||
const isResetToken = (userInfo as Record<string, unknown>).purpose === 'reset-password';
|
||||
if (!isResetToken && user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
|
||||
if (!isResetToken && user.status !== 'Unverified' && user.status !== 'Invited') throw errors.BAD_REQUEST('Account is already verified');
|
||||
|
||||
return ctx.json({ ok: true, email: user.email });
|
||||
return ctx.json({ ok: true, email: user.email, flow: user.status === 'Invited' ? 'invite' : 'verify' });
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as errors from '@@/custom-errors';
|
||||
import { validatePassword } from './validate-password';
|
||||
|
||||
export const verifyHandler: Handler = async function (ctx) {
|
||||
const { verificationCode, name, password, confirmPassword } = ctx.get('body');
|
||||
const { verificationCode, name, username, password, confirmPassword } = ctx.get('body');
|
||||
const userInfo = (await verifyJwt(verificationCode)) as User;
|
||||
if (!userInfo) throw errors.BAD_REQUEST();
|
||||
|
||||
@@ -25,6 +25,10 @@ export const verifyHandler: Handler = async function (ctx) {
|
||||
updates.name = name.trim();
|
||||
}
|
||||
|
||||
if (username && typeof username === 'string' && username.trim()) {
|
||||
updates.username = username.trim();
|
||||
}
|
||||
|
||||
if (password) {
|
||||
validatePassword(password);
|
||||
if (password !== confirmPassword) {
|
||||
@@ -33,10 +37,26 @@ export const verifyHandler: Handler = async function (ctx) {
|
||||
updates.password = await argon2.hash(password);
|
||||
}
|
||||
|
||||
await officerdb.update(Users).set(updates).where(eq(Users.id, userInfo.id));
|
||||
const [updatedUser] = await officerdb
|
||||
.update(Users)
|
||||
.set(updates)
|
||||
.where(eq(Users.id, userInfo.id))
|
||||
.returning({ username: Users.username });
|
||||
|
||||
// Re-fetch user to get final values after update
|
||||
const finalUser = await officerdb.query.Users.findFirst({
|
||||
where: eq(Users.id, userInfo.id),
|
||||
});
|
||||
if (!finalUser) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Issue a token so the user is logged in immediately
|
||||
const token = await sign({ id: userInfo.id, email: userInfo.email });
|
||||
const token = await sign({
|
||||
id: finalUser.id,
|
||||
email: finalUser.email,
|
||||
name: finalUser.name,
|
||||
username: finalUser.username,
|
||||
role: finalUser.role,
|
||||
});
|
||||
|
||||
return ctx.json({ ok: true, token });
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { join, relative } from "path";
|
||||
import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { readApiKeys } from "../server-settings/pi-mono";
|
||||
@@ -8,11 +9,10 @@ export type PiEventHandler = (event: PiEvent) => void;
|
||||
|
||||
type SandboxOptions = {
|
||||
userId: number;
|
||||
username: string;
|
||||
homeDir: string;
|
||||
};
|
||||
|
||||
const CONTAINER_HOME = '/home/officer';
|
||||
const CONTAINER_PI_CONFIG = '/home/officer/.pi/agent';
|
||||
|
||||
export async function spawnPi(
|
||||
cwd: string,
|
||||
model: string,
|
||||
@@ -25,21 +25,25 @@ export async function spawnPi(
|
||||
const storedKeys = await readApiKeys();
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const containerId = `officer-terminal-${sandbox.userId}`;
|
||||
const containerHome = `/home/${sandbox.username}`;
|
||||
const containerPiConfig = `${containerHome}/.pi/agent`;
|
||||
const piArgs = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||
if (model) piArgs.push('--model', model);
|
||||
|
||||
// Build env flags: Pi config dir + all stored API keys
|
||||
const envFlags = [
|
||||
'-e', `PI_CODING_AGENT_DIR=${CONTAINER_PI_CONFIG}`,
|
||||
'-e', `HOME=${CONTAINER_HOME}`,
|
||||
'-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`,
|
||||
'-e', `HOME=${containerHome}`,
|
||||
];
|
||||
for (const [key, value] of Object.entries(storedKeys)) {
|
||||
if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`);
|
||||
}
|
||||
|
||||
const rel = relative(sandbox.homeDir, cwd);
|
||||
const workdir = rel && !rel.startsWith('..') ? join(containerHome, rel) : containerHome;
|
||||
proc = Bun.spawn([
|
||||
dockerPath, 'exec', '-i',
|
||||
'-w', CONTAINER_HOME,
|
||||
'-w', workdir,
|
||||
...envFlags,
|
||||
containerId,
|
||||
...piArgs,
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ClientMessage, ServerMessage, Message, PiEvent } from './types';
|
||||
import { sessionManager } from './session-manager';
|
||||
import * as storage from './storage';
|
||||
import * as piBridge from './pi-bridge';
|
||||
import { join, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getHomeDir, getUserSettingsFile } from '../../../servers/data-path';
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -27,12 +29,27 @@ async function getUserDefaultModel(email: string): Promise<string | null> {
|
||||
type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const resolveRoot = (email: string, root?: string) => {
|
||||
if (!root || root === 'home') return getHomeDir(email);
|
||||
if (root === '~') return homedir();
|
||||
if (root === 'officer.dev') return resolve(process.cwd(), '..');
|
||||
return getHomeDir(email);
|
||||
};
|
||||
|
||||
const resolveCwd = (home: string, cwd?: string) => {
|
||||
if (!cwd || cwd === '~') return home;
|
||||
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||
if (cwd.startsWith('/')) return join(home, cwd.slice(1));
|
||||
return home;
|
||||
};
|
||||
|
||||
const wsToSessionMap = new WeakMap<any, string>();
|
||||
|
||||
function sendToClient(ws: ServerWebSocket<WSData> | null, msg: ServerMessage): void {
|
||||
@@ -220,9 +237,9 @@ function createEventHandler(sessionId: string, model: string, cwd: string) {
|
||||
|
||||
async function handleChat(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[] }
|
||||
msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[] }
|
||||
): Promise<void> {
|
||||
const { email, userId } = ws.data;
|
||||
const { email, username, userId } = ws.data;
|
||||
const sessionId = msg.sessionId || randomUUID();
|
||||
|
||||
// Use provided model, or fall back to user default, or use system default
|
||||
@@ -248,7 +265,9 @@ async function handleChat(
|
||||
userDefault,
|
||||
});
|
||||
|
||||
const cwd = msg.cwd || getHomeDir(email);
|
||||
const homeDir = getHomeDir(email);
|
||||
const rootDir = resolveRoot(email, msg.cwdRoot);
|
||||
const cwd = resolveCwd(rootDir, msg.cwd);
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
const sandboxed = msg.sandboxed ?? false;
|
||||
@@ -262,7 +281,7 @@ async function handleChat(
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
const onEvent = createEventHandler(sessionId, model, cwd);
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId } : undefined);
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, homeDir } : undefined);
|
||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
||||
@@ -328,7 +347,8 @@ async function handleResume(
|
||||
// Spawn fresh Pi process if needed
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId } : undefined;
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, homeDir } : undefined;
|
||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd);
|
||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox);
|
||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
|
||||
|
||||
@@ -52,9 +52,7 @@ RUN curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LA
|
||||
|
||||
RUN npm install -g @mariozechner/pi-coding-agent
|
||||
|
||||
RUN mkdir -p /home/officer/Documents /home/officer/Downloads /home/officer/Music /home/officer/Videos /home/officer/Pictures /home/officer/Desktop /home/officer/Projects
|
||||
|
||||
WORKDIR /home/officer
|
||||
WORKDIR /tmp
|
||||
|
||||
ENV TERMINAL_PTY_PORT=5337
|
||||
|
||||
|
||||
@@ -17,17 +17,18 @@ fi
|
||||
|
||||
# Create group and user
|
||||
groupadd -g "$USER_GID" "$USERNAME" 2>/dev/null || true
|
||||
useradd -u "$USER_UID" -g "$USER_GID" -s /bin/zsh -d /home/officer "$USERNAME" 2>/dev/null || true
|
||||
mkdir -p /home/$USERNAME
|
||||
useradd -u "$USER_UID" -g "$USER_GID" -s /bin/zsh -d /home/$USERNAME "$USERNAME" 2>/dev/null || true
|
||||
|
||||
# Passwordless sudo
|
||||
echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/terminal-user
|
||||
chmod 0440 /etc/sudoers.d/terminal-user
|
||||
|
||||
# Seed LazyVim config if not present
|
||||
if [ ! -d /home/officer/.config/nvim ]; then
|
||||
mkdir -p /home/officer/.config
|
||||
cp -r /opt/lazyvim-starter /home/officer/.config/nvim
|
||||
chown -R "$USER_UID:$USER_GID" /home/officer/.config
|
||||
if [ ! -d /home/$USERNAME/.config/nvim ]; then
|
||||
mkdir -p /home/$USERNAME/.config
|
||||
cp -r /opt/lazyvim-starter /home/$USERNAME/.config/nvim
|
||||
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.config
|
||||
fi
|
||||
|
||||
# Run sidecar as the user
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getHomeDir } from '@@/data-path';
|
||||
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
||||
import { officerdb, Users } from 'officerdb';
|
||||
|
||||
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
|
||||
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
|
||||
type ShellInfo = { command: string; args: string[]; name: string };
|
||||
type BridgeSession = {
|
||||
client: ServerWebSocket<WSData>;
|
||||
@@ -101,7 +101,7 @@ const ensureDockerImage = () => {
|
||||
dockerImageReady = true;
|
||||
};
|
||||
|
||||
const startDockerSidecar = (port: number, homeDir: string, userId: number, email: string): { dockerId: string } => {
|
||||
const startDockerSidecar = (port: number, homeDir: string, userId: number, username: string): { dockerId: string } => {
|
||||
ensureDockerImage();
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const dockerId = `officer-terminal-${userId}`;
|
||||
@@ -112,7 +112,6 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, email
|
||||
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
||||
}
|
||||
|
||||
const username = (email.split('@')[0] ?? 'officer').replace(/[^a-z0-9_-]/g, '_').slice(0, 32);
|
||||
let uid = 1000;
|
||||
let gid = 1000;
|
||||
try {
|
||||
@@ -123,6 +122,7 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, email
|
||||
// fallback to defaults
|
||||
}
|
||||
|
||||
const containerHome = `/home/${username}`;
|
||||
const run = Bun.spawnSync({
|
||||
cmd: [
|
||||
dockerPath,
|
||||
@@ -145,9 +145,9 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, email
|
||||
'-e',
|
||||
`TERMINAL_GID=${gid}`,
|
||||
'-v',
|
||||
`${homeDir}:/home/officer`,
|
||||
`${homeDir}:${containerHome}`,
|
||||
'-w',
|
||||
'/home/officer',
|
||||
containerHome,
|
||||
tag,
|
||||
],
|
||||
stdout: 'inherit',
|
||||
@@ -210,7 +210,7 @@ const dockerStart = (dockerId: string) => {
|
||||
return result.exitCode === 0;
|
||||
};
|
||||
|
||||
const ensureDockerContainer = async (email: string, userId: number, homeDir: string) => {
|
||||
const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => {
|
||||
const map = await loadContainerMap();
|
||||
const existing = map[email];
|
||||
if (existing && dockerContainerRunning(existing.dockerId)) return existing;
|
||||
@@ -221,7 +221,7 @@ const ensureDockerContainer = async (email: string, userId: number, homeDir: str
|
||||
}
|
||||
|
||||
const port = existing?.port ?? getAvailablePort(map, userId);
|
||||
const docker = startDockerSidecar(port, homeDir, userId, email);
|
||||
const docker = startDockerSidecar(port, homeDir, userId, username);
|
||||
const next = { userId, email, dockerId: docker.dockerId, port };
|
||||
map[email] = next;
|
||||
await saveContainerMap(map);
|
||||
@@ -269,7 +269,7 @@ const startHostSidecar = async () => {
|
||||
export const initTerminalSidecars = async () => {
|
||||
await startHostSidecar();
|
||||
ensureDockerImage();
|
||||
const users = await officerdb.select({ id: Users.id, email: Users.email }).from(Users);
|
||||
const users = await officerdb.select({ id: Users.id, email: Users.email, username: Users.username }).from(Users);
|
||||
for (const user of users) {
|
||||
const homeDir = getHomeDir(user.email);
|
||||
mkdirSync(dirname(homeDir), { recursive: true });
|
||||
@@ -278,7 +278,7 @@ export const initTerminalSidecars = async () => {
|
||||
await syncUserPiConfig(user.email).catch((err) => {
|
||||
console.error(`[terminal] failed to sync Pi config for ${user.email}:`, err);
|
||||
});
|
||||
await ensureDockerContainer(user.email, user.id, homeDir);
|
||||
await ensureDockerContainer(user.email, user.id, homeDir, user.username ?? user.email.split('@')[0]!);
|
||||
console.log(`[terminal] sidecar ready for ${user.email}`);
|
||||
} catch (err) {
|
||||
console.error(`[terminal] failed to start sidecar for ${user.email}:`, err);
|
||||
@@ -287,7 +287,6 @@ export const initTerminalSidecars = async () => {
|
||||
};
|
||||
|
||||
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
|
||||
const containerHome = '/home/officer';
|
||||
|
||||
const resolveCwd = (home: string, cwd?: string) => {
|
||||
if (!cwd || cwd === '~') return home;
|
||||
@@ -298,7 +297,7 @@ const resolveCwd = (home: string, cwd?: string) => {
|
||||
|
||||
export const terminalWebsocket = {
|
||||
async open(ws: ServerWebSocket<WSData>) {
|
||||
const { email, role, sandboxed } = ws.data;
|
||||
const { email, username, role, sandboxed } = ws.data;
|
||||
|
||||
if (!sandboxed && role !== 'Super Admin') {
|
||||
sendOutput(ws, '\r\n[Permission denied] Host terminal requires Super Admin role.\r\n');
|
||||
@@ -363,7 +362,7 @@ export const terminalWebsocket = {
|
||||
let sidecar: WebSocket | null = null;
|
||||
let info: ContainerInfo | undefined;
|
||||
try {
|
||||
info = await ensureDockerContainer(email, ws.data.userId, cwd);
|
||||
info = await ensureDockerContainer(email, ws.data.userId, cwd, username);
|
||||
sidecar = await connectSidecar(info.port);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
|
||||
@@ -396,6 +395,7 @@ export const terminalWebsocket = {
|
||||
}
|
||||
});
|
||||
|
||||
const containerHome = `/home/${username}`;
|
||||
sidecar.send(
|
||||
JSON.stringify({
|
||||
type: 'init',
|
||||
|
||||
@@ -3,14 +3,14 @@ import { officerdb, eq, Users } from 'officerdb';
|
||||
import * as errors from '@@/custom-errors';
|
||||
|
||||
export const updateUserHandler: Handler = async function (ctx) {
|
||||
const { name, avatar } = ctx.get('body');
|
||||
const { name, username, avatar } = ctx.get('body');
|
||||
const reqUser = ctx.get('user');
|
||||
|
||||
if (typeof name !== 'string') throw errors.BAD_REQUEST('Name is required');
|
||||
|
||||
await officerdb
|
||||
.update(Users)
|
||||
.set({ name, avatar: avatar ?? null })
|
||||
.set({ name, username: typeof username === 'string' ? username : undefined, avatar: avatar ?? null })
|
||||
.where(eq(Users.id, reqUser.id));
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { officerdb, eq, Users } from 'officerdb';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { sign } from '@@/jwt';
|
||||
import { USER_ROLES } from 'definitions';
|
||||
import { sendMail } from 'emailer';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { originMiddleware } from '@@/_middlewares';
|
||||
import { updateUserHandler } from './update-user';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
usersRouter.use(originMiddleware);
|
||||
|
||||
// List all users (Super Admin only)
|
||||
usersRouter.get('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const users = await officerdb.query.Users.findMany();
|
||||
const sanitized = users.map(({ password, ...rest }) => rest);
|
||||
|
||||
return ctx.json(sanitized);
|
||||
});
|
||||
|
||||
// Self-update (any authenticated user)
|
||||
usersRouter.put('/', updateUserHandler);
|
||||
|
||||
// Invite a new user (Super Admin only)
|
||||
usersRouter.post('/invite', async (ctx) => {
|
||||
const reqUser = ctx.get('user');
|
||||
if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const { email, role } = ctx.get('body');
|
||||
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
throw errors.BAD_REQUEST('Invalid email address');
|
||||
}
|
||||
|
||||
const validRoles = USER_ROLES.filter((r) => r !== 'Super Admin');
|
||||
const assignedRole = (typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number]))
|
||||
? (role as (typeof USER_ROLES)[number])
|
||||
: ('Member' as const);
|
||||
|
||||
const existing = await officerdb.query.Users.findFirst({ where: eq(Users.email, email) });
|
||||
if (existing) throw errors.CONFLICT('A user with this email already exists');
|
||||
|
||||
const insertedUsers = await officerdb.insert(Users).values({
|
||||
email,
|
||||
role: assignedRole,
|
||||
status: 'Invited',
|
||||
}).returning();
|
||||
|
||||
if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user');
|
||||
const dbUser = insertedUsers[0]!;
|
||||
|
||||
const origin = ctx.get('origin');
|
||||
const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h');
|
||||
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
||||
|
||||
await sendMail({
|
||||
template: 'UserInvite',
|
||||
subject: 'You have been invited to Officer',
|
||||
to: email,
|
||||
data: { invitedBy: reqUser.name ?? reqUser.email, url },
|
||||
});
|
||||
|
||||
const { password, ...safeUser } = dbUser;
|
||||
return ctx.json(safeUser);
|
||||
});
|
||||
|
||||
// Resend invite (Super Admin only, status must be Invited)
|
||||
usersRouter.post('/:id/resend-invite', async (ctx) => {
|
||||
const reqUser = ctx.get('user');
|
||||
if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID');
|
||||
|
||||
const target = await officerdb.query.Users.findFirst({ where: eq(Users.id, id) });
|
||||
if (!target) throw errors.NOT_FOUND('User not found');
|
||||
if (target.status !== 'Invited') throw errors.BAD_REQUEST('User is not in Invited status');
|
||||
|
||||
const origin = ctx.get('origin');
|
||||
const verificationCode = await sign({ id: target.id, email: target.email }, '24h');
|
||||
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
||||
|
||||
await sendMail({
|
||||
template: 'UserInvite',
|
||||
subject: 'You have been invited to Officer',
|
||||
to: target.email,
|
||||
data: { invitedBy: reqUser.name ?? reqUser.email, url },
|
||||
});
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// Delete a user (Super Admin only, cannot delete self)
|
||||
usersRouter.delete('/:id', async (ctx) => {
|
||||
const reqUser = ctx.get('user');
|
||||
if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID');
|
||||
if (id === reqUser.id) throw errors.BAD_REQUEST('Cannot delete yourself');
|
||||
|
||||
const target = await officerdb.query.Users.findFirst({ where: eq(Users.id, id) });
|
||||
if (!target) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
await officerdb.delete(Users).where(eq(Users.id, id));
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
+2
-2
@@ -5,7 +5,7 @@ import type { HonoVariables } from './create-router';
|
||||
import { authRouter } from './api/auth';
|
||||
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
||||
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
||||
import { updateUserHandler } from './api/users/update-user';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { plansRouter } from './api/plans/plans';
|
||||
import { skillsRouter } from './api/skills/skills';
|
||||
import { tasksRouter } from './api/tasks/tasks';
|
||||
@@ -46,7 +46,7 @@ const protectedRouter = createRouter();
|
||||
protectedRouter.use(bodyParser());
|
||||
protectedRouter.use(userMiddleware);
|
||||
|
||||
protectedRouter.put('/users', updateUserHandler);
|
||||
protectedRouter.route('/users', usersRouter);
|
||||
protectedRouter.route('/plans', plansRouter);
|
||||
protectedRouter.route('/skills', skillsRouter);
|
||||
protectedRouter.route('/tasks', tasksRouter);
|
||||
|
||||
@@ -4,18 +4,17 @@ import { Text, Button } from '@react-email/components';
|
||||
|
||||
type EmailProps = {
|
||||
invitedBy: string;
|
||||
companyName: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const Email = ({ invitedBy, companyName, url }: EmailProps) => {
|
||||
const Email = ({ invitedBy, url }: EmailProps) => {
|
||||
return (
|
||||
<Layout>
|
||||
<Container>
|
||||
<Text className="pt-4 text-2xl">Invitation to our platform</Text>
|
||||
<Text>You have been invited by {invitedBy || '<invitedBy>'} to join Pertento.ai</Text>
|
||||
<Text>as a member of {companyName || '<companyName>'}</Text>
|
||||
<Button href={url || 'https://example.com'}>Click here to accept our invitation</Button>
|
||||
<Text className="pt-4 text-2xl">You're invited to Officer</Text>
|
||||
<Text>You have been invited by {invitedBy || '<invitedBy>'} to join Officer.</Text>
|
||||
<Text>Click the button below to set up your account.</Text>
|
||||
<Button href={url || 'https://example.com'}>Accept Invitation</Button>
|
||||
</Container>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ export type SignupUserForm = {
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type UpdateUserPayload = { name: string; avatar: string };
|
||||
export type UpdateUserPayload = { name: string; username?: string; avatar: string };
|
||||
|
||||
export type ResetPasswordPayload = { password: string; verificationCode: string };
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ export type SignupUserForm = {
|
||||
|
||||
export type UpdateUserPayload = {
|
||||
name: string;
|
||||
username?: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { EmbeddableChat } from './EmbeddableChat';
|
||||
|
||||
export const ChatPanelWrapper = () => <EmbeddableChat className="h-full" />;
|
||||
export const ChatPanelWrapper = () => {
|
||||
const { cwd, root } = useWorkspace();
|
||||
const scoped = cwd !== '~';
|
||||
const hostRoot = root === '~' || root === 'officer.dev';
|
||||
const sandboxed = !hostRoot;
|
||||
|
||||
return (
|
||||
<EmbeddableChat
|
||||
className="h-full"
|
||||
cwd={scoped ? { root, path: cwd } : undefined}
|
||||
sandboxed={sandboxed}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ type EmbeddableChatProps = {
|
||||
className?: string;
|
||||
cwd?: { root?: string; path: string };
|
||||
sandboxed?: boolean;
|
||||
replaceUrl?: boolean;
|
||||
autoSend?: boolean;
|
||||
chat?: UsePiChatType;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ type UseEmbeddableChatParams = {
|
||||
promptPrefix?: string;
|
||||
cwd?: { root?: string; path: string };
|
||||
sandboxed?: boolean;
|
||||
replaceUrl?: boolean;
|
||||
autoSend?: boolean;
|
||||
chat?: UsePiChatType;
|
||||
};
|
||||
@@ -25,7 +26,7 @@ type UseEmbeddableChatParams = {
|
||||
export function useEmbeddableChat(params: UseEmbeddableChatParams) {
|
||||
const { initialMessage, defaultInput = '', promptPrefix, cwd, sandboxed, autoSend = false, chat: externalChat } = params;
|
||||
|
||||
const internalChat = usePiChat(params.sessionId, params.initialModel);
|
||||
const internalChat = usePiChat(params.sessionId, params.initialModel, { replaceUrl: params.replaceUrl ?? false });
|
||||
const chat = externalChat ?? internalChat;
|
||||
|
||||
const {
|
||||
|
||||
@@ -7,10 +7,10 @@ import { useTerminalMode } from './useTerminalMode';
|
||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||
|
||||
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const { workspaceId, cwd, root } = useWorkspace();
|
||||
const { mode } = useTerminalMode(panelId);
|
||||
const scoped = cwd !== '~';
|
||||
const sandboxed = scoped || mode === 'sandboxed';
|
||||
const hostRoot = root === '~' || root === 'officer.dev';
|
||||
const sandboxed = !hostRoot && (cwd !== '~' || mode === 'sandboxed');
|
||||
const stateKey = workspaceId ? `ws-terminals-${mode}-${workspaceId}` : `ws-terminals-${mode}-default`;
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
@@ -20,7 +20,8 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
|
||||
const id = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
setTerminals((prev) => ({ ...prev, [panelId]: id }));
|
||||
}
|
||||
}, [panelId, sessionId, setTerminals]);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useClient } from 'hooks/useClient';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '../../components/Workspace';
|
||||
import type { LayoutNode, WorkspaceDefinition } from '../../components/Workspace';
|
||||
import type { LayoutNode, WorkspaceDefinition, HomeRoot } from '../../components/Workspace';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateSlug, slugify } from 'helpers/slug';
|
||||
import { FileBrowserApp } from '../FileBrowser';
|
||||
@@ -276,6 +276,7 @@ const CreatePanel = () => {
|
||||
const client = useClient();
|
||||
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||
const [filePath] = useUserState<string>('files/currentPath', '/');
|
||||
const [homeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
||||
const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
|
||||
const cwd = filePath === '/' ? '~' : `~/${filePath.replace(/^\//, '')}`;
|
||||
@@ -337,6 +338,7 @@ const CreatePanel = () => {
|
||||
id,
|
||||
name: trimmed,
|
||||
cwd,
|
||||
root: homeRoot !== 'home' ? homeRoot : undefined,
|
||||
description: desc || undefined,
|
||||
templateIdx,
|
||||
};
|
||||
@@ -480,7 +482,7 @@ const WorkspacePreviewEmpty = () => {
|
||||
const WorkspacePreviewInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||
const ws = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
|
||||
return <WorkspaceView workspace={ws} cwd={workspace.cwd} />;
|
||||
return <WorkspaceView workspace={ws} cwd={workspace.cwd} root={workspace.root} />;
|
||||
};
|
||||
|
||||
// --- Main Export ---
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DropPosition } from './layout-utils';
|
||||
type WorkspaceContextValue = {
|
||||
workspaceId: string | null;
|
||||
cwd: string;
|
||||
root?: string;
|
||||
swapSourceId: string | null;
|
||||
setSwapSourceId: (id: string | null) => void;
|
||||
onSwap: (sourceId: string, targetId: string) => void;
|
||||
|
||||
@@ -11,12 +11,13 @@ import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
||||
type WorkspaceViewProps = {
|
||||
workspace: WorkspaceState;
|
||||
cwd?: string;
|
||||
root?: string;
|
||||
ephemeral?: EphemeralPanels | null;
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const WorkspaceView = ({ workspace, cwd = '~', ephemeral }: WorkspaceViewProps) => {
|
||||
export const WorkspaceView = ({ workspace, cwd = '~', root, ephemeral }: WorkspaceViewProps) => {
|
||||
const { registry } = useAppRegistry();
|
||||
|
||||
const layout = workspace.value;
|
||||
@@ -147,6 +148,7 @@ export const WorkspaceView = ({ workspace, cwd = '~', ephemeral }: WorkspaceView
|
||||
value={{
|
||||
workspaceId: workspace.key,
|
||||
cwd,
|
||||
root,
|
||||
swapSourceId,
|
||||
setSwapSourceId,
|
||||
onSwap: handleSwap,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels } from './types';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, HomeRoot } from './types';
|
||||
export type { DropPosition } from './layout-utils';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||
|
||||
@@ -16,10 +16,13 @@ export type LayoutPanel = {
|
||||
|
||||
export type LayoutNode = LayoutGroup | LayoutPanel;
|
||||
|
||||
export type HomeRoot = 'home' | '~' | 'officer.dev';
|
||||
|
||||
export type WorkspaceDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
root?: HomeRoot;
|
||||
description?: string;
|
||||
templateIdx?: number;
|
||||
};
|
||||
|
||||
@@ -283,6 +283,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
|
||||
sessionId: sessionIdRef.current,
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
|
||||
...(cwdParam?.root ? { cwdRoot: cwdParam.root } : {}),
|
||||
...(sandboxed !== undefined ? { sandboxed } : {}),
|
||||
...(groupSlug !== undefined ? { groupSlug } : {}),
|
||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||
|
||||
@@ -20,4 +20,4 @@ export { ProjectListApp, ProjectPreview, SELECTED_PROJECT, CREATING_PROJECT, EDI
|
||||
// Workspace
|
||||
export { WorkspaceView, WorkspaceLayout, WorkspaceProvider, useWorkspace } from './components/Workspace';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './components/Workspace';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, DropPosition } from './components/Workspace';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, DropPosition, HomeRoot } from './components/Workspace';
|
||||
|
||||
Reference in New Issue
Block a user