Skip to content

Commit 86f1ce6

Browse files
added user roles, migrations, app_user junction table (#203)
* added user roles, migrations, app_user junction table - role can be owner or app_admin - only owner can create, modify delete users - check if the user with role app_admin has app access in floware_proxy_service - removed super_admin env variable and also from readme * modified FE for app_users management * resolved comments * resolved comments * resolved minor comments
1 parent 42c9543 commit 86f1ce6

27 files changed

+883
-97
lines changed

DOCKER_SETUP.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,6 @@ Some services require credential files (JSON files for GCP, OAuth, etc.). Follow
275275
| `CONSOLE_JWT_ISSUER` | JWT issuer URL for console |
276276
| `CONSOLE_JWT_AUDIENCE` | JWT audience URL for console |
277277
| `CONSOLE_TOKEN_PREFIX` | Token prefix for console tokens (default: `fc_`) |
278-
| `SUPER_ADMIN_EMAIL` | Super admin email (usually same as `CONSOLE_EMAIL`) |
279278
| `TOKEN_EXPIRY` | Token expiration in seconds (default: `3600`) |
280279
| `TEMPORARY_TOKEN_EXPIRY` | Temporary token expiration (default: `600`) |
281280
| `PRIVATE_KEY` | Base64-encoded RSA private key (can be different from floware) |

docker-compose.sample.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,6 @@ services:
311311
- CONSOLE_JWT_ISSUER=<YOUR_CONSOLE_JWT_ISSUER>
312312
- CONSOLE_JWT_AUDIENCE=<YOUR_CONSOLE_JWT_AUDIENCE>
313313
- CONSOLE_TOKEN_PREFIX=<YOUR_CONSOLE_TOKEN_PREFIX>
314-
- SUPER_ADMIN_EMAIL=<YOUR_SUPER_ADMIN_EMAIL>
315314
- TOKEN_EXPIRY=<YOUR_TOKEN_EXPIRY>
316315
- TEMPORARY_TOKEN_EXPIRY=<YOUR_TEMPORARY_TOKEN_EXPIRY>
317316
- PRIVATE_KEY=<YOUR_BASE64_ENCODED_PRIVATE_KEY>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { IApiResponse } from '@app/lib/axios';
2+
import { IUser } from '@app/types/user';
3+
import { AxiosInstance } from 'axios';
4+
5+
export class AppUserService {
6+
constructor(private http: AxiosInstance) {}
7+
8+
/**
9+
* Grant user access to an app (owners only)
10+
*/
11+
async grantAppAccess(appId: string, userId: string): Promise<IApiResponse<{ message: string }>> {
12+
return this.http.post(`/v1/apps/${appId}/users/${userId}`);
13+
}
14+
15+
/**
16+
* Revoke user access from an app (owners only)
17+
*/
18+
async revokeAppAccess(appId: string, userId: string): Promise<IApiResponse<{ message: string }>> {
19+
return this.http.delete(`/v1/apps/${appId}/users/${userId}`);
20+
}
21+
22+
/**
23+
* List users with access to an app (owners only)
24+
*/
25+
async listAppUsers(appId: string): Promise<IApiResponse<{ users: IUser[] }>> {
26+
return this.http.get(`/v1/apps/${appId}/users`);
27+
}
28+
29+
/**
30+
* List apps accessible to a user (owners only)
31+
*/
32+
async listUserApps(userId: string): Promise<IApiResponse<{ app_ids: string[] }>> {
33+
return this.http.get(`/v1/users/${userId}/apps`);
34+
}
35+
}

wavefront/client/src/api/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AxiosInstance } from 'axios';
33
import { AgentService } from './agent-service';
44
import { ApiServiceService } from './api-service-service';
55
import { AppService } from './app-service';
6+
import { AppUserService } from './app-user-service';
67
import { AuthenticatorService } from './authenticator-service';
78
import { ConsoleAuthService } from './console-auth-service';
89
import { DataPipelineService } from './data-pipeline-service';
@@ -39,6 +40,10 @@ class FloConsoleService {
3940
return new AppService(this.http);
4041
}
4142

43+
get appUserService() {
44+
return new AppUserService(this.http);
45+
}
46+
4247
get authenticatorService() {
4348
return new AuthenticatorService(this.http);
4449
}

wavefront/client/src/api/user-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,14 @@ export class UserService {
3333
password: string;
3434
first_name: string;
3535
last_name: string;
36+
role?: string;
3637
}): Promise<IApiResponse<{ user: IUser }>> {
3738
return this.http.post('/v1/users', data);
3839
}
3940

