Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion DOCKER_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,6 @@ Some services require credential files (JSON files for GCP, OAuth, etc.). Follow
| `CONSOLE_JWT_ISSUER` | JWT issuer URL for console |
| `CONSOLE_JWT_AUDIENCE` | JWT audience URL for console |
| `CONSOLE_TOKEN_PREFIX` | Token prefix for console tokens (default: `fc_`) |
| `SUPER_ADMIN_EMAIL` | Super admin email (usually same as `CONSOLE_EMAIL`) |
| `TOKEN_EXPIRY` | Token expiration in seconds (default: `3600`) |
| `TEMPORARY_TOKEN_EXPIRY` | Temporary token expiration (default: `600`) |
| `PRIVATE_KEY` | Base64-encoded RSA private key (can be different from floware) |
Expand Down
1 change: 0 additions & 1 deletion docker-compose.sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,6 @@ services:
- CONSOLE_JWT_ISSUER=<YOUR_CONSOLE_JWT_ISSUER>
- CONSOLE_JWT_AUDIENCE=<YOUR_CONSOLE_JWT_AUDIENCE>
- CONSOLE_TOKEN_PREFIX=<YOUR_CONSOLE_TOKEN_PREFIX>
- SUPER_ADMIN_EMAIL=<YOUR_SUPER_ADMIN_EMAIL>
- TOKEN_EXPIRY=<YOUR_TOKEN_EXPIRY>
- TEMPORARY_TOKEN_EXPIRY=<YOUR_TEMPORARY_TOKEN_EXPIRY>
- PRIVATE_KEY=<YOUR_BASE64_ENCODED_PRIVATE_KEY>
Expand Down
35 changes: 35 additions & 0 deletions wavefront/client/src/api/app-user-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { IApiResponse } from '@app/lib/axios';
import { IUser } from '@app/types/user';
import { AxiosInstance } from 'axios';

export class AppUserService {
constructor(private http: AxiosInstance) {}

/**
* Grant user access to an app (owners only)
*/
async grantAppAccess(appId: string, userId: string): Promise<IApiResponse<{ message: string }>> {
return this.http.post(`/v1/apps/${appId}/users/${userId}`);
}

/**
* Revoke user access from an app (owners only)
*/
async revokeAppAccess(appId: string, userId: string): Promise<IApiResponse<{ message: string }>> {
return this.http.delete(`/v1/apps/${appId}/users/${userId}`);
}

/**
* List users with access to an app (owners only)
*/
async listAppUsers(appId: string): Promise<IApiResponse<{ users: IUser[] }>> {
return this.http.get(`/v1/apps/${appId}/users`);
}

/**
* List apps accessible to a user (owners only)
*/
async listUserApps(userId: string): Promise<IApiResponse<{ app_ids: string[] }>> {
return this.http.get(`/v1/users/${userId}/apps`);
}
}
5 changes: 5 additions & 0 deletions wavefront/client/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AxiosInstance } from 'axios';
import { AgentService } from './agent-service';
import { ApiServiceService } from './api-service-service';
import { AppService } from './app-service';
import { AppUserService } from './app-user-service';
import { AuthenticatorService } from './authenticator-service';
import { ConsoleAuthService } from './console-auth-service';
import { DataPipelineService } from './data-pipeline-service';
Expand Down Expand Up @@ -39,6 +40,10 @@ class FloConsoleService {
return new AppService(this.http);
}

get appUserService() {
return new AppUserService(this.http);
}

get authenticatorService() {
return new AuthenticatorService(this.http);
}
Expand Down
2 changes: 1 addition & 1 deletion wavefront/client/src/api/user-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ export class UserService {
password: string;
first_name: string;
last_name: string;
role?: string;
}): Promise<IApiResponse<{ user: IUser }>> {
return this.http.post('/v1/users', data);
}

