Skip to content
Open
Show file tree
Hide file tree
Changes from all 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