Skip to content

Commit 2d5a252

Browse files
committed
feat: add organization edit page
Signed-off-by: Gabor Boros <[email protected]>
1 parent 0a2f18d commit 2d5a252

File tree

17 files changed

+800
-141
lines changed

17 files changed

+800
-141
lines changed

assets/queries/bootstrap.cypher

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ ON CREATE SET r.created_at = datetime();
3030

3131
// ============================================================================
3232
// Create system roles to manage resources
33+
//
34+
// The users with system roles assigned are considered superusers. However,
35+
// they shouldn't access __ALL__ resources. Some resources may contain private
36+
// or sensitive data to the individual user. For example, a user may create a
37+
// TODO item for themselves that should not be visible to other users.
38+
//
39+
// All other resources are considered public within the platform and can be
40+
// accessed by all users (with necessary permissions).
3341
// ============================================================================
3442

3543
// Create roles
@@ -48,7 +56,6 @@ UNWIND [
4856
'Organization',
4957
'Project',
5058
'Role',
51-
'Todo',
5259
'User'
5360
] AS t
5461
UNWIND [
@@ -57,7 +64,8 @@ UNWIND [
5764
['Support', 'read', 'write']
5865
] AS bindings
5966
WITH t, bindings[0] AS role, bindings[1..] AS permissions
60-
MATCH (rt:ResourceType {id: t}), (r:Role {id: role})
67+
MATCH (rt:ResourceType {id: t})
68+
OPTIONAL MATCH (r:Role {id: role, system: true})
6169
WITH rt, r, permissions
6270
UNWIND permissions AS permission
6371
MERGE (r)-[p:HAS_PERMISSION {kind: permission}]->(rt)

web/src/components/auth/auth-provider.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useNavigate } from "@tanstack/react-router";
2-
import { useEffect, useReducer } from "react";
2+
import { useEffect, useReducer, useRef } from "react";
33
import type { ReactNode } from "react";
44

55
import { client, v1UserGet } from "@/lib/api";
@@ -20,6 +20,7 @@ import type {
2020
LoginCredentials,
2121
User,
2222
} from "@/lib/auth/types";
23+
import { queryClient } from "@/lib/query-client";
2324