4041
async updateUser(
4142
userId: string,
4243
data: {
43-
email?: string;
4444
password?: string;
4545
first_name?: string;
4646
last_name?: string;

wavefront/client/src/pages/apps/users/CreateUserDialog.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,17 @@ import {
88
DialogHeader,
99
DialogTitle,
1010
} from '@app/components/ui/dialog';
11-
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@app/components/ui/form';
11+
import {
12+
Form,
13+
FormControl,
14+
FormDescription,
15+
FormField,
16+
FormItem,
17+
FormLabel,
18+
FormMessage,
19+
} from '@app/components/ui/form';
1220
import { Input } from '@app/components/ui/input';
21+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@app/components/ui/select';
1322
import { extractErrorMessage } from '@app/lib/utils';
1423
import { useNotifyStore } from '@app/store';
1524
import { CreateUserInput, createUserSchema } from '@app/types/user';
@@ -33,6 +42,7 @@ const CreateUserDialog: React.FC<CreateUserDialogProps> = ({ isOpen, onOpenChang
3342
password: '',
3443
first_name: '',
3544
last_name: '',
45+
role: 'app_admin',
3646
},
3747
});
3848

@@ -129,6 +139,29 @@ const CreateUserDialog: React.FC<CreateUserDialogProps> = ({ isOpen, onOpenChang
129139
)}
130140
/>
131141

142+
<FormField
143+
control={form.control}
144+
name="role"
145+
render={({ field }) => (
146+
<FormItem>
147+
<FormLabel>Role</FormLabel>
148+
<Select onValueChange={field.onChange} value={field.value}>
149+
<FormControl>
150+
<SelectTrigger>
151+
<SelectValue placeholder="Select a role" />
152+
</SelectTrigger>
153+
</FormControl>
154+
<SelectContent>
155+
<SelectItem value="app_admin">App Admin</SelectItem>
156+
<SelectItem value="owner">Owner</SelectItem>
157+
</SelectContent>
158+
</Select>
159+
<FormDescription>Default role is App Admin</FormDescription>
160+
<FormMessage />
161+
</FormItem>
162+
)}
163+
/>
164+
132165
<DialogFooter>
133166
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
134167
Cancel

wavefront/client/src/pages/apps/users/EditUserDialog.tsx

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ const EditUserDialog: React.FC<EditUserDialogProps> = ({ isOpen, onOpenChange, u
3838
const form = useForm<UpdateUserInput>({
3939
resolver: zodResolver(updateUserSchema),
4040
defaultValues: {
41-
email: user.email,
4241
password: '',
4342
first_name: user.first_name,
4443
last_name: user.last_name,
@@ -49,7 +48,6 @@ const EditUserDialog: React.FC<EditUserDialogProps> = ({ isOpen, onOpenChange, u
4948
useEffect(() => {
5049
if (isOpen && user) {
5150
form.reset({
52-
email: user.email,
5351
password: '',
5452
first_name: user.first_name,
5553
last_name: user.last_name,
@@ -61,15 +59,11 @@ const EditUserDialog: React.FC<EditUserDialogProps> = ({ isOpen, onOpenChange, u
6159
try {
6260
// Filter out empty values
6361
const updateData: {
64-
email?: string;
6562
password?: string;
6663
first_name?: string;
6764
last_name?: string;
6865
} = {};
6966

70-
if (data.email && data.email !== user.email) {
71-
updateData.email = data.email;
72-
}
7367
if (data.password && data.password.trim()) {
7468
updateData.password = data.password;
7569
}
@@ -105,20 +99,6 @@ const EditUserDialog: React.FC<EditUserDialogProps> = ({ isOpen, onOpenChange, u
10599

106100
<Form {...form}>
107101
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
108-
<FormField
109-
control={form.control}
110-
name="email"
111-
render={({ field }) => (
112-
<FormItem>
113-
<FormLabel>Email</FormLabel>
114-
<FormControl>
115-
<Input type="email" placeholder="user@example.com" {...field} />
116-
</FormControl>
117-
<FormMessage />
118-
</FormItem>
119-
)}
120-
/>
121-
122102
<FormField
123103
control={form.control}
124104
name="password"
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import floConsoleService from '@app/api';
2+
import { Button } from '@app/components/ui/button';
3+
import {
4+
Dialog,
5+
DialogContent,
6+
DialogDescription,
7+
DialogFooter,
8+
DialogHeader,
9+
DialogTitle,
10+
} from '@app/components/ui/dialog';
11+
import { Checkbox } from '@app/components/ui/checkbox';
12+
import { extractErrorMessage } from '@app/lib/utils';
13+
import { useNotifyStore } from '@app/store';
14+
import { IUser } from '@app/types/user';
15+
import { App } from '@app/types/app';
16+
import React, { useEffect, useState } from 'react';
17+
import { useGetAllApps } from '@app/hooks';
18+
19+
interface ManageAppAccessDialogProps {
20+
isOpen: boolean;
21+
onOpenChange: (open: boolean) => void;
22+
user: IUser | null;
23+
onSuccess?: () => void;
24+
}
25+
26+
const ManageAppAccessDialog: React.FC<ManageAppAccessDialogProps> = ({ isOpen, onOpenChange, user, onSuccess }) => {
27+
const { notifySuccess, notifyError } = useNotifyStore();
28+
const { data: allApps = [] } = useGetAllApps(true);
29+
30+
const [userAppIds, setUserAppIds] = useState<string[]>([]);
31+
const [loading, setLoading] = useState(false);
32+
const [saving, setSaving] = useState(false);
33+
34+
// Fetch user's apps when dialog opens
35+
useEffect(() => {
36+
const fetchUserApps = async () => {
37+
if (!user || !isOpen) return;
38+
39+
setLoading(true);
40+
try {
41+
const response = await floConsoleService.appUserService.listUserApps(user.id);
42+
setUserAppIds(response.data.data?.app_ids || []);
43+
} catch (error) {
44+
const errorMessage = extractErrorMessage(error);
45+
notifyError(errorMessage || 'Failed to load user apps');
46+
} finally {
47+
setLoading(false);
48+
}
49+
};
50+
51+
fetchUserApps();
52+
}, [user, isOpen, notifyError]);
53+
54+
const handleToggleApp = (appId: string, checked: boolean) => {
55+
if (checked) {
56+
setUserAppIds([...userAppIds, appId]);
57+
} else {
58+
setUserAppIds(userAppIds.filter((id) => id !== appId));
59+
}
60+
};
61+
62+
const handleSave = async () => {
63+
if (!user) return;
64+
65+
setSaving(true);
66+
try {
67+
// Get apps to grant and revoke
68+
const currentAppIds = new Set(userAppIds);
69+
const previousAppIds = new Set<string>();
70+
71+
// Fetch current state again to compare
72+
const response = await floConsoleService.appUserService.listUserApps(user.id);
73+
response.data.data?.app_ids?.forEach((id) => previousAppIds.add(id));
74+
75+
// Grant access to new apps
76+
const appsToGrant = Array.from(currentAppIds).filter((id) => !previousAppIds.has(id));
77+
for (const appId of appsToGrant) {
78+
await floConsoleService.appUserService.grantAppAccess(appId, user.id);
79+
}
80+
81+
// Revoke access from removed apps
82+
const appsToRevoke = Array.from(previousAppIds).filter((id) => !currentAppIds.has(id));
83+
for (const appId of appsToRevoke) {
84+
await floConsoleService.appUserService.revokeAppAccess(appId, user.id);
85+
}
86+
87+
notifySuccess('App access updated successfully');
88+
onSuccess?.();
89+
onOpenChange(false);
90+
} catch (error) {
91+
const errorMessage = extractErrorMessage(error);
92+
notifyError(errorMessage || 'Failed to update app access');
93+
} finally {
94+
setSaving(false);
95+
}
96+
};
97+
98+
return (
99+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
100+
<DialogContent className="max-w-md">
101+
<DialogHeader>
102+
<DialogTitle>Manage App Access</DialogTitle>
103+
<DialogDescription>Select which apps {user?.email} can access</DialogDescription>
104+
</DialogHeader>
105+
106+
<div className="max-h-[400px] space-y-3 overflow-y-auto py-4">
107+
{loading ? (
108+
<div className="text-center text-gray-500">Loading apps...</div>
109+
) : allApps.length === 0 ? (
110+
<div className="text-center text-gray-500">No apps available</div>
111+
) : (
112+
allApps.map((app: App) => (
113+
<div key={app.id} className="flex items-center space-x-3 rounded p-2 hover:bg-gray-50">
114+
<Checkbox
115+
id={`app-${app.id}`}
116+
checked={userAppIds.includes(app.id)}
117+
onCheckedChange={(checked) => handleToggleApp(app.id, checked as boolean)}
118+
/>
119+
<label
120+
htmlFor={`app-${app.id}`}
121+
className="flex-1 cursor-pointer text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
122+
>
123+
{app.app_name}
124+
</label>
125+
</div>
126+
))
127+
)}
128+
</div>
129+
130+
<DialogFooter>
131+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
132+
Cancel
133+
</Button>
134+
<Button type="button" onClick={handleSave} loading={saving}>
135+
Save Changes
136+
</Button>
137+
</DialogFooter>
138+
</DialogContent>
139+
</Dialog>
140+
);
141+
};
142+
143+
export default ManageAppAccessDialog;

0 commit comments

Comments
 (0)