diff --git a/web/apps/client-demo/src/Router.tsx b/web/apps/client-demo/src/Router.tsx index 5bdf354f6..785d7ce47 100644 --- a/web/apps/client-demo/src/Router.tsx +++ b/web/apps/client-demo/src/Router.tsx @@ -14,6 +14,8 @@ import Profile from './pages/settings/Profile'; import Sessions from './pages/settings/Sessions'; import Members from './pages/settings/Members'; import Security from './pages/settings/Security'; +import Projects from './pages/settings/Projects'; +import ProjectDetails from './pages/settings/ProjectDetails'; function Router() { return ( @@ -34,6 +36,8 @@ function Router() { } /> } /> } /> + } /> + } /> } /> diff --git a/web/apps/client-demo/src/pages/Settings.tsx b/web/apps/client-demo/src/pages/Settings.tsx index 2fc33a7db..076986669 100644 --- a/web/apps/client-demo/src/pages/Settings.tsx +++ b/web/apps/client-demo/src/pages/Settings.tsx @@ -9,7 +9,8 @@ const NAV_ITEMS = [ { label: 'Profile', path: 'profile' }, { label: 'Sessions', path: 'sessions' }, { label: 'Members', path: 'members' }, - { label: 'Security', path: 'security' } + { label: 'Security', path: 'security' }, + { label: 'Projects', path: 'projects' } ]; export default function Settings() { diff --git a/web/apps/client-demo/src/pages/settings/ProjectDetails.tsx b/web/apps/client-demo/src/pages/settings/ProjectDetails.tsx new file mode 100644 index 000000000..4b949caa3 --- /dev/null +++ b/web/apps/client-demo/src/pages/settings/ProjectDetails.tsx @@ -0,0 +1,17 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { ProjectDetailsView } from '@raystack/frontier/react'; + +export default function ProjectDetails() { + const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>(); + const navigate = useNavigate(); + + if (!projectId) return null; + + return ( + navigate(`/${orgId}/settings/projects`)} + onDeleteSuccess={() => navigate(`/${orgId}/settings/projects`)} + /> + ); +} diff --git a/web/apps/client-demo/src/pages/settings/Projects.tsx b/web/apps/client-demo/src/pages/settings/Projects.tsx new file mode 100644 index 000000000..85147975a --- /dev/null +++ b/web/apps/client-demo/src/pages/settings/Projects.tsx @@ -0,0 +1,13 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { ProjectsView } from '@raystack/frontier/react'; + +export default function Projects() { + const { orgId } = useParams<{ orgId: string }>(); + const navigate = useNavigate(); + + return ( + navigate(`/${orgId}/settings/projects/${projectId}`)} + /> + ); +} diff --git a/web/sdk/react/components/view-header/view-header.tsx b/web/sdk/react/components/view-header/view-header.tsx index d4b33e99d..f36606c90 100644 --- a/web/sdk/react/components/view-header/view-header.tsx +++ b/web/sdk/react/components/view-header/view-header.tsx @@ -1,12 +1,32 @@ -import { ComponentProps } from 'react'; +import { ComponentProps, ReactNode } from 'react'; import { Flex, Headline, Text } from '@raystack/apsara-v1'; export interface ViewHeaderProps extends ComponentProps { title: string; description?: string; + /** + * When provided, renders a breadcrumb row above the title. + * `children` are placed alongside the breadcrumb (e.g. action buttons). + * Without breadcrumb, `children` render alongside the title (default layout). + */ + breadcrumb?: ReactNode; } -export function ViewHeader({ title, description, children, ...props }: ViewHeaderProps) { +export function ViewHeader({ title, description, breadcrumb, children, ...props }: ViewHeaderProps) { + if (breadcrumb) { + return ( + + + {breadcrumb} + {children} + + + {title} + + + ); + } + return ( diff --git a/web/sdk/react/index.ts b/web/sdk/react/index.ts index 6e59d19ca..f53635636 100644 --- a/web/sdk/react/index.ts +++ b/web/sdk/react/index.ts @@ -36,6 +36,7 @@ export { ProfileView } from './views-new/profile'; export { SessionsView } from './views-new/sessions'; export { MembersView } from './views-new/members'; export { SecurityView } from './views-new/security'; +export { ProjectsView, ProjectDetailsView } from './views-new/projects'; export type { FrontierClientOptions, diff --git a/web/sdk/react/views-new/preferences/preferences-view.tsx b/web/sdk/react/views-new/preferences/preferences-view.tsx index f60c0b3ac..42bbf25a0 100644 --- a/web/sdk/react/views-new/preferences/preferences-view.tsx +++ b/web/sdk/react/views-new/preferences/preferences-view.tsx @@ -79,7 +79,7 @@ export function PreferencesView({ children }: PreferencesViewProps) { {children} - - + + ); } diff --git a/web/sdk/react/views-new/projects/components/add-member-menu.tsx b/web/sdk/react/views-new/projects/components/add-member-menu.tsx new file mode 100644 index 000000000..5558361d1 --- /dev/null +++ b/web/sdk/react/views-new/projects/components/add-member-menu.tsx @@ -0,0 +1,251 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { CardStackPlusIcon, PlusIcon } from '@radix-ui/react-icons'; +import { + Avatar, + Button, + Flex, + Menu, + Separator, + Skeleton, + Tooltip +} from '@raystack/apsara-v1'; +import { toastManager } from '@raystack/apsara-v1'; +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + ListOrganizationUsersRequestSchema, + CreatePolicyForProjectRequestSchema, + type User +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useOrganizationTeams } from '../../../hooks/useOrganizationTeams'; +import { AuthTooltipMessage } from '../../../utils'; +import { + PERMISSIONS, + filterUsersfromUsers, + getInitials +} from '../../../../utils'; +import styles from '../project-details-view.module.css'; + +interface AddMemberMenuProps { + projectId: string; + canUpdateProject: boolean; + members: User[]; + refetch: () => void; +} + +export function AddMemberMenu({ + projectId, + canUpdateProject, + members, + refetch +}: AddMemberMenuProps) { + const [showTeam, setShowTeam] = useState(false); + + const { activeOrganization: organization } = useFrontier(); + const { isFetching: isTeamsLoading, teams } = useOrganizationTeams({}); + + const { + data: orgUsersData, + isLoading: isOrgUsersLoading, + error: orgUsersError + } = useQuery( + FrontierServiceQueries.listOrganizationUsers, + create(ListOrganizationUsersRequestSchema, { + id: organization?.id || '' + }), + { enabled: !!organization?.id && canUpdateProject } + ); + + const orgUsers = useMemo( + () => orgUsersData?.users ?? [], + [orgUsersData] + ); + + useEffect(() => { + if (orgUsersError) { + toastManager.add({ + title: 'Something went wrong', + description: orgUsersError.message, + type: 'error' + }); + } + }, [orgUsersError]); + + const invitableUsers = useMemo( + () => filterUsersfromUsers(orgUsers, members), + [orgUsers, members] + ); + + const { mutate: createPolicyForProject } = useMutation( + FrontierServiceQueries.createPolicyForProject, + { + onSuccess: () => { + toastManager.add({ + title: 'Member added', + type: 'success' + }); + refetch(); + }, + onError: (err: Error) => { + toastManager.add({ + title: 'Something went wrong', + description: err.message, + type: 'error' + }); + } + } + ); + + const addMember = useCallback( + (userId: string) => { + if (!userId || !organization?.id || !projectId) return; + const principal = `${PERMISSIONS.UserNamespace}:${userId}`; + createPolicyForProject( + create(CreatePolicyForProjectRequestSchema, { + projectId, + body: { roleId: PERMISSIONS.RoleProjectViewer, principal } + }) + ); + }, + [createPolicyForProject, organization?.id, projectId] + ); + + const addTeam = useCallback( + (teamId: string) => { + if (!teamId || !organization?.id || !projectId) return; + const principal = `${PERMISSIONS.GroupNamespace}:${teamId}`; + createPolicyForProject( + create(CreatePolicyForProjectRequestSchema, { + projectId, + body: { roleId: PERMISSIONS.RoleProjectViewer, principal } + }) + ); + }, + [createPolicyForProject, organization?.id, projectId] + ); + + const toggleShowTeam = useCallback(() => { + setShowTeam(prev => !prev); + }, []); + + const isLoading = showTeam ? isTeamsLoading : isOrgUsersLoading; + + if (!canUpdateProject) { + return ( + + }> + + + {AuthTooltipMessage} + + ); + } + + return ( + + + } + > + Add a member + + +
+ {isLoading + ? + { + Array.from({ length: 6 }, (_, i) => ( + + )) + } + + : showTeam + ? teams.map(team => ( + + } + onClick={() => addTeam(team.id || '')} + data-test-id={`frontier-sdk-add-team-to-project-item-${team.id}`} + > + {team.title || team.name} + + )) + : invitableUsers.map(user => ( + + } + onClick={() => addMember(user.id || '')} + data-test-id={`frontier-sdk-add-user-to-project-item-${user.id}`} + > + {user.title || user.email} + + ))} + + {!isLoading && + (showTeam ? !teams.length : !invitableUsers.length) && ( + + {showTeam ? 'No teams found' : 'No users found'} + + )} +
+ + + + +
+
+ ); +} diff --git a/web/sdk/react/views-new/projects/components/add-project-dialog.tsx b/web/sdk/react/views-new/projects/components/add-project-dialog.tsx new file mode 100644 index 000000000..16b68eda8 --- /dev/null +++ b/web/sdk/react/views-new/projects/components/add-project-dialog.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { + Button, + Flex, + Dialog, + InputField +} from '@raystack/apsara-v1'; +import { toastManager } from '@raystack/apsara-v1'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + CreateProjectRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import slugify from 'slugify'; +import { generateHashFromString } from '../../../utils'; +import { ConnectError, Code } from '@connectrpc/connect'; + +const projectSchema = yup + .object({ + title: yup.string().required('Project title is required') + }) + .required(); + +type FormData = yup.InferType; + +type DialogHandle = ReturnType; + +export interface AddProjectDialogProps { + handle: DialogHandle; + refetch: () => void; +} + +export function AddProjectDialog({ handle, refetch }: AddProjectDialogProps) { + const { + reset, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + register + } = useForm({ + resolver: yupResolver(projectSchema) + }); + const { activeOrganization: organization } = useFrontier(); + + const { mutateAsync: createProject } = useMutation( + FrontierServiceQueries.createProject + ); + + const handleOpenChange = (open: boolean) => { + if (!open) reset(); + }; + + async function onSubmit(data: FormData) { + if (!organization?.id) return; + const slug = slugify(data.title, { lower: true, strict: true }); + const suffix = generateHashFromString(organization.id); + const name = `${slug}-${suffix}`; + try { + await createProject( + create(CreateProjectRequestSchema, { + body: { + title: data.title, + name, + orgId: organization.id + } + }) + ); + toastManager.add({ title: 'Project added', type: 'success' }); + refetch(); + handle.close(); + } catch (error) { + if (error instanceof ConnectError && error.code === Code.AlreadyExists) { + setError('title', { + message: + 'A project with a similar title already exists. Please tweak the title and try again.' + }); + } else { + toastManager.add({ + title: 'Something went wrong', + description: + error instanceof Error ? error.message : 'Failed to create project', + type: 'error' + }); + } + } + } + + return ( + + + + Add Project + +
+ + + + + + + + + + + +
+
+
+ ); +} diff --git a/web/sdk/react/views-new/projects/components/delete-project-dialog.tsx b/web/sdk/react/views-new/projects/components/delete-project-dialog.tsx new file mode 100644 index 000000000..3ebd6627a --- /dev/null +++ b/web/sdk/react/views-new/projects/components/delete-project-dialog.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useState } from 'react'; +import { + Button, + Flex, + Text, + AlertDialog +} from '@raystack/apsara-v1'; +import { toastManager } from '@raystack/apsara-v1'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + DeleteProjectRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; + +export interface DeleteProjectPayload { + projectId: string; +} + +type AlertDialogHandle = ReturnType>; + +export interface DeleteProjectDialogProps { + handle: AlertDialogHandle; + refetch: () => void; +} + +export function DeleteProjectDialog({ handle, refetch }: DeleteProjectDialogProps) { + const { activeOrganization: organization } = useFrontier(); + const [isLoading, setIsLoading] = useState(false); + + const { mutateAsync: deleteProject } = useMutation( + FrontierServiceQueries.deleteProject + ); + + const handleDelete = async (projectId: string) => { + if (!organization?.id || !projectId) return; + setIsLoading(true); + try { + await deleteProject( + create(DeleteProjectRequestSchema, { id: projectId }) + ); + toastManager.add({ title: 'Project deleted', type: 'success' }); + refetch(); + handle.close(); + } catch (error) { + toastManager.add({ + title: 'Something went wrong', + description: + error instanceof Error ? error.message : 'Failed to delete project', + type: 'error' + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + {({ payload: rawPayload }) => { + const payload = rawPayload as DeleteProjectPayload | undefined; + return ( + + + Delete Project + + + + This action is irreversible and may result in the deletion of + all related files. Are you sure you want to proceed? + + + + + + + + + + ); + }} + + ); +} diff --git a/web/sdk/react/views-new/projects/components/edit-project-dialog.module.css b/web/sdk/react/views-new/projects/components/edit-project-dialog.module.css new file mode 100644 index 000000000..0aa54aa46 --- /dev/null +++ b/web/sdk/react/views-new/projects/components/edit-project-dialog.module.css @@ -0,0 +1,4 @@ +.radioOptions { + display: flex; + gap: var(--rs-space-10); +} diff --git a/web/sdk/react/views-new/projects/components/edit-project-dialog.tsx b/web/sdk/react/views-new/projects/components/edit-project-dialog.tsx new file mode 100644 index 000000000..36ba9b9eb --- /dev/null +++ b/web/sdk/react/views-new/projects/components/edit-project-dialog.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useEffect } from 'react'; +import { + Button, + Flex, + Text, + Dialog, + InputField, + Radio +} from '@raystack/apsara-v1'; +import { toastManager } from '@raystack/apsara-v1'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + UpdateProjectRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import styles from './edit-project-dialog.module.css'; + +const editProjectSchema = yup + .object({ + title: yup.string().required('Project name is required'), + privacy: yup.string().oneOf(['private', 'public']).required() + }) + .required(); + +type FormData = yup.InferType; + +export interface EditProjectPayload { + projectId: string; + title: string; +} + +type DialogHandle = ReturnType>; + +export interface EditProjectDialogProps { + handle: DialogHandle; + refetch: () => void; +} + +export function EditProjectDialog({ handle, refetch }: EditProjectDialogProps) { + return ( + + {({ payload }) => { + const p = payload as EditProjectPayload | undefined; + return ( + + {p ? ( + + ) : null} + + ); + }} + + ); +} + +interface EditProjectFormProps { + payload: EditProjectPayload; + handle: DialogHandle; + refetch: () => void; +} + +function EditProjectForm({ payload, handle, refetch }: EditProjectFormProps) { + const { + reset, + handleSubmit, + setValue, + watch, + formState: { errors, isSubmitting, isDirty }, + register + } = useForm({ + resolver: yupResolver(editProjectSchema), + defaultValues: { + title: payload.title, + privacy: 'private' as const + } + }); + + const { activeOrganization: organization } = useFrontier(); + const privacy = watch('privacy'); + + const { mutateAsync: updateProject } = useMutation( + FrontierServiceQueries.updateProject + ); + + useEffect(() => { + reset({ + title: payload.title, + privacy: 'private' + }); + }, [payload.projectId, payload.title, reset]); + + async function onSubmit(data: FormData) { + if (!organization?.id || !payload.projectId) return; + + try { + await updateProject( + create(UpdateProjectRequestSchema, { + id: payload.projectId, + body: { + title: data.title, + orgId: organization.id + } + }) + ); + toastManager.add({ title: 'Project updated', type: 'success' }); + refetch(); + handle.close(); + } catch (error) { + toastManager.add({ + title: 'Something went wrong', + description: + error instanceof Error ? error.message : 'Failed to update project', + type: 'error' + }); + } + } + + return ( +
+ + Edit project details + + + + + + + Project privacy + + + setValue('privacy', val as 'private' | 'public', { + shouldDirty: true + }) + } + > +
+ + + + Private + + + + + + Public + + +
+
+
+
+
+ + + + + + +
+ ); +} diff --git a/web/sdk/react/views-new/projects/components/member-columns.tsx b/web/sdk/react/views-new/projects/components/member-columns.tsx new file mode 100644 index 000000000..7bd4e763a --- /dev/null +++ b/web/sdk/react/views-new/projects/components/member-columns.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { DotsVerticalIcon, TrashIcon, UpdateIcon } from '@radix-ui/react-icons'; +import { + Flex, + Text, + Avatar, + Menu, + IconButton, + DataTableColumnDef, + getAvatarColor +} from '@raystack/apsara-v1'; +import type { User, Group, Role } from '@raystack/proton/frontier'; +import { getInitials } from '~/utils'; +import teamIcon from '~/react/assets/users.svg'; + +export type MemberRow = (Group & { isTeam: true }) | (User & { isTeam?: false }); + +export interface MemberMenuPayload { + memberId: string; + isTeam: boolean; + excludedRoles: Role[]; +} + +type MenuHandle = ReturnType>; + +interface GetColumnsOptions { + memberRoles: Record; + groupRoles: Record; + roles: Role[]; + canUpdateProject: boolean; + menuHandle: MenuHandle; +} + +export function getColumns({ + memberRoles, + groupRoles, + roles, + canUpdateProject, + menuHandle +}: GetColumnsOptions): DataTableColumnDef[] { + return [ + { + header: 'Name', + accessorKey: 'title', + cell: ({ row }) => { + const member = row.original; + const fallback = member.isTeam + ? getInitials(member.title || member.name) + : getInitials(member.title || member.email); + const color = getAvatarColor(member.id || ''); + const label = member.isTeam ? member.title : member.title; + const subLabel = member.isTeam ? member.name : member.email; + return ( + + + + + {label} + + + {subLabel} + + + + ); + } + }, + { + header: 'Role', + accessorKey: 'email', + cell: ({ row }) => { + const member = row.original; + const roleList = member.isTeam + ? (member.id && groupRoles[member.id]) || [] + : (member.id && memberRoles[member.id]) || []; + const roleText = + roleList.map((r: Role) => r.title || r.name).join(', ') || + (member.isTeam ? 'Project Viewer' : 'Inherited role'); + return ( + + {roleText} + + ); + } + }, + { + header: '', + accessorKey: 'id', + enableSorting: false, + styles: { + cell: { width: '32px' } + }, + cell: ({ row }) => { + if (!canUpdateProject) return null; + + const member = row.original; + const currentRoles = member.isTeam + ? (member.id && groupRoles[member.id]) || [] + : (member.id && memberRoles[member.id]) || []; + const currentRoleIds = new Set(currentRoles.map(r => r.id)); + const excludedRoles = roles.filter(r => !currentRoleIds.has(r.id)); + + return ( + + + } + > + + + + ); + } + } + ]; +} diff --git a/web/sdk/react/views-new/projects/components/members-cell.module.css b/web/sdk/react/views-new/projects/components/members-cell.module.css new file mode 100644 index 000000000..4e5ce1885 --- /dev/null +++ b/web/sdk/react/views-new/projects/components/members-cell.module.css @@ -0,0 +1,9 @@ +.avatar { + border: none; + outline: 1px solid var(--rs-color-background-base-primary); + margin-left: calc(var(--rs-space-3) * -1); +} + +.avatar:first-child { + margin-left: 0; +} diff --git a/web/sdk/react/views-new/projects/components/members-cell.tsx b/web/sdk/react/views-new/projects/components/members-cell.tsx new file mode 100644 index 000000000..d5658c95b --- /dev/null +++ b/web/sdk/react/views-new/projects/components/members-cell.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { + Avatar, + Flex, + Skeleton, + Text, + getAvatarColor +} from '@raystack/apsara-v1'; +import { useQuery } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + ListProjectUsersRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { getInitials } from '~/utils'; +import styles from './members-cell.module.css'; + +const MAX_AVATARS = 4; + +export interface MembersCellProps { + projectId: string; +} + +export function MembersCell({ projectId }: MembersCellProps) { + const { data: users = [], isLoading } = useQuery( + FrontierServiceQueries.listProjectUsers, + create(ListProjectUsersRequestSchema, { id: projectId }), + { + enabled: !!projectId, + select: (d) => d?.users ?? [] + } + ); + + if (isLoading) return ; + + if (!users.length) return null; + + return ( + + + {users.slice(0, MAX_AVATARS).map(user => ( + + ))} + + {users.length > MAX_AVATARS && +{users.length - MAX_AVATARS}} + + ); +} diff --git a/web/sdk/react/views-new/projects/components/project-columns.tsx b/web/sdk/react/views-new/projects/components/project-columns.tsx new file mode 100644 index 000000000..6a7515897 --- /dev/null +++ b/web/sdk/react/views-new/projects/components/project-columns.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { DotsVerticalIcon, Pencil1Icon, TrashIcon } from '@radix-ui/react-icons'; +import { + Flex, + Text, + Menu, + IconButton, + DataTableColumnDef +} from '@raystack/apsara-v1'; +import type { Project } from '@raystack/proton/frontier'; +import { MembersCell } from './members-cell'; + +export interface ProjectMenuPayload { + projectId: string; + title: string; + canUpdate: boolean; + canDelete: boolean; +} + +type MenuHandle = ReturnType; + +export const getColumns = ({ + userAccessOnProject, + menuHandle +}: { + userAccessOnProject: Record; + menuHandle: MenuHandle; +}): DataTableColumnDef[] => [ + { + header: 'Name', + accessorKey: 'title', + maxSize: 400, + minSize: 200, + cell: ({ getValue }) => { + return ( + + {getValue() as string} + + ); + } + }, + { + header: 'Privacy', + accessorKey: 'metadata', + enableSorting: false, + + cell: () => { + return ( + + Private + + ); + } + }, + { + header: 'Members', + accessorKey: 'membersCount', + enableSorting: false, + cell: ({ row }) => { + const project = row.original as Project; + return ; + } + }, + { + header: '', + accessorKey: 'id', + enableSorting: false, + styles: { + cell: { width: '48px' } + }, + cell: ({ row }) => { + const project = row.original as Project; + const access = userAccessOnProject[project.id!] ?? []; + const canUpdate = access.includes('update'); + const canDelete = access.includes('delete'); + + if (!canUpdate && !canDelete) return null; + + return ( + + + } + > + + + + ); + } + } + ]; diff --git a/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx b/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx new file mode 100644 index 000000000..d3096d3fe --- /dev/null +++ b/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useState } from 'react'; +import { + Button, + Flex, + Text, + AlertDialog +} from '@raystack/apsara-v1'; +import { toastManager } from '@raystack/apsara-v1'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + ListPoliciesRequestSchema, + DeletePolicyRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { useQuery } from '@connectrpc/connect-query'; + +export interface RemoveMemberPayload { + memberId: string; + projectId: string; +} + +type AlertDialogHandle = ReturnType< + typeof AlertDialog.createHandle +>; + +export interface RemoveMemberDialogProps { + handle: AlertDialogHandle; + refetch: () => void; +} + +export function RemoveMemberDialog({ + handle, + refetch +}: RemoveMemberDialogProps) { + return ( + + {({ payload }) => { + const p = payload as RemoveMemberPayload | undefined; + return ( + + {p ? ( + + ) : null} + + ); + }} + + ); +} + +interface RemoveMemberFormProps { + payload: RemoveMemberPayload; + handle: AlertDialogHandle; + refetch: () => void; +} + +function RemoveMemberForm({ + payload, + handle, + refetch +}: RemoveMemberFormProps) { + const [isLoading, setIsLoading] = useState(false); + + const { data: policiesData } = useQuery( + FrontierServiceQueries.listPolicies, + create(ListPoliciesRequestSchema, { + projectId: payload.projectId, + userId: payload.memberId + }), + { enabled: !!payload.projectId && !!payload.memberId } + ); + + const policies = policiesData?.policies ?? []; + + const { mutateAsync: deletePolicy } = useMutation( + FrontierServiceQueries.deletePolicy + ); + + async function handleRemove() { + setIsLoading(true); + try { + await Promise.all( + policies.map(p => + deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) + ) + ); + toastManager.add({ title: 'Member removed', type: 'success' }); + refetch(); + handle.close(); + } catch (error) { + toastManager.add({ + title: 'Something went wrong', + description: + error instanceof Error ? error.message : 'Failed to remove member', + type: 'error' + }); + } finally { + setIsLoading(false); + } + } + + return ( + <> + + Remove project member + + + + Are you sure you want to remove this member from the project? This + action cannot be undone. + + + + + + + + + + ); +} diff --git a/web/sdk/react/views-new/projects/index.ts b/web/sdk/react/views-new/projects/index.ts new file mode 100644 index 000000000..4c506aea1 --- /dev/null +++ b/web/sdk/react/views-new/projects/index.ts @@ -0,0 +1,3 @@ +export { ProjectsView } from './projects-view'; +export { ProjectDetailsView } from './project-details-view'; +export type { ProjectDetailsViewProps } from './project-details-view'; diff --git a/web/sdk/react/views-new/projects/project-details-view.module.css b/web/sdk/react/views-new/projects/project-details-view.module.css new file mode 100644 index 000000000..685cbc235 --- /dev/null +++ b/web/sdk/react/views-new/projects/project-details-view.module.css @@ -0,0 +1,52 @@ +.roleFilter { + width: auto; + min-width: 80px; + box-shadow: none; +} + +.tableRoot { + border: none; +} + +.menuContent { + min-width: 160px; +} + +.addMemberContent { + width: 228px; +} + +.addMemberMenuList { + max-height: 256px; + overflow-y: auto; +} + +.addMemberMenuItem { + width: 100%; +} + +.addMemberMenuItemText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; +} + +.addMemberEmptyState { + min-height: 200px; +} + +.addMemberSeparator { + margin: var(--rs-space-2) calc(var(--rs-space-3) * -1); +} + +.addMemberToggleBtn { + justify-content: flex-start; +} + +.addMemberContent:has(input[value]:not([value=''])):not(:has([role='option'])) + .addMemberFooter + > :first-child { + display: none; +} diff --git a/web/sdk/react/views-new/projects/project-details-view.tsx b/web/sdk/react/views-new/projects/project-details-view.tsx new file mode 100644 index 000000000..766a09288 --- /dev/null +++ b/web/sdk/react/views-new/projects/project-details-view.tsx @@ -0,0 +1,579 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + DotsHorizontalIcon, + ExclamationTriangleIcon, + Pencil1Icon, + UpdateIcon +} from '@radix-ui/react-icons'; +import { + Breadcrumb, + Skeleton, + Flex, + EmptyState, + DataTable, + Menu, + AlertDialog, + Dialog, + IconButton, + Image, + Select +} from '@raystack/apsara-v1'; +import deleteIcon from '../../assets/delete.svg'; +import { toastManager } from '@raystack/apsara-v1'; +import { + useQuery, + useMutation, + createConnectQueryKey, + useTransport +} from '@connectrpc/connect-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { + FrontierServiceQueries, + ListProjectGroupsRequestSchema, + ListProjectUsersRequestSchema, + GetProjectRequestSchema, + ListRolesRequestSchema, + CreatePolicyForProjectRequestSchema, + DeletePolicyRequestSchema, + ListPoliciesRequestSchema, + type Role as ProtoRole +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { useFrontier } from '../../contexts/FrontierContext'; +import { usePermissions } from '../../hooks/usePermissions'; +import { PERMISSIONS, shouldShowComponent } from '../../../utils'; +import { ViewContainer } from '../../components/view-container'; +import { ViewHeader } from '../../components/view-header'; +import { + EditProjectDialog, + type EditProjectPayload +} from './components/edit-project-dialog'; +import { + DeleteProjectDialog, + type DeleteProjectPayload +} from './components/delete-project-dialog'; +import { + RemoveMemberDialog, + type RemoveMemberPayload +} from './components/remove-member-dialog'; +import { + getColumns, + type MemberRow, + type MemberMenuPayload +} from './components/member-columns'; +import { AddMemberMenu } from './components/add-member-menu'; +import styles from './project-details-view.module.css'; + +interface ProjectGroupRolePair { + groupId?: string; + roles: ProtoRole[]; +} + +interface ProjectUserRolePair { + userId?: string; + roles: ProtoRole[]; +} + +const memberMenuHandle = Menu.createHandle(); +const editProjectDialogHandle = Dialog.createHandle(); +const deleteProjectDialogHandle = + AlertDialog.createHandle(); +const removeMemberDialogHandle = + AlertDialog.createHandle(); + +export interface ProjectDetailsViewProps { + projectId: string; + projectsLabel?: string; + onNavigateToProjects?: () => void; + onDeleteSuccess?: () => void; +} + +export function ProjectDetailsView({ + projectId, + projectsLabel = 'Projects', + onNavigateToProjects, + onDeleteSuccess +}: ProjectDetailsViewProps) { + const { activeOrganization: organization } = useFrontier(); + + const { + data: project, + isLoading: isProjectLoading, + error: projectError, + refetch: refetchProject + } = useQuery( + FrontierServiceQueries.getProject, + create(GetProjectRequestSchema, { id: projectId || '' }), + { + enabled: !!organization?.id && !!projectId, + select: d => d?.project + } + ); + + useEffect(() => { + if (projectError) { + toastManager.add({ + title: 'Something went wrong', + description: projectError.message, + type: 'error' + }); + } + }, [projectError]); + + const { + data: projectUsersData, + isLoading: isMembersLoading, + refetch: refetchProjectUsers + } = useQuery( + FrontierServiceQueries.listProjectUsers, + create(ListProjectUsersRequestSchema, { + id: projectId || '', + withRoles: true + }), + { enabled: !!organization?.id && !!projectId } + ); + + const projectUsers = useMemo( + () => ({ + users: projectUsersData?.users ?? [], + memberRoles: (projectUsersData?.rolePairs ?? []).reduce( + (acc: Record, mr: ProjectUserRolePair) => { + if (mr.userId) acc[mr.userId] = mr.roles; + return acc; + }, + {} + ) + }), + [projectUsersData] + ); + + const { + data: projectGroupsData, + isLoading: isTeamsLoading, + error: groupsError, + refetch: refetchProjectGroups + } = useQuery( + FrontierServiceQueries.listProjectGroups, + create(ListProjectGroupsRequestSchema, { + id: projectId || '', + withRoles: true + }), + { enabled: !!organization?.id && !!projectId } + ); + + useEffect(() => { + if (groupsError) { + toastManager.add({ + title: 'Something went wrong', + description: groupsError.message, + type: 'error' + }); + } + }, [groupsError]); + + const projectGroups = useMemo( + () => ({ + groups: projectGroupsData?.groups ?? [], + groupRoles: (projectGroupsData?.rolePairs ?? []).reduce( + (acc: Record, gr: ProjectGroupRolePair) => { + if (gr.groupId) acc[gr.groupId] = gr.roles; + return acc; + }, + {} + ) + }), + [projectGroupsData] + ); + + const { + data: rolesData, + isLoading: isRolesLoading, + error: rolesError + } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { + state: 'enabled', + scopes: [PERMISSIONS.ProjectNamespace] + }), + { enabled: !!organization?.id && !!projectId } + ); + + const roles = useMemo(() => rolesData?.roles ?? [], [rolesData]); + + useEffect(() => { + if (rolesError) { + toastManager.add({ + title: 'Something went wrong', + description: rolesError.message, + type: 'error' + }); + } + }, [rolesError]); + + const resource = `app/project:${projectId}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { permission: PERMISSIONS.UpdatePermission, resource }, + { permission: PERMISSIONS.DeletePermission, resource } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!projectId + ); + + const { canUpdateProject, canDeleteProject } = useMemo(() => { + return { + canUpdateProject: shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ), + canDeleteProject: shouldShowComponent( + permissions, + `${PERMISSIONS.DeletePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + const isLoading = + !organization?.id || + isProjectLoading || + isMembersLoading || + isTeamsLoading || + isRolesLoading || + isPermissionsFetching; + + const refetchMembers = useCallback(() => { + refetchProjectUsers(); + refetchProjectGroups(); + }, [refetchProjectUsers, refetchProjectGroups]); + + const [roleFilter, setRoleFilter] = useState('all'); + + const members: MemberRow[] = useMemo(() => { + const teams = projectGroups.groups.map(t => ({ + ...t, + isTeam: true as const + })); + return [...teams, ...projectUsers.users]; + }, [projectGroups.groups, projectUsers.users]); + + const filteredMembers = useMemo(() => { + if (roleFilter === 'all') return members; + return members.filter(member => { + const memberRoleList = member.isTeam + ? (member.id && projectGroups.groupRoles[member.id]) || [] + : (member.id && projectUsers.memberRoles[member.id]) || []; + return memberRoleList.some(r => r.id === roleFilter); + }); + }, [members, roleFilter, projectUsers.memberRoles, projectGroups.groupRoles]); + + const columns = useMemo( + () => + getColumns({ + memberRoles: projectUsers.memberRoles, + groupRoles: projectGroups.groupRoles, + roles, + canUpdateProject, + menuHandle: memberMenuHandle + }), + [ + projectUsers.memberRoles, + projectGroups.groupRoles, + roles, + canUpdateProject + ] + ); + + const queryClient = useQueryClient(); + const transport = useTransport(); + + const { mutateAsync: deletePolicy } = useMutation( + FrontierServiceQueries.deletePolicy + ); + const { mutateAsync: createPolicyForProject } = useMutation( + FrontierServiceQueries.createPolicyForProject + ); + + const updateMemberRole = useCallback( + async (memberId: string, isTeam: boolean, role: ProtoRole) => { + try { + const principal = isTeam + ? `${PERMISSIONS.GroupNamespace}:${memberId}` + : `${PERMISSIONS.UserNamespace}:${memberId}`; + + const input = create(ListPoliciesRequestSchema, { + projectId, + ...(isTeam ? { groupId: memberId } : { userId: memberId }) + }); + + const policiesData = await queryClient.fetchQuery({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listPolicies, + transport, + input, + cardinality: 'finite' + }) + }); + + const policies = (policiesData as { policies?: { id?: string }[] })?.policies ?? []; + + await Promise.all( + policies.map(p => + deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) + ) + ); + + await createPolicyForProject( + create(CreatePolicyForProjectRequestSchema, { + projectId, + body: { roleId: role.id as string, principal } + }) + ); + refetchMembers(); + toastManager.add({ + title: 'Member role updated', + type: 'success' + }); + } catch (error) { + toastManager.add({ + title: 'Something went wrong', + description: + error instanceof Error + ? error.message + : 'Failed to update member role', + type: 'error' + }); + } + }, + [queryClient, transport, deletePolicy, createPolicyForProject, projectId, refetchMembers] + ); + + const handleDeleteSuccess = useCallback(() => { + onDeleteSuccess?.(); + }, [onDeleteSuccess]); + + const projectTitle = project?.title || ''; + + return ( + + + { + e.preventDefault(); + onNavigateToProjects?.(); + }} + > + {projectsLabel} + + + + {isProjectLoading ? ( + + ) : ( + projectTitle + )} + + + } + > + {!isLoading && (canUpdateProject || canDeleteProject) && ( + + )} + + + + + + + {isLoading ? ( + + ) : ( + <> + + + + )} + + {isLoading ? ( + + ) : ( + + )} + + } + heading="No members found" + subHeading="Get started by adding your first member." + /> + } + classNames={{ + root: styles.tableRoot + }} + /> + + + + + {({ payload: rawPayload }) => { + const payload = rawPayload as MemberMenuPayload | undefined; + return ( + + {payload?.excludedRoles.map((role: ProtoRole) => ( + } + onClick={() => + payload && + updateMemberRole( + payload.memberId, + payload.isTeam, + role + ) + } + data-test-id="frontier-sdk-update-member-role-btn" + > + Make {role.title} + + ))} + } + onClick={() => + payload && + removeMemberDialogHandle.openWithPayload({ + memberId: payload.memberId, + projectId + }) + } + data-test-id="frontier-sdk-remove-member-btn" + style={{ color: 'var(--rs-color-foreground-danger-primary)' }} + > + Remove from project + + + ); + }} + + + { + refetchProject(); + }} + /> + + + + ); +} + +interface ProjectActionsMenuProps { + projectId: string; + projectTitle: string; + canUpdate: boolean; + canDelete: boolean; +} + +const projectActionsMenuHandle = Menu.createHandle(); + +function ProjectActionsMenu({ + projectId, + projectTitle, + canUpdate, + canDelete +}: ProjectActionsMenuProps) { + return ( + <> + + } + > + + + + + {canUpdate && ( + } + onClick={() => + editProjectDialogHandle.openWithPayload({ + projectId, + title: projectTitle + }) + } + data-test-id="frontier-sdk-edit-project-details-btn" + > + Edit + + )} + {canDelete && ( + } + onClick={() => + deleteProjectDialogHandle.openWithPayload({ projectId }) + } + data-test-id="frontier-sdk-delete-project-details-btn" + style={{ color: 'var(--rs-color-foreground-danger-primary)' }} + > + Delete project + + )} + + + + ); +} diff --git a/web/sdk/react/views-new/projects/projects-view.module.css b/web/sdk/react/views-new/projects/projects-view.module.css new file mode 100644 index 000000000..cef9196f1 --- /dev/null +++ b/web/sdk/react/views-new/projects/projects-view.module.css @@ -0,0 +1,7 @@ +.tableRoot { + border: none; +} + +.menuContent { + min-width: 6; +} diff --git a/web/sdk/react/views-new/projects/projects-view.tsx b/web/sdk/react/views-new/projects/projects-view.tsx new file mode 100644 index 000000000..71db5340c --- /dev/null +++ b/web/sdk/react/views-new/projects/projects-view.tsx @@ -0,0 +1,222 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { ExclamationTriangleIcon, Pencil1Icon } from '@radix-ui/react-icons'; +import { + Button, + Tooltip, + Skeleton, + Flex, + EmptyState, + DataTable, + Dialog, + AlertDialog, + Image, + Menu +} from '@raystack/apsara-v1'; +import deleteIcon from '../../assets/delete.svg'; +import { toastManager } from '@raystack/apsara-v1'; +import { useFrontier } from '../../contexts/FrontierContext'; +import { useOrganizationProjects } from '../../hooks/useOrganizationProjects'; +import { usePermissions } from '../../hooks/usePermissions'; +import { AuthTooltipMessage } from '../../utils'; +import { PERMISSIONS, shouldShowComponent } from '../../../utils'; +import { ViewContainer } from '../../components/view-container'; +import { ViewHeader } from '../../components/view-header'; +import { getColumns, type ProjectMenuPayload } from './components/project-columns'; +import { AddProjectDialog } from './components/add-project-dialog'; +import { EditProjectDialog, type EditProjectPayload } from './components/edit-project-dialog'; +import { DeleteProjectDialog, type DeleteProjectPayload } from './components/delete-project-dialog'; +import styles from './projects-view.module.css'; +import { useTerminology } from '~/react/hooks/useTerminology'; + +const projectMenuHandle = Menu.createHandle(); +const addProjectDialogHandle = Dialog.createHandle(); +const editProjectDialogHandle = Dialog.createHandle(); +const deleteProjectDialogHandle = AlertDialog.createHandle(); + +export interface ProjectsViewProps { + title?: string; + description?: string; + onProjectClick?: (projectId: string) => void; +} + +export function ProjectsView({ + title = 'Projects', + description, + onProjectClick +}: ProjectsViewProps) { + const { + isFetching: isProjectsLoading, + projects, + userAccessOnProject, + refetch, + error: projectsError + } = useOrganizationProjects({ + withMemberCount: true + }); + + const { activeOrganization: organization } = useFrontier(); + const t = useTerminology(); + + const resource = `app/organization:${organization?.id}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.ProjectCreatePermission, + resource + }, + { + permission: PERMISSIONS.UpdatePermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!organization?.id + ); + + const { canCreateProject } = useMemo(() => { + return { + canCreateProject: shouldShowComponent( + permissions, + `${PERMISSIONS.ProjectCreatePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + useEffect(() => { + if (projectsError) { + toastManager.add({ + title: 'Something went wrong', + description: + projectsError instanceof Error + ? projectsError.message + : 'Failed to load projects', + type: 'error' + }); + } + }, [projectsError]); + + const isLoading = !organization?.id || isPermissionsFetching || isProjectsLoading; + + const columns = useMemo( + () => + getColumns({ + userAccessOnProject, + menuHandle: projectMenuHandle + }), + [userAccessOnProject] + ); + + return ( + + + + onProjectClick?.(row.id)} + > + + + + {isLoading ? ( + + ) : ( + + )} + + {isLoading ? ( + + ) : ( + + } + > + + + {!canCreateProject && ( + {AuthTooltipMessage} + )} + + )} + + } + heading="No projects found" + subHeading="Get started by creating your first project." + /> + } + classNames={{ + root: styles.tableRoot + }} + /> + + + + + {({ payload: rawPayload }) => { + const payload = rawPayload as ProjectMenuPayload | undefined; + return ( + + {payload?.canUpdate && ( + } + onClick={() => + editProjectDialogHandle.openWithPayload({ + projectId: payload.projectId, + title: payload.title + }) + } + data-test-id="edit-project-dropdown-item" + > + Edit + + )} + {payload?.canDelete && ( + } + onClick={() => + deleteProjectDialogHandle.openWithPayload({ + projectId: payload.projectId + }) + } + data-test-id="delete-project-dropdown-item" + style={{ color: 'var(--rs-color-foreground-danger-primary)' }} + > + Delete project + + )} + + ); + }} + + + + + + + ); +}