async updateUser(
userId: string,
data: {
email?: string;
password?: string;
first_name?: string;
last_name?: string;
Expand Down
35 changes: 34 additions & 1 deletion wavefront/client/src/pages/apps/users/CreateUserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,17 @@ import {
DialogHeader,
DialogTitle,
} from '@app/components/ui/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@app/components/ui/form';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@app/components/ui/form';
import { Input } from '@app/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@app/components/ui/select';
import { extractErrorMessage } from '@app/lib/utils';
import { useNotifyStore } from '@app/store';
import { CreateUserInput, createUserSchema } from '@app/types/user';
Expand All @@ -33,6 +42,7 @@ const CreateUserDialog: React.FC<CreateUserDialogProps> = ({ isOpen, onOpenChang
password: '',
first_name: '',
last_name: '',
role: 'app_admin',
},
});

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

<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="app_admin">App Admin</SelectItem>
<SelectItem value="owner">Owner</SelectItem>
</SelectContent>
</Select>
<FormDescription>Default role is App Admin</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
Expand Down
20 changes: 0 additions & 20 deletions wavefront/client/src/pages/apps/users/EditUserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ const EditUserDialog: React.FC<EditUserDialogProps> = ({ isOpen, onOpenChange, u
const form = useForm<UpdateUserInput>({
resolver: zodResolver(updateUserSchema),
defaultValues: {
email: user.email,
password: '',
first_name: user.first_name,
last_name: user.last_name,
Expand All @@ -49,7 +48,6 @@ const EditUserDialog: React.FC<EditUserDialogProps> = ({ isOpen, onOpenChange, u
useEffect(() => {
if (isOpen && user) {
form.reset({
email: user.email,
password: '',
first_name: user.first_name,
last_name: user.last_name,
Expand All @@ -61,15 +59,11 @@ const EditUserDialog: React.FC<EditUserDialogProps> = ({ isOpen, onOpenChange, u
try {
// Filter out empty values
const updateData: {
email?: string;
password?: string;
first_name?: string;
last_name?: string;
} = {};

if (data.email && data.email !== user.email) {
updateData.email = data.email;
}
if (data.password && data.password.trim()) {
updateData.password = data.password;
}
Expand Down Expand Up @@ -105,20 +99,6 @@ const EditUserDialog: React.FC<EditUserDialogProps> = ({ isOpen, onOpenChange, u

<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="user@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="password"
Expand Down
143 changes: 143 additions & 0 deletions wavefront/client/src/pages/apps/users/ManageAppAccessDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import floConsoleService from '@app/api';
import { Button } from '@app/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@app/components/ui/dialog';
import { Checkbox } from '@app/components/ui/checkbox';
import { extractErrorMessage } from '@app/lib/utils';
import { useNotifyStore } from '@app/store';
import { IUser } from '@app/types/user';
import { App } from '@app/types/app';
import React, { useEffect, useState } from 'react';
import { useGetAllApps } from '@app/hooks';

interface ManageAppAccessDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
user: IUser | null;
onSuccess?: () => void;
}

const ManageAppAccessDialog: React.FC<ManageAppAccessDialogProps> = ({ isOpen, onOpenChange, user, onSuccess }) => {
const { notifySuccess, notifyError } = useNotifyStore();
const { data: allApps = [] } = useGetAllApps(true);

const [userAppIds, setUserAppIds] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);

// Fetch user's apps when dialog opens
useEffect(() => {
const fetchUserApps = async () => {
if (!user || !isOpen) return;

setLoading(true);
try {
const response = await floConsoleService.appUserService.listUserApps(user.id);
setUserAppIds(response.data.data?.app_ids || []);
} catch (error) {
const errorMessage = extractErrorMessage(error);
notifyError(errorMessage || 'Failed to load user apps');
} finally {
setLoading(false);
}
};

fetchUserApps();
}, [user, isOpen, notifyError]);

const handleToggleApp = (appId: string, checked: boolean) => {
if (checked) {
setUserAppIds([...userAppIds, appId]);
} else {
setUserAppIds(userAppIds.filter((id) => id !== appId));
}
};

const handleSave = async () => {
if (!user) return;

setSaving(true);
try {
// Get apps to grant and revoke
const currentAppIds = new Set(userAppIds);
const previousAppIds = new Set<string>();

// Fetch current state again to compare
const response = await floConsoleService.appUserService.listUserApps(user.id);
response.data.data?.app_ids?.forEach((id) => previousAppIds.add(id));

// Grant access to new apps
const appsToGrant = Array.from(currentAppIds).filter((id) => !previousAppIds.has(id));
for (const appId of appsToGrant) {
await floConsoleService.appUserService.grantAppAccess(appId, user.id);
}

// Revoke access from removed apps
const appsToRevoke = Array.from(previousAppIds).filter((id) => !currentAppIds.has(id));
for (const appId of appsToRevoke) {
await floConsoleService.appUserService.revokeAppAccess(appId, user.id);
}

notifySuccess('App access updated successfully');
onSuccess?.();
onOpenChange(false);
} catch (error) {
const errorMessage = extractErrorMessage(error);
notifyError(errorMessage || 'Failed to update app access');
} finally {
setSaving(false);
}
};

return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Manage App Access</DialogTitle>
<DialogDescription>Select which apps {user?.email} can access</DialogDescription>
</DialogHeader>

<div className="max-h-[400px] space-y-3 overflow-y-auto py-4">
{loading ? (
<div className="text-center text-gray-500">Loading apps...</div>
) : allApps.length === 0 ? (
<div className="text-center text-gray-500">No apps available</div>
) : (
allApps.map((app: App) => (
<div key={app.id} className="flex items-center space-x-3 rounded p-2 hover:bg-gray-50">
<Checkbox
id={`app-${app.id}`}
checked={userAppIds.includes(app.id)}
onCheckedChange={(checked) => handleToggleApp(app.id, checked as boolean)}
/>
<label
htmlFor={`app-${app.id}`}
className="flex-1 cursor-pointer text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{app.app_name}
</label>
</div>
))
)}
</div>

<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={handleSave} loading={saving}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

export default ManageAppAccessDialog;
Loading