2425
type AuthAction =
2526
| { type: "SET_LOADING"; payload: boolean }
@@ -74,6 +75,15 @@ interface AuthProviderProps {
7475
export function AuthProvider({ children }: AuthProviderProps) {
7576
const navigate = useNavigate();
7677
const [state, dispatch] = useReducer(authReducer, initialState);
78+
const prevIsAuthenticatedRef = useRef<boolean>(false);
79+
80+
// Clear query cache when authentication state transitions from authenticated to unauthenticated
81+
useEffect(() => {
82+
if (prevIsAuthenticatedRef.current && !state.isAuthenticated) {
83+
queryClient.clear();
84+
}
85+
prevIsAuthenticatedRef.current = state.isAuthenticated;
86+
}, [state.isAuthenticated]);
7787

7888
// Initialize auth state on mount
7989
useEffect(() => {
@@ -188,6 +198,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
188198

189199
const login = async (credentials: LoginCredentials): Promise<void> => {
190200
try {
201+
queryClient.clear();
191202
dispatch({ type: "SET_LOADING", payload: true });
192203
dispatch({ type: "SET_ERROR", payload: null });
193204

@@ -220,6 +231,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
220231

221232
// eslint-disable-next-line @typescript-eslint/require-await
222233
const logout = async (): Promise<void> => {
234+
queryClient.clear();
223235
tokenRefreshService.stopAutoRefresh();
224236
clearAllAuthData();
225237
dispatch({ type: "CLEAR_AUTH" });

web/src/components/notification/notification-item.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ export function NotificationItem({
2020
notification,
2121
onSuccess,
2222
}: NotificationItemProps) {
23+
const queryClient = useQueryClient();
24+
2325
const deleteMutation = useMutation({
2426
...v1NotificationDeleteMutation(),
2527
onSuccess: () => {
26-
useQueryClient().invalidateQueries({
28+
queryClient.invalidateQueries({
2729
queryKey: v1NotificationsGetOptions().queryKey,
2830
});
2931
showSuccessToast(

web/src/components/organizations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from "./organization-detail-header";
66
export * from "./organization-detail-info";
77
export * from "./organization-detail-field";
88
export * from "./organization-detail-skeleton";
9+
export * from "./organization-edit-form";
910
export * from "./organization-list";
1011
export * from "./organization-list-skeleton";
1112
export * from "./organization-members-list";

web/src/components/organizations/organization-detail-info.tsx

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import { Link } from "@tanstack/react-router";
2+
import { Edit } from "lucide-react";
3+
14
import { OrganizationDetailField } from "./organization-detail-field";
25

36
import { Badge } from "@/components/ui/badge";
7+
import { Button } from "@/components/ui/button";
48
import {
59
Card,
610
CardContent,
@@ -9,21 +13,48 @@ import {
913
CardTitle,
1014
} from "@/components/ui/card";
1115
import { ExternalLink } from "@/components/ui/external-link";
16+
import {
17+
ResourceType,
18+
usePermissions,
19+
withResourceType,
20+
} from "@/hooks/use-permissions";
1221
import type { Organization } from "@/lib/api";
22+
import { can } from "@/lib/auth/permissions";
1323
import { formatDate } from "@/lib/utils";
1424

1525
export function OrganizationDetailInfo({
1626
organization,
1727
}: {
1828
organization: Organization;
1929
}) {
30+
const { data: permissions } = usePermissions(
31+
withResourceType(ResourceType.Organization, organization.id)
32+
);
33+
34+
const hasWritePermission = can(permissions, "write");
35+
2036
return (
2137
<Card>
2238
<CardHeader>
23-
<CardTitle>Organization Information</CardTitle>
24-
<CardDescription>
25-
Details about the organization and its status.
26-
</CardDescription>
39+
<div className="flex items-start justify-between">
40+
<div>
41+
<CardTitle>Organization Information</CardTitle>
42+
<CardDescription>
43+
Details about the organization and its status.
44+
</CardDescription>
45+
</div>
46+
{hasWritePermission && (
47+
<Button variant="outline" size="sm" asChild>
48+
<Link
49+
to="/settings/organizations/$organizationId/edit"
50+
params={{ organizationId: organization.id }}
51+
>
52+
<Edit className="mr-2 size-4" />
53+
Edit
54+
</Link>
55+
</Button>
56+
)}
57+
</div>
2758
</CardHeader>
2859
<CardContent className="space-y-4">
2960
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { zodResolver } from "@hookform/resolvers/zod";
2+
import { useMutation } from "@tanstack/react-query";
3+
import { useNavigate } from "@tanstack/react-router";
4+
import { useEffect } from "react";
5+
import { useForm } from "react-hook-form";
6+
import type { z } from "zod";
7+
8+
import { Button } from "@/components/ui/button";
9+
import {
10+
Card,
11+
CardContent,
12+
CardDescription,
13+
CardHeader,
14+
CardTitle,
15+
} from "@/components/ui/card";
16+
import {
17+
Form,
18+
FormControl,
19+
FormField,
20+
FormItem,
21+
FormLabel,
22+
FormMessage,
23+
} from "@/components/ui/form";
24+
import { Input } from "@/components/ui/input";
25+
import { Spinner } from "@/components/ui/spinner";
26+
import type { Organization } from "@/lib/api";
27+
import { v1OrganizationUpdateMutation } from "@/lib/client/@tanstack/react-query.gen";
28+
import { zOrganizationPatch } from "@/lib/client/zod.gen";
29+
import { showErrorToast, showSuccessToast } from "@/lib/toast";
30+
31+
// Create a schema without logo and status fields for the form
32+
// TODO: Add logo field when implementing image upload
33+
const organizationEditFormSchema = zOrganizationPatch.omit({
34+
logo: true,
35+
status: true,
36+
});
37+
38+
type OrganizationEditFormValues = z.infer<typeof organizationEditFormSchema>;
39+
40+
interface OrganizationEditFormProps {
41+
organization: Organization;
42+
organizationId: string;
43+
}
44+
45+
export function OrganizationEditForm({
46+
organization,
47+
organizationId,
48+
}: OrganizationEditFormProps) {
49+
const navigate = useNavigate();
50+
51+
const form = useForm<OrganizationEditFormValues>({
52+
resolver: zodResolver(organizationEditFormSchema),
53+
defaultValues: {
54+
name: organization.name,
55+
email: organization.email,
56+
website: organization.website || undefined,
57+
},
58+
});
59+
60+
// Update form when organization data changes
61+
useEffect(() => {
62+
form.reset({
63+
name: organization.name,
64+
email: organization.email,
65+
website: organization.website || undefined,
66+
});
67+
}, [organization, form]);
68+
69+
const mutation = useMutation(v1OrganizationUpdateMutation());
70+
71+
const onSubmit = (values: OrganizationEditFormValues) => {
72+
const body: {
73+
name?: string;
74+
email?: string;
75+
website?: string;
76+
} = {};
77+
78+
// Only include fields that have changed or are explicitly provided
79+
if (values.name !== organization.name) {
80+
body.name = values.name;
81+
}
82+
if (values.email !== organization.email) {
83+
body.email = values.email;
84+
}
85+
if (values.website !== (organization.website || undefined)) {
86+
body.website = values.website;
87+
}
88+
89+
// If no changes, don't submit
90+
if (Object.keys(body).length === 0) {
91+
showErrorToast(
92+
"No changes",
93+
"Please make changes to the organization before saving."
94+
);
95+
return;
96+
}
97+
98+
mutation.mutate(
99+
{
100+
path: {
101+
id: organizationId,
102+
},
103+
body,
104+
},
105+
{
106+
onSuccess: () => {
107+
showSuccessToast(
108+
"Organization updated",
109+
"Organization updated successfully"
110+
);
111+
navigate({
112+
to: "/settings/organizations/$organizationId",
113+
params: { organizationId },
114+
});
115+
},
116+
onError: (error) => {
117+
showErrorToast("Failed to update organization", error.message);
118+
},
119+
}
120+
);
121+
};
122+
123+
return (
124+
<Card>
125+
<CardHeader>
126+
<CardTitle>Edit Organization</CardTitle>
127+
<CardDescription>
128+
Update the organization details below.
129+
</CardDescription>
130+
</CardHeader>
131+
<CardContent>
132+
<Form {...form}>
133+
<form
134+
onSubmit={form.handleSubmit(onSubmit)}
135+
className="flex flex-col gap-y-6"
136+
>
137+
{mutation.isError && (
138+
<div className="text-destructive text-sm">
139+
<p>{mutation.error.message}</p>
140+
</div>
141+
)}
142+
143+
<FormField
144+
control={form.control}
145+
name="name"
146+
render={({ field }) => (
147+
<FormItem>
148+
<FormLabel>Name</FormLabel>
149+
<FormControl>
150+
<Input placeholder="Enter organization name" {...field} />
151+
</FormControl>
152+
<FormMessage />
153+
</FormItem>
154+
)}
155+
/>
156+
157+
<FormField
158+
control={form.control}
159+
name="email"
160+
render={({ field }) => (
161+
<FormItem>
162+
<FormLabel>Email</FormLabel>
163+
<FormControl>
164+
<Input
165+
type="email"
166+
placeholder="Enter organization email"
167+
{...field}
168+
/>
169+
</FormControl>
170+
<FormMessage />
171+
</FormItem>
172+
)}
173+
/>
174+
175+
<FormField
176+
control={form.control}
177+
name="website"
178+
render={({ field }) => (
179+
<FormItem>
180+
<FormLabel>Website</FormLabel>
181+
<FormControl>
182+
<Input
183+
type="url"
184+
placeholder="https://example.com (optional)"
185+
value={field.value || ""}
186+
onChange={(e) => {
187+
const value = e.target.value;
188+
field.onChange(value === "" ? undefined : value);
189+
}}
190+
onBlur={field.onBlur}
191+
name={field.name}
192+
/>
193+
</FormControl>
194+
<FormMessage />
195+
</FormItem>
196+
)}
197+
/>
198+
199+
<div className="flex justify-end gap-2">
200+
<Button
201+
type="button"
202+
variant="outline"
203+
onClick={() =>
204+
navigate({
205+
to: "/settings/organizations/$organizationId",
206+
params: { organizationId },
207+
})
208+
}
209+
disabled={mutation.isPending}
210+
>
211+
Cancel
212+
</Button>
213+
<Button type="submit" disabled={mutation.isPending}>
214+
{mutation.isPending ? (
215+
<>
216+
<Spinner size="xs" className="mr-0.5 text-white" />
217+
<span>Saving...</span>
218+
</>
219+
) : (
220+
"Save Changes"
221+
)}
222+
</Button>
223+
</div>
224+
</form>
225+
</Form>
226+
</CardContent>
227+
</Card>
228+
);
229+
}

0 commit comments

Comments
 (0)