Skip to content

Commit 593fd8f

Browse files
committed
add settings page for workspace
1 parent c01e9f2 commit 593fd8f

File tree

16 files changed

+461
-26
lines changed

16 files changed

+461
-26
lines changed

apps/web/app/root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { Route } from "./+types/root";
1212
import "./app.css";
1313

1414
import { TRPCReactProvider } from "./api/trpc";
15+
import { Toaster } from "./components/ui/sonner";
1516

1617
export const links: Route.LinksFunction = () => [
1718
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
@@ -38,6 +39,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
3839
</head>
3940
<body suppressHydrationWarning>
4041
{children}
42+
<Toaster />
4143
<ScrollRestoration />
4244
<Scripts />
4345
</body>

apps/web/app/routes.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,30 @@ export default [
1515
"deployments/:deploymentId/versions",
1616
"routes/ws/deployments/page.$deploymentId.versions.tsx",
1717
),
18+
19+
route(
20+
"deployments/:deploymentId/settings",
21+
"routes/ws/deployments/settings/_layout.tsx",
22+
[
23+
route(
24+
"settings",
25+
"routes/ws/deployments/settings/page.$deploymentId.settings.tsx",
26+
),
27+
],
28+
),
29+
1830
route("resources", "routes/ws/resources.tsx"),
1931
route("relationship-rules", "routes/ws/relationship-rules.tsx"),
2032
route("projects", "routes/ws/projects.tsx"),
2133
route("runners", "routes/ws/runners.tsx"),
2234
route("providers", "routes/ws/providers.tsx"),
2335
route("policies", "routes/ws/policies.tsx"),
36+
37+
route("settings", "routes/ws/settings/_layout.tsx", [
38+
route("general", "routes/ws/settings/general.tsx"),
39+
route("members", "routes/ws/settings/members.tsx"),
40+
route("api-keys", "routes/ws/settings/api-keys.tsx"),
41+
]),
2442
]),
2543
]),
2644
route("login", "routes/auth/login.tsx"),

apps/web/app/routes/ws/_components/WorkspaceSelector.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ export const WorkspaceSelector: React.FC<{
3737
</Button>
3838
</DropdownMenuTrigger>
3939
<DropdownMenuContent align="start" className="w-56">
40-
<NavLink to={`/workspaces/${workspace.slug}/settings`}>
40+
<NavLink to={`/${workspace.slug}/settings/general`}>
4141
<DropdownMenuItem>Workspace settings</DropdownMenuItem>
4242
</NavLink>
43-
<NavLink to={`/workspaces/${workspace.slug}/members`}>
43+
<NavLink to={`/${workspace.slug}/settings/members`}>
4444
<DropdownMenuItem>Invite and manage users</DropdownMenuItem>
4545
</NavLink>
4646

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Link } from "react-router";
2+
3+
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
4+
import { useWorkspace } from "~/components/WorkspaceProvider";
5+
6+
export const DeploymentsNavbarTabs = ({
7+
deploymentId,
8+
}: {
9+
deploymentId: string;
10+
}) => {
11+
const { workspace } = useWorkspace();
12+
return (
13+
<Tabs value="environments">
14+
<TabsList>
15+
<TabsTrigger value="environments" asChild>
16+
<Link to={`/${workspace.slug}/deployments/${deploymentId}`}>
17+
Environments
18+
</Link>
19+
</TabsTrigger>
20+
<TabsTrigger value="versions" asChild>
21+
<Link to={`/${workspace.slug}/deployments/${deploymentId}/versions`}>
22+
Versions
23+
</Link>
24+
</TabsTrigger>
25+
<TabsTrigger value="activity" asChild>
26+
<Link to={`/${workspace.slug}/deployments/${deploymentId}/activity`}>
27+
Activity
28+
</Link>
29+
</TabsTrigger>
30+
<TabsTrigger value="activity" asChild>
31+
<Link to={`/${workspace.slug}/deployments/${deploymentId}/settings`}>
32+
Settings
33+
</Link>
34+
</TabsTrigger>
35+
</TabsList>
36+
</Tabs>
37+
);
38+
};

apps/web/app/routes/ws/deployments/page.$deploymentId.tsx

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import {
1212
import { ResizablePanel, ResizablePanelGroup } from "~/components/ui/resizable";
1313
import { Separator } from "~/components/ui/separator";
1414
import { SidebarTrigger } from "~/components/ui/sidebar";
15-
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
1615
import { DeploymentFlow } from "./_components/DeploymentFlow";
16+
import { DeploymentsNavbarTabs } from "./_components/DeploymentsNavbarTabs";
1717
import { EnvironmentActionsPanel } from "./_components/EnvironmentActionsPanel";
1818
import { mockDeploymentDetail, mockEnvironments } from "./_components/mockData";
1919
import { VersionActionsPanel } from "./_components/VersionActionsPanel";
@@ -216,23 +216,7 @@ export default function DeploymentDetail() {
216216
</div>
217217

218218
<div className="flex items-center gap-4">
219-
<Tabs value="environments">
220-
<TabsList>
221-
<TabsTrigger value="environments" asChild>
222-
<Link to={`/deployments/${deployment.id}`}>Environments</Link>
223-
</TabsTrigger>
224-
<TabsTrigger value="versions" asChild>
225-
<Link to={`/deployments/${deployment.id}/versions`}>
226-
Versions
227-
</Link>
228-
</TabsTrigger>
229-
<TabsTrigger value="activity" asChild>
230-
<Link to={`/deployments/${deployment.id}/activity`}>
231-
Activity
232-
</Link>
233-
</TabsTrigger>
234-
</TabsList>
235-
</Tabs>
219+
<DeploymentsNavbarTabs deploymentId={deployment.id} />
236220
</div>
237221
</header>
238222

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function DeploymentsSettingsLayout() {
2+
return <div>Settings</div>;
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function DeploymentsSettingsPage() {
2+
return <div>Settings</div>;
3+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
2+
3+
import { buttonVariants } from "~/components/ui/button";
4+
import { useWorkspace } from "~/components/WorkspaceProvider";
5+
6+
export default function SettingsLayout() {
7+
const { workspace } = useWorkspace();
8+
const path = useLocation();
9+
const navigate = useNavigate();
10+
11+
if (path.pathname === `/${workspace.slug}/settings`) {
12+
navigate(`/${workspace.slug}/settings/general`);
13+
}
14+
15+
const isActive = (pathname: string) => path.pathname.startsWith(pathname);
16+
17+
const defaultLinkStyle = buttonVariants({
18+
variant: "ghost",
19+
className: "w-full justify-start text-muted-foreground",
20+
});
21+
22+
const activeLinkStyle = buttonVariants({
23+
variant: "ghost",
24+
className: "w-full justify-start bg-muted text-primary",
25+
});
26+
27+
return (
28+
<div className="container mx-auto flex max-w-6xl gap-8 py-20">
29+
<div className="flex flex-shrink-0 flex-col gap-2">
30+
<NavLink
31+
to={`/${workspace.slug}/settings/general`}
32+
className={
33+
isActive(`/${workspace.slug}/settings/general`)
34+
? activeLinkStyle
35+
: defaultLinkStyle
36+
}
37+
>
38+
General
39+
</NavLink>
40+
<NavLink
41+
to={`/${workspace.slug}/settings/members`}
42+
className={
43+
isActive(`/${workspace.slug}/settings/members`)
44+
? activeLinkStyle
45+
: defaultLinkStyle
46+
}
47+
>
48+
Members
49+
</NavLink>
50+
<NavLink
51+
to={`/${workspace.slug}/settings/api-keys`}
52+
className={
53+
isActive(`/${workspace.slug}/settings/api-keys`)
54+
? activeLinkStyle
55+
: defaultLinkStyle
56+
}
57+
>
58+
API Keys
59+
</NavLink>
60+
<NavLink
61+
to={`/${workspace.slug}/settings/delete-workspace`}
62+
className={
63+
isActive(`/${workspace.slug}/settings/delete-workspace`)
64+
? activeLinkStyle
65+
: defaultLinkStyle
66+
}
67+
>
68+
Delete Workspace
69+
</NavLink>
70+
</div>
71+
<div className="flex-grow">
72+
<Outlet />
73+
</div>
74+
</div>
75+
);
76+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function ApiKeysSettingsPage() {
2+
return <div>API Keys</div>;
3+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { useState } from "react";
2+
import { toast } from "sonner";
3+
4+
import { trpc } from "~/api/trpc";
5+
import { Button } from "~/components/ui/button";
6+
import {
7+
Card,
8+
CardContent,
9+
CardDescription,
10+
CardHeader,
11+
CardTitle,
12+
} from "~/components/ui/card";
13+
import {
14+
Field,
15+
FieldContent,
16+
FieldDescription,
17+
FieldError,
18+
FieldGroup,
19+
FieldLabel,
20+
} from "~/components/ui/field";
21+
import { Input } from "~/components/ui/input";
22+
import { useWorkspace } from "~/components/WorkspaceProvider";
23+
24+
export default function GeneralSettingsPage() {
25+
const { workspace } = useWorkspace();
26+
const [name, setName] = useState(workspace.name);
27+
const [slug, setSlug] = useState(workspace.slug);
28+
const [errors, setErrors] = useState<{
29+
name?: string;
30+
slug?: string;
31+
}>({});
32+
33+
const utils = trpc.useUtils();
34+
const updateWorkspace = trpc.workspace.update.useMutation({
35+
onSuccess: () => {
36+
toast.success("Workspace updated successfully");
37+
// Invalidate the user session to refetch the updated workspace info
38+
void utils.user.session.invalidate();
39+
},
40+
onError: (error: unknown) => {
41+
const message =
42+
error instanceof Error ? error.message : "Failed to update workspace";
43+
toast.error(message);
44+
},
45+
});
46+
47+
const validateForm = () => {
48+
const newErrors: { name?: string; slug?: string } = {};
49+
50+
if (!name || name.trim().length === 0) {
51+
newErrors.name = "Workspace name is required";
52+
} else if (name.length > 100) {
53+
newErrors.name = "Workspace name must be less than 100 characters";
54+
}
55+
56+
if (!slug || slug.trim().length === 0) {
57+
newErrors.slug = "Workspace slug is required";
58+
} else if (!/^[a-z0-9-]+$/.test(slug)) {
59+
newErrors.slug =
60+
"Workspace slug can only contain lowercase letters, numbers, and hyphens";
61+
} else if (slug.length < 3) {
62+
newErrors.slug = "Workspace slug must be at least 3 characters";
63+
} else if (slug.length > 50) {
64+
newErrors.slug = "Workspace slug must be less than 50 characters";
65+
}
66+
67+
setErrors(newErrors);
68+
return Object.keys(newErrors).length === 0;
69+
};
70+
71+
const handleSubmit = (e: React.FormEvent) => {
72+
e.preventDefault();
73+
74+
if (!validateForm()) {
75+
return;
76+
}
77+
78+
const data: { name?: string; slug?: string } = {};
79+
if (name !== workspace.name) data.name = name;
80+
if (slug !== workspace.slug) data.slug = slug;
81+
82+
updateWorkspace.mutate({
83+
workspaceId: workspace.id,
84+
data,
85+
});
86+
};
87+
88+
const hasChanges = name !== workspace.name || slug !== workspace.slug;
89+
90+
return (
91+
<div className="space-y-6">
92+
<div>
93+
<h1 className="text-2xl font-bold">General Settings</h1>
94+
<p className="mt-1 text-sm text-muted-foreground">
95+
Manage your workspace settings
96+
</p>
97+
</div>
98+
99+
<Card>
100+
<CardHeader>
101+
<CardTitle>Workspace Information</CardTitle>
102+
<CardDescription>
103+
Update your workspace name and URL slug
104+
</CardDescription>
105+
</CardHeader>
106+
<CardContent>
107+
<form onSubmit={handleSubmit}>
108+
<FieldGroup>
109+
<Field>
110+
<FieldLabel htmlFor="workspace-name">Workspace Name</FieldLabel>
111+
<FieldContent>
112+
<Input
113+
id="workspace-name"
114+
type="text"
115+
value={name}
116+
onChange={(e) => setName(e.target.value)}
117+
placeholder="My Workspace"
118+
aria-invalid={!!errors.name}
119+
/>
120+
<FieldDescription>
121+
The display name for your workspace
122+
</FieldDescription>
123+
{errors.name && <FieldError>{errors.name}</FieldError>}
124+
</FieldContent>
125+
</Field>
126+
127+
<Field>
128+
<FieldLabel htmlFor="workspace-slug">Workspace Slug</FieldLabel>
129+
<FieldContent>
130+
<Input
131+
id="workspace-slug"
132+
type="text"
133+
value={slug}
134+
onChange={(e) => setSlug(e.target.value.toLowerCase())}
135+
placeholder="my-workspace"
136+
aria-invalid={!!errors.slug}
137+
/>
138+
<FieldDescription>
139+
The URL-friendly identifier for your workspace. Used in URLs
140+
like /{slug}/...
141+
</FieldDescription>
142+
{errors.slug && <FieldError>{errors.slug}</FieldError>}
143+
</FieldContent>
144+
</Field>
145+
146+
<div className="flex justify-end gap-3 pt-2">
147+
<Button
148+
type="button"
149+
variant="outline"
150+
onClick={() => {
151+
setName(workspace.name);
152+
setSlug(workspace.slug);
153+
setErrors({});
154+
}}
155+
disabled={!hasChanges || updateWorkspace.isPending}
156+
>
157+
Reset
158+
</Button>
159+
<Button
160+
type="submit"
161+
disabled={!hasChanges || updateWorkspace.isPending}
162+
>
163+
{updateWorkspace.isPending ? "Saving..." : "Save Changes"}
164+
</Button>
165+
</div>
166+
</FieldGroup>
167+
</form>
168+
</CardContent>
169+
</Card>
170+
</div>
171+
);
172+
}

0 commit comments

Comments
 (0)