diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index 101b59a2..3c60a37a 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -22,6 +22,7 @@ import { SidebarGroup, SidebarGroupContent, SidebarHeader, + SidebarFooter, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -51,6 +52,7 @@ const workspaceNav = [ { href: "/agents", label: "Agents", icon: Bot }, { href: "/skills", label: "Skills", icon: Sparkles }, { href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen }, + { href: "/settings", label: "Settings", icon: Settings }, ]; export function AppSidebar() { @@ -103,15 +105,6 @@ export function AppSidebar() { - - } - > - - Settings - - - Workspaces @@ -218,6 +211,7 @@ export function AppSidebar() { + ); diff --git a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx new file mode 100644 index 00000000..d3ecb705 --- /dev/null +++ b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Save } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "sonner"; +import { useAuthStore } from "@/features/auth"; +import { api } from "@/shared/api"; + +export function AccountTab() { + const user = useAuthStore((s) => s.user); + const setUser = useAuthStore((s) => s.setUser); + + const [profileName, setProfileName] = useState(user?.name ?? ""); + const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? ""); + const [profileSaving, setProfileSaving] = useState(false); + + useEffect(() => { + setProfileName(user?.name ?? ""); + setAvatarUrl(user?.avatar_url ?? ""); + }, [user]); + + const handleProfileSave = async () => { + setProfileSaving(true); + try { + const updated = await api.updateMe({ + name: profileName, + avatar_url: avatarUrl || undefined, + }); + setUser(updated); + toast.success("Profile updated"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to update profile"); + } finally { + setProfileSaving(false); + } + }; + + return ( +
+
+

Profile

+ + + +
+ + setProfileName(e.target.value)} + className="mt-1" + /> +
+
+ + setAvatarUrl(e.target.value)} + placeholder="https://example.com/avatar.png" + className="mt-1" + /> +
+
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/settings/_components/general-tab.tsx b/apps/web/app/(dashboard)/settings/_components/general-tab.tsx new file mode 100644 index 00000000..58eb9187 --- /dev/null +++ b/apps/web/app/(dashboard)/settings/_components/general-tab.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Sun, Moon, Monitor } from "lucide-react"; + +const themeOptions = [ + { value: "light", label: "Light", icon: Sun }, + { value: "dark", label: "Dark", icon: Moon }, + { value: "system", label: "System", icon: Monitor }, +] as const; + +export function AppearanceTab() { + const { theme, setTheme } = useTheme(); + + return ( +
+
+

Theme

+
+ {themeOptions.map((opt) => { + const active = theme === opt.value; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx new file mode 100644 index 00000000..5bd83f2b --- /dev/null +++ b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { useState } from "react"; +import { Crown, Shield, User, Plus, Trash2, Users } from "lucide-react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import type { MemberWithUser, MemberRole } from "@/shared/types"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { toast } from "sonner"; +import { useAuthStore } from "@/features/auth"; +import { useWorkspaceStore } from "@/features/workspace"; +import { api } from "@/shared/api"; + +const roleConfig: Record = { + owner: { label: "Owner", icon: Crown }, + admin: { label: "Admin", icon: Shield }, + member: { label: "Member", icon: User }, +}; + +function MemberRow({ + member, + canManage, + canManageOwners, + isSelf, + busy, + onRoleChange, + onRemove, +}: { + member: MemberWithUser; + canManage: boolean; + canManageOwners: boolean; + isSelf: boolean; + busy: boolean; + onRoleChange: (role: MemberRole) => void; + onRemove: () => void; +}) { + const rc = roleConfig[member.role]; + const RoleIcon = rc.icon; + const canEditRole = canManage && (!isSelf || canManageOwners) && (member.role !== "owner" || canManageOwners); + const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners); + + return ( + + +
+ {member.name + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2)} +
+
+
{member.name}
+
{member.email}
+
+ {canEditRole ? ( + + ) : ( +
+ + {rc.label} +
+ )} + {canRemove && ( + + + + + } + /> + Remove member + + )} +
+
+ ); +} + +export function MembersTab() { + const user = useAuthStore((s) => s.user); + const workspace = useWorkspaceStore((s) => s.workspace); + const members = useWorkspaceStore((s) => s.members); + const refreshMembers = useWorkspaceStore((s) => s.refreshMembers); + + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("member"); + const [inviteLoading, setInviteLoading] = useState(false); + const [memberActionId, setMemberActionId] = useState(null); + const [confirmAction, setConfirmAction] = useState<{ + title: string; + description: string; + variant?: "destructive"; + onConfirm: () => Promise; + } | null>(null); + + const currentMember = members.find((m) => m.user_id === user?.id) ?? null; + const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin"; + const isOwner = currentMember?.role === "owner"; + + const handleAddMember = async () => { + if (!workspace) return; + setInviteLoading(true); + try { + await api.createMember(workspace.id, { + email: inviteEmail, + role: inviteRole, + }); + setInviteEmail(""); + setInviteRole("member"); + await refreshMembers(); + toast.success("Member added"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to add member"); + } finally { + setInviteLoading(false); + } + }; + + const handleRoleChange = async (memberId: string, role: MemberRole) => { + if (!workspace) return; + setMemberActionId(memberId); + try { + await api.updateMember(workspace.id, memberId, { role }); + await refreshMembers(); + toast.success("Role updated"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to update member"); + } finally { + setMemberActionId(null); + } + }; + + const handleRemoveMember = (member: MemberWithUser) => { + if (!workspace) return; + setConfirmAction({ + title: `Remove ${member.name}`, + description: `Remove ${member.name} from ${workspace.name}? They will lose access to this workspace.`, + variant: "destructive", + onConfirm: async () => { + setMemberActionId(member.id); + try { + await api.deleteMember(workspace.id, member.id); + await refreshMembers(); + toast.success("Member removed"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to remove member"); + } finally { + setMemberActionId(null); + } + }, + }); + }; + + if (!workspace) return null; + + return ( +
+
+
+ +

Members ({members.length})

+
+ + {canManageWorkspace && ( + + +
+ +

Add member

+
+
+ setInviteEmail(e.target.value)} + placeholder="user@company.com" + /> + + +
+
+
+ )} + +
+ {members.map((m) => ( + handleRoleChange(m.id, role)} + onRemove={() => handleRemoveMember(m)} + /> + ))} + {members.length === 0 && ( +

No members found.

+ )} +
+
+ + { if (!v) setConfirmAction(null); }}> + + + {confirmAction?.title} + {confirmAction?.description} + + + Cancel + { + await confirmAction?.onConfirm(); + setConfirmAction(null); + }} + > + Confirm + + + + +
+ ); +} diff --git a/apps/web/app/(dashboard)/settings/_components/tokens-tab.tsx b/apps/web/app/(dashboard)/settings/_components/tokens-tab.tsx new file mode 100644 index 00000000..8a55017a --- /dev/null +++ b/apps/web/app/(dashboard)/settings/_components/tokens-tab.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Key, Trash2, Copy, Check } from "lucide-react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import type { PersonalAccessToken } from "@/shared/types"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { toast } from "sonner"; +import { api } from "@/shared/api"; + +export function TokensTab() { + const [tokens, setTokens] = useState([]); + const [tokenName, setTokenName] = useState(""); + const [tokenExpiry, setTokenExpiry] = useState("90"); + const [tokenCreating, setTokenCreating] = useState(false); + const [newToken, setNewToken] = useState(null); + const [tokenCopied, setTokenCopied] = useState(false); + const [tokenRevoking, setTokenRevoking] = useState(null); + + const loadTokens = useCallback(async () => { + try { + const list = await api.listPersonalAccessTokens(); + setTokens(list); + } catch { + // ignore — tokens section simply stays empty + } + }, []); + + useEffect(() => { loadTokens(); }, [loadTokens]); + + const handleCreateToken = async () => { + setTokenCreating(true); + try { + const expiresInDays = tokenExpiry === "never" ? undefined : Number(tokenExpiry); + const result = await api.createPersonalAccessToken({ name: tokenName, expires_in_days: expiresInDays }); + setNewToken(result.token); + setTokenName(""); + setTokenExpiry("90"); + await loadTokens(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to create token"); + } finally { + setTokenCreating(false); + } + }; + + const handleRevokeToken = async (id: string) => { + setTokenRevoking(id); + try { + await api.revokePersonalAccessToken(id); + await loadTokens(); + toast.success("Token revoked"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to revoke token"); + } finally { + setTokenRevoking(null); + } + }; + + const handleCopyToken = async () => { + if (!newToken) return; + await navigator.clipboard.writeText(newToken); + setTokenCopied(true); + setTimeout(() => setTokenCopied(false), 2000); + }; + + return ( +
+
+
+ +

API Tokens

+
+ + + +

+ Personal access tokens allow the CLI and external integrations to authenticate with your account. +

+
+ setTokenName(e.target.value)} + placeholder="Token name (e.g. My CLI)" + /> + + +
+
+
+ + {tokens.length > 0 && ( +
+ {tokens.map((t) => ( + + +
+
{t.name}
+
+ {t.token_prefix}... · Created {new Date(t.created_at).toLocaleDateString()} · {t.last_used_at ? `Last used ${new Date(t.last_used_at).toLocaleDateString()}` : "Never used"} + {t.expires_at && ` · Expires ${new Date(t.expires_at).toLocaleDateString()}`} +
+
+ + handleRevokeToken(t.id)} + disabled={tokenRevoking === t.id} + aria-label={`Revoke ${t.name}`} + > + + + } + /> + Revoke + +
+
+ ))} +
+ )} +
+ + { if (!v) { setNewToken(null); setTokenCopied(false); } }}> + + + Token created + + Copy your personal access token now. You won't be able to see it again. + + +
+ + {newToken} + + + + {tokenCopied ? : } + + } + /> + Copy token + +
+ + + +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx new file mode 100644 index 00000000..bdb2f78d --- /dev/null +++ b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Save, LogOut } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; +import { useAuthStore } from "@/features/auth"; +import { useWorkspaceStore } from "@/features/workspace"; +import { api } from "@/shared/api"; + +export function WorkspaceTab() { + const user = useAuthStore((s) => s.user); + const workspace = useWorkspaceStore((s) => s.workspace); + const members = useWorkspaceStore((s) => s.members); + const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace); + const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace); + const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace); + + const [name, setName] = useState(workspace?.name ?? ""); + const [description, setDescription] = useState(workspace?.description ?? ""); + const [context, setContext] = useState(workspace?.context ?? ""); + const [saving, setSaving] = useState(false); + const [actionId, setActionId] = useState(null); + const [confirmAction, setConfirmAction] = useState<{ + title: string; + description: string; + variant?: "destructive"; + onConfirm: () => Promise; + } | null>(null); + + const currentMember = members.find((m) => m.user_id === user?.id) ?? null; + const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin"; + const isOwner = currentMember?.role === "owner"; + + useEffect(() => { + setName(workspace?.name ?? ""); + setDescription(workspace?.description ?? ""); + setContext(workspace?.context ?? ""); + }, [workspace]); + + const handleSave = async () => { + if (!workspace) return; + setSaving(true); + try { + const updated = await api.updateWorkspace(workspace.id, { + name, + description, + context, + }); + updateWorkspace(updated); + toast.success("Workspace settings saved"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to save workspace settings"); + } finally { + setSaving(false); + } + }; + + const handleLeaveWorkspace = () => { + if (!workspace) return; + setConfirmAction({ + title: "Leave workspace", + description: `Leave ${workspace.name}? You will lose access until re-invited.`, + variant: "destructive", + onConfirm: async () => { + setActionId("leave"); + try { + await leaveWorkspace(workspace.id); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to leave workspace"); + } finally { + setActionId(null); + } + }, + }); + }; + + const handleDeleteWorkspace = () => { + if (!workspace) return; + setConfirmAction({ + title: "Delete workspace", + description: `Delete ${workspace.name}? This cannot be undone. All issues, agents, and data will be permanently removed.`, + variant: "destructive", + onConfirm: async () => { + setActionId("delete-workspace"); + try { + await deleteWorkspace(workspace.id); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to delete workspace"); + } finally { + setActionId(null); + } + }, + }); + }; + + if (!workspace) return null; + + return ( +
+ {/* Workspace settings */} +
+

General

+ + + +
+ + setName(e.target.value)} + disabled={!canManageWorkspace} + className="mt-1" + /> +
+
+ +