diff --git a/web/apps/client-demo/src/Router.tsx b/web/apps/client-demo/src/Router.tsx index 8e1519499..099807432 100644 --- a/web/apps/client-demo/src/Router.tsx +++ b/web/apps/client-demo/src/Router.tsx @@ -18,6 +18,8 @@ import Projects from './pages/settings/Projects'; import ProjectDetails from './pages/settings/ProjectDetails'; import Teams from './pages/settings/Teams'; import TeamDetails from './pages/settings/TeamDetails'; +import ServiceAccounts from './pages/settings/ServiceAccounts'; +import ServiceAccountDetails from './pages/settings/ServiceAccountDetails'; function Router() { return ( @@ -42,6 +44,8 @@ function Router() { } /> } /> } /> + } /> + } /> } /> diff --git a/web/apps/client-demo/src/pages/Settings.tsx b/web/apps/client-demo/src/pages/Settings.tsx index 98b15bba9..3d9c44d6f 100644 --- a/web/apps/client-demo/src/pages/Settings.tsx +++ b/web/apps/client-demo/src/pages/Settings.tsx @@ -11,7 +11,8 @@ const NAV_ITEMS = [ { label: 'Members', path: 'members' }, { label: 'Security', path: 'security' }, { label: 'Projects', path: 'projects' }, - { label: 'Teams', path: 'teams' } + { label: 'Teams', path: 'teams' }, + { label: 'Service Accounts', path: 'service-accounts' } ]; export default function Settings() { diff --git a/web/apps/client-demo/src/pages/settings/ServiceAccountDetails.tsx b/web/apps/client-demo/src/pages/settings/ServiceAccountDetails.tsx new file mode 100644 index 000000000..d0d20eb98 --- /dev/null +++ b/web/apps/client-demo/src/pages/settings/ServiceAccountDetails.tsx @@ -0,0 +1,24 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { ServiceAccountDetailsView } from '@raystack/frontier/react'; + +export default function ServiceAccountDetails() { + const { orgId, serviceAccountId } = useParams<{ + orgId: string; + serviceAccountId: string; + }>(); + const navigate = useNavigate(); + + if (!serviceAccountId) return null; + + return ( + + navigate(`/${orgId}/settings/service-accounts`) + } + onDeleteSuccess={() => + navigate(`/${orgId}/settings/service-accounts`) + } + /> + ); +} diff --git a/web/apps/client-demo/src/pages/settings/ServiceAccounts.tsx b/web/apps/client-demo/src/pages/settings/ServiceAccounts.tsx new file mode 100644 index 000000000..7a868a4e8 --- /dev/null +++ b/web/apps/client-demo/src/pages/settings/ServiceAccounts.tsx @@ -0,0 +1,15 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { ServiceAccountsView } from '@raystack/frontier/react'; + +export default function ServiceAccounts() { + const { orgId } = useParams<{ orgId: string }>(); + const navigate = useNavigate(); + + return ( + + navigate(`/${orgId}/settings/service-accounts/${id}`) + } + /> + ); +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9e3d27500..3b9e012cf 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -6530,14 +6530,14 @@ packages: sentence-case@2.1.1: resolution: {integrity: sha512-ENl7cYHaK/Ktwk5OTD+aDbQ3uC8IByu/6Bkg+HDv8Mm+XnBnppVNalcfJTNsp1ibstKh030/JKQQWglDvtKwEQ==} - seroval-plugins@1.5.1: - resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 - seroval@1.5.1: - resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} engines: {node: '>=10'} set-blocking@2.0.0: @@ -9909,8 +9909,8 @@ snapshots: dependencies: '@tanstack/history': 1.161.6 cookie-es: 2.0.1 - seroval: 1.5.1 - seroval-plugins: 1.5.1(seroval@1.5.1) + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) '@tanstack/store@0.9.3': {} @@ -14561,11 +14561,11 @@ snapshots: no-case: 2.3.2 upper-case-first: 1.1.2 - seroval-plugins@1.5.1(seroval@1.5.1): + seroval-plugins@1.5.2(seroval@1.5.2): dependencies: - seroval: 1.5.1 + seroval: 1.5.2 - seroval@1.5.1: {} + seroval@1.5.2: {} set-blocking@2.0.0: {} diff --git a/web/sdk/react/components/onboarding/updates.tsx b/web/sdk/react/components/onboarding/updates.tsx index d1a10331a..713330a03 100644 --- a/web/sdk/react/components/onboarding/updates.tsx +++ b/web/sdk/react/components/onboarding/updates.tsx @@ -1,7 +1,7 @@ 'use client'; import { yupResolver } from '@hookform/resolvers/yup'; -import { ReactNode } from 'react'; +import { type ReactNode } from 'react'; import { Button, Flex, Text, Switch, Skeleton } from '@raystack/apsara'; import { Controller, useForm } from 'react-hook-form'; import * as yup from 'yup'; diff --git a/web/sdk/react/index.ts b/web/sdk/react/index.ts index 0720e1594..b4bcf5b19 100644 --- a/web/sdk/react/index.ts +++ b/web/sdk/react/index.ts @@ -38,6 +38,10 @@ export { MembersView } from './views-new/members'; export { SecurityView } from './views-new/security'; export { ProjectsView, ProjectDetailsView } from './views-new/projects'; export { TeamsView, TeamDetailsView } from './views-new/teams'; +export { + ServiceAccountsView, + ServiceAccountDetailsView +} from './views-new/service-accounts'; export type { FrontierClientOptions, diff --git a/web/sdk/react/views-new/service-accounts/components/add-service-account-dialog.tsx b/web/sdk/react/views-new/service-accounts/components/add-service-account-dialog.tsx new file mode 100644 index 000000000..84f376bbf --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/components/add-service-account-dialog.tsx @@ -0,0 +1,302 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { orderBy } from 'lodash'; +import { create } from '@bufbuild/protobuf'; +import { useQueryClient } from '@tanstack/react-query'; +import { + useMutation, + useQuery, + createConnectQueryKey, + useTransport +} from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + CreateServiceUserRequestSchema, + CreatePolicyForProjectRequestSchema, + CreateServiceUserTokenRequestSchema, + ListOrganizationServiceUsersRequestSchema, + ListOrganizationProjectsRequestSchema, + ListServiceUserTokensRequestSchema, + ListServiceUserTokensResponseSchema, + ServiceUserRequestBodySchema, + CreatePolicyForProjectBodySchema +} from '@raystack/proton/frontier'; +import { + Button, + Text, + Dialog, + Flex, + InputField, + Label, + Select, + Skeleton, + toastManager +} from '@raystack/apsara-v1'; +import { PERMISSIONS } from '../../../../utils'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useTerminology } from '../../../hooks/useTerminology'; + +const DEFAULT_KEY_NAME = 'Initial Generated Key'; + +const serviceAccountSchema = yup + .object({ + title: yup.string().required('Name is a required field'), + project_ids: yup + .array() + .of(yup.string().required()) + .min(1, 'At least one project is required') + .required('Project is a required field') + }) + .required(); + +type FormData = yup.InferType; + +export interface AddServiceAccountDialogProps { + handle: ReturnType; + onCreated?: (serviceUserId: string) => void; +} + +export function AddServiceAccountDialog({ + handle, + onCreated +}: AddServiceAccountDialogProps) { + const { activeOrganization: organization } = useFrontier(); + const t = useTerminology(); + const queryClient = useQueryClient(); + const transport = useTransport(); + + const orgId = organization?.id || ''; + + const { + register, + control, + handleSubmit, + reset, + formState: { errors, isSubmitting, isDirty } + } = useForm({ + resolver: yupResolver(serviceAccountSchema), + defaultValues: { + title: '', + project_ids: [] + } + }); + + const handleOpenChange = (open: boolean) => { + if (!open) { + reset(); + } + }; + + const { data: projectsData, isLoading: isProjectsLoading } = useQuery( + FrontierServiceQueries.listOrganizationProjects, + create(ListOrganizationProjectsRequestSchema, { + id: orgId, + state: '', + withMemberCount: false + }), + { + enabled: Boolean(orgId) + } + ); + + const projects = useMemo(() => { + const list = projectsData?.projects ?? []; + return orderBy(list, ['title'], ['asc']); + }, [projectsData]); + + const { mutateAsync: createServiceUser } = useMutation( + FrontierServiceQueries.createServiceUser + ); + + const { mutateAsync: createPolicyForProject } = useMutation( + FrontierServiceQueries.createPolicyForProject + ); + + const { mutateAsync: createServiceUserToken } = useMutation( + FrontierServiceQueries.createServiceUserToken + ); + + const onSubmit = useCallback( + async (data: FormData) => { + if (!orgId) return; + + try { + const serviceUserResponse = await createServiceUser( + create(CreateServiceUserRequestSchema, { + orgId, + body: create(ServiceUserRequestBodySchema, { + title: data.title + }) + }) + ); + + const serviceUserId = serviceUserResponse.serviceuser?.id; + if (!serviceUserId) return; + + const principal = `${PERMISSIONS.ServiceUserPrincipal}:${serviceUserId}`; + + await Promise.all( + data.project_ids.map(projectId => + createPolicyForProject( + create(CreatePolicyForProjectRequestSchema, { + projectId, + body: create(CreatePolicyForProjectBodySchema, { + roleId: PERMISSIONS.RoleProjectOwner, + principal + }) + }) + ) + ) + ); + + const tokenResponse = await createServiceUserToken( + create(CreateServiceUserTokenRequestSchema, { + orgId, + id: serviceUserId, + title: DEFAULT_KEY_NAME + }) + ); + + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listOrganizationServiceUsers, + transport, + input: create(ListOrganizationServiceUsersRequestSchema, { + id: orgId + }), + cardinality: 'finite' + }) + }); + + const listTokensQueryKey = createConnectQueryKey({ + schema: FrontierServiceQueries.listServiceUserTokens, + transport, + input: create(ListServiceUserTokensRequestSchema, { + id: serviceUserId, + orgId + }), + cardinality: 'finite' + }); + + queryClient.setQueryData( + listTokensQueryKey, + create(ListServiceUserTokensResponseSchema, { + tokens: tokenResponse.token ? [tokenResponse.token] : [] + }) + ); + + toastManager.add({ title: 'Service account created', type: 'success' }); + handle.close(); + reset(); + onCreated?.(serviceUserId); + } catch (error: unknown) { + toastManager.add({ + title: 'Something went wrong', + description: error instanceof Error ? error.message : 'Unknown error', + type: 'error' + }); + } + }, + [ + orgId, + createServiceUser, + createPolicyForProject, + createServiceUserToken, + queryClient, + transport, + handle, + reset, + onCreated + ] + ); + + return ( + + +
+ + New Service Account + + + + + Create a dedicated service account to facilitate secure API + interactions on behalf of the{' '} + {t.organization({ case: 'lower' })}. + + {isProjectsLoading ? ( + + + + + ) : ( + <> + + + + ( + + )} + /> + {errors.project_ids && ( + + {String(errors.project_ids?.message)} + + )} + + + )} + + + + + + + +
+
+
+ ); +} diff --git a/web/sdk/react/views-new/service-accounts/components/add-token-form.tsx b/web/sdk/react/views-new/service-accounts/components/add-token-form.tsx new file mode 100644 index 000000000..6c2cc511d --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/components/add-token-form.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useCallback } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { Flex, Button, InputField, toastManager } from '@raystack/apsara-v1'; +import { useMutation } from '@connectrpc/connect-query'; +import { create } from '@bufbuild/protobuf'; +import { + FrontierServiceQueries, + CreateServiceUserTokenRequestSchema, + type ServiceUserToken +} from '@raystack/proton/frontier'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import styles from '../service-account-details-view.module.css'; + +const tokenSchema = yup + .object({ + title: yup.string().required('Name is a required field') + }) + .required(); + +type FormData = yup.InferType; + +export interface AddTokenFormProps { + serviceUserId: string; + onAddToken: (token: ServiceUserToken) => void; +} + +export function AddTokenForm({ serviceUserId, onAddToken }: AddTokenFormProps) { + const { activeOrganization } = useFrontier(); + const orgId = activeOrganization?.id || ''; + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting } + } = useForm({ + resolver: yupResolver(tokenSchema) + }); + + const { mutateAsync: createServiceUserToken } = useMutation( + FrontierServiceQueries.createServiceUserToken + ); + + const onSubmit = useCallback( + async (data: FormData) => { + try { + const response = await createServiceUserToken( + create(CreateServiceUserTokenRequestSchema, { + orgId, + id: serviceUserId, + title: data.title + }) + ); + if (response.token) { + onAddToken(response.token); + reset(); + toastManager.add({ title: 'API key created', type: 'success' }); + } + } catch (error: unknown) { + toastManager.add({ + title: 'Something went wrong', + description: error instanceof Error ? error.message : 'Unknown error', + type: 'error' + }); + } + }, + [createServiceUserToken, onAddToken, serviceUserId, orgId, reset] + ); + + return ( +
+ + + + +
+ ); +} diff --git a/web/sdk/react/views-new/service-accounts/components/delete-service-account-dialog.tsx b/web/sdk/react/views-new/service-accounts/components/delete-service-account-dialog.tsx new file mode 100644 index 000000000..57f2c8e65 --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/components/delete-service-account-dialog.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useState } from 'react'; +import { create } from '@bufbuild/protobuf'; +import { useMutation, createConnectQueryKey, useTransport } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + DeleteServiceUserRequestSchema, + ListOrganizationServiceUsersRequestSchema +} from '@raystack/proton/frontier'; +import { + Button, + Text, + AlertDialog, + Flex, + toastManager +} from '@raystack/apsara-v1'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useQueryClient } from '@tanstack/react-query'; + +export type DeleteServiceAccountPayload = { serviceAccountId: string }; + +export interface DeleteServiceAccountDialogProps { + handle: ReturnType>; + refetch: () => void; +} + +export function DeleteServiceAccountDialog({ handle, refetch }: DeleteServiceAccountDialogProps) { + const { activeOrganization } = useFrontier(); + const orgId = activeOrganization?.id ?? ''; + const queryClient = useQueryClient(); + const transport = useTransport(); + const [isLoading, setIsLoading] = useState(false); + + const { mutateAsync: deleteServiceUser } = useMutation( + FrontierServiceQueries.deleteServiceUser + ); + + const handleDelete = async (serviceAccountId: string) => { + setIsLoading(true); + try { + await deleteServiceUser( + create(DeleteServiceUserRequestSchema, { + id: serviceAccountId, + orgId + }) + ); + + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listOrganizationServiceUsers, + transport, + input: create(ListOrganizationServiceUsersRequestSchema, { + id: orgId + }), + cardinality: 'finite' + }) + }); + + handle.close(); + refetch(); + toastManager.add({ title: 'Service account deleted', type: 'success' }); + } catch (error: unknown) { + toastManager.add({ + title: 'Unable to delete service account', + description: error instanceof Error ? error.message : 'Unknown error', + type: 'error' + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + {({ payload: rawPayload }) => { + const payload = rawPayload as DeleteServiceAccountPayload | undefined; + return ( + + + Delete Service Account + + + + This action is irreversible and may result in the deletion of all + keys associated with this account. Are you sure you want to + proceed? + + + + + + + + + + ); + }} + + ); +} diff --git a/web/sdk/react/views-new/service-accounts/components/manage-project-access-dialog.tsx b/web/sdk/react/views-new/service-accounts/components/manage-project-access-dialog.tsx new file mode 100644 index 000000000..2860c4780 --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/components/manage-project-access-dialog.tsx @@ -0,0 +1,374 @@ +'use client'; + +import { useCallback, useState, useEffect, useMemo } from 'react'; +import { + Checkbox, + Flex, + Spinner, + Text, + Dialog, + DataTable, + Select, + toastManager +} from '@raystack/apsara-v1'; +import type { DataTableColumnDef } from '@raystack/apsara-v1'; +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { create } from '@bufbuild/protobuf'; +import { + FrontierServiceQueries, + ListServiceUserProjectsRequestSchema, + ListOrganizationProjectsRequestSchema, + CreatePolicyForProjectRequestSchema, + CreatePolicyForProjectBodySchema, + ListPoliciesRequestSchema, + DeletePolicyRequestSchema, + type Project, + type Policy +} from '@raystack/proton/frontier'; +import { orderBy } from 'lodash'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { PERMISSIONS } from '../../../../utils'; +import styles from '../service-account-details-view.module.css'; + +const PROJECT_ROLES = [ + { value: PERMISSIONS.RoleProjectViewer, label: 'Viewer' }, + { value: PERMISSIONS.RoleProjectOwner, label: 'Owner' } +]; + +type ProjectAccessMap = Record< + string, + { value: boolean; isLoading: boolean; roleId: string } +>; + +function getColumns({ + permMap, + onCheckChange, + onRoleChange +}: { + permMap: ProjectAccessMap; + onCheckChange: (projectId: string, value: boolean) => void; + onRoleChange: (projectId: string, roleId: string) => void; +}): DataTableColumnDef[] { + return [ + { + header: '', + id: 'checkbox', + accessorKey: 'id', + enableSorting: false, + cell: ({ getValue }) => { + const projectId = getValue() as string; + const entry = permMap[projectId]; + const isLoading = entry?.isLoading; + const checked = entry?.value ?? false; + return ( + + {isLoading ? ( + + ) : ( + onCheckChange(projectId, v === true)} + /> + )} + + ); + } + }, + { + header: 'Project Name', + accessorKey: 'title', + cell: ({ getValue }) => ( + {getValue() as string} + ) + }, + { + header: 'Access', + id: 'access', + accessorKey: 'id', + enableSorting: false, + cell: ({ getValue }) => { + const projectId = getValue() as string; + const entry = permMap[projectId]; + const isChecked = entry?.value ?? false; + const roleId = entry?.roleId || PERMISSIONS.RoleProjectViewer; + return ( + + ); + } + } + ]; +} + +export interface ManageProjectAccessDialogProps { + handle: ReturnType; + serviceUserId: string; +} + +export function ManageProjectAccessDialog({ + handle, + serviceUserId +}: ManageProjectAccessDialogProps) { + const { activeOrganization: organization } = useFrontier(); + const orgId = organization?.id || ''; + + const [isOpen, setIsOpen] = useState(false); + const [accessMap, setAccessMap] = useState({}); + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + }; + + const { data: projectsData, isLoading: isProjectsLoading } = useQuery( + FrontierServiceQueries.listOrganizationProjects, + create(ListOrganizationProjectsRequestSchema, { + id: orgId, + state: '', + withMemberCount: false + }), + { enabled: Boolean(orgId) && isOpen } + ); + + const projects = useMemo(() => { + const list = projectsData?.projects ?? []; + return orderBy(list, ['title'], ['asc']); + }, [projectsData]); + + const { data: addedProjectsData, isLoading: isAddedProjectsLoading } = + useQuery( + FrontierServiceQueries.listServiceUserProjects, + create(ListServiceUserProjectsRequestSchema, { + id: serviceUserId, + orgId, + withPermissions: [] + }), + { enabled: Boolean(serviceUserId) && Boolean(orgId) && isOpen } + ); + + const addedProjects = useMemo( + () => addedProjectsData?.projects ?? [], + [addedProjectsData] + ); + + useEffect(() => { + const permMap = addedProjects.reduce((acc, proj) => { + acc[proj?.id || ''] = { + value: true, + isLoading: false, + roleId: PERMISSIONS.RoleProjectOwner + }; + return acc; + }, {} as ProjectAccessMap); + setAccessMap(permMap); + }, [addedProjects]); + + const { mutateAsync: createPolicyForProject } = useMutation( + FrontierServiceQueries.createPolicyForProject + ); + + const { mutateAsync: listPolicies } = useMutation( + FrontierServiceQueries.listPolicies + ); + + const { mutateAsync: deletePolicy } = useMutation( + FrontierServiceQueries.deletePolicy + ); + + const onCheckChange = useCallback( + async (projectId: string, value: boolean) => { + try { + setAccessMap(prev => ({ + ...prev, + [projectId]: { + ...prev[projectId], + isLoading: true, + roleId: prev[projectId]?.roleId || PERMISSIONS.RoleProjectViewer + } + })); + + if (value) { + const roleId = + accessMap[projectId]?.roleId || PERMISSIONS.RoleProjectViewer; + const principal = `${PERMISSIONS.ServiceUserPrincipal}:${serviceUserId}`; + await createPolicyForProject( + create(CreatePolicyForProjectRequestSchema, { + projectId, + body: create(CreatePolicyForProjectBodySchema, { + roleId, + principal + }) + }) + ); + setAccessMap(prev => ({ + ...prev, + [projectId]: { value: true, isLoading: false, roleId } + })); + } else { + const policiesResp = await listPolicies( + create(ListPoliciesRequestSchema, { + projectId, + userId: serviceUserId, + orgId: '', + roleId: '', + groupId: '' + }) + ); + const policies = policiesResp?.policies || []; + await Promise.all( + policies.map((p: Policy) => + deletePolicy(create(DeletePolicyRequestSchema, { id: p.id })) + ) + ); + setAccessMap(prev => ({ + ...prev, + [projectId]: { + value: false, + isLoading: false, + roleId: prev[projectId]?.roleId || PERMISSIONS.RoleProjectViewer + } + })); + } + } catch (error: unknown) { + toastManager.add({ + title: 'Unable to update project access', + description: error instanceof Error ? error.message : 'Unknown error', + type: 'error' + }); + setAccessMap(prev => ({ + ...prev, + [projectId]: { ...prev[projectId], isLoading: false } + })); + } + }, + [ + serviceUserId, + accessMap, + createPolicyForProject, + listPolicies, + deletePolicy + ] + ); + + const onRoleChange = useCallback( + async (projectId: string, roleId: string) => { + const entry = accessMap[projectId]; + if (!entry?.value) { + setAccessMap(prev => ({ + ...prev, + [projectId]: { ...prev[projectId], roleId } + })); + return; + } + + try { + setAccessMap(prev => ({ + ...prev, + [projectId]: { ...prev[projectId], isLoading: true, roleId } + })); + + const policiesResp = await listPolicies( + create(ListPoliciesRequestSchema, { + projectId, + userId: serviceUserId, + orgId: '', + roleId: '', + groupId: '' + }) + ); + const policies = policiesResp?.policies || []; + await Promise.all( + policies.map((p: Policy) => + deletePolicy(create(DeletePolicyRequestSchema, { id: p.id })) + ) + ); + + const principal = `${PERMISSIONS.ServiceUserPrincipal}:${serviceUserId}`; + await createPolicyForProject( + create(CreatePolicyForProjectRequestSchema, { + projectId, + body: create(CreatePolicyForProjectBodySchema, { + roleId, + principal + }) + }) + ); + + setAccessMap(prev => ({ + ...prev, + [projectId]: { value: true, isLoading: false, roleId } + })); + } catch (error: unknown) { + toastManager.add({ + title: 'Unable to update project role', + description: error instanceof Error ? error.message : 'Unknown error', + type: 'error' + }); + setAccessMap(prev => ({ + ...prev, + [projectId]: { ...prev[projectId], isLoading: false } + })); + } + }, + [ + serviceUserId, + accessMap, + createPolicyForProject, + listPolicies, + deletePolicy + ] + ); + + const columns = useMemo( + () => + getColumns({ + permMap: accessMap, + onCheckChange, + onRoleChange + }), + [accessMap, onCheckChange, onRoleChange] + ); + + const isLoading = isProjectsLoading || isAddedProjectsLoading; + + return ( + + + + Manage Project Access + + + + + Note: Choose a project to join and specify the access type. + + + + + + + + + ); +} diff --git a/web/sdk/react/views-new/service-accounts/components/projects-cell.tsx b/web/sdk/react/views-new/service-accounts/components/projects-cell.tsx new file mode 100644 index 000000000..fb144c56e --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/components/projects-cell.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useMemo } from 'react'; +import { Text } from '@raystack/apsara-v1'; +import { useQuery } from '@connectrpc/connect-query'; +import { create } from '@bufbuild/protobuf'; +import { + FrontierServiceQueries, + ListServiceUserProjectsRequestSchema +} from '@raystack/proton/frontier'; + +interface ProjectsCellProps { + serviceUserId: string; + orgId: string; +} + +export function ProjectsCell({ serviceUserId, orgId }: ProjectsCellProps) { + const { data } = useQuery( + FrontierServiceQueries.listServiceUserProjects, + create(ListServiceUserProjectsRequestSchema, { + id: serviceUserId, + orgId, + withPermissions: [] + }), + { + enabled: Boolean(serviceUserId) && Boolean(orgId) + } + ); + + const projectNames = useMemo(() => { + const projects = data?.projects ?? []; + return projects.map(p => p.title).join(', '); + }, [data]); + + return ( + + {projectNames || '-'} + + ); +} diff --git a/web/sdk/react/views-new/service-accounts/components/revoke-token-dialog.tsx b/web/sdk/react/views-new/service-accounts/components/revoke-token-dialog.tsx new file mode 100644 index 000000000..b8fec3b82 --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/components/revoke-token-dialog.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useState } from 'react'; +import { create } from '@bufbuild/protobuf'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + DeleteServiceUserTokenRequestSchema +} from '@raystack/proton/frontier'; +import { + Button, + Text, + AlertDialog, + Flex, + toastManager +} from '@raystack/apsara-v1'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { useTerminology } from '../../../hooks/useTerminology'; + +export type RevokeTokenPayload = { tokenId: string }; + +export interface RevokeTokenDialogProps { + handle: ReturnType>; + serviceUserId: string; + onRevoked: (tokenId: string) => void; +} + +export function RevokeTokenDialog({ + handle, + serviceUserId, + onRevoked +}: RevokeTokenDialogProps) { + const { activeOrganization } = useFrontier(); + const orgId = activeOrganization?.id ?? ''; + const t = useTerminology(); + const [isLoading, setIsLoading] = useState(false); + + const { mutateAsync: deleteServiceUserToken } = useMutation( + FrontierServiceQueries.deleteServiceUserToken + ); + + const handleRevoke = async (tokenId: string) => { + setIsLoading(true); + try { + await deleteServiceUserToken( + create(DeleteServiceUserTokenRequestSchema, { + id: serviceUserId, + tokenId, + orgId + }) + ); + onRevoked(tokenId); + handle.close(); + toastManager.add({ title: 'Service account key revoked', type: 'success' }); + } catch (error: unknown) { + toastManager.add({ + title: 'Unable to revoke service account key', + description: error instanceof Error ? error.message : 'Unknown error', + type: 'error' + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + {({ payload: rawPayload }) => { + const payload = rawPayload as RevokeTokenPayload | undefined; + return ( + + + Revoke API Key + + + + This is an irreversible action doing this might lead to + discontinuation of access to the {t.appName()} features. Do you + wish to proceed? + + + + + + + + + + ); + }} + + ); +} diff --git a/web/sdk/react/views-new/service-accounts/components/service-account-columns.tsx b/web/sdk/react/views-new/service-accounts/components/service-account-columns.tsx new file mode 100644 index 000000000..a2a21439d --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/components/service-account-columns.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { DotsVerticalIcon } from '@radix-ui/react-icons'; +import { + Flex, + Text, + Menu, + IconButton, + DataTableColumnDef +} from '@raystack/apsara-v1'; +import type { ServiceUser } from '@raystack/proton/frontier'; +import type { Timestamp } from '@bufbuild/protobuf/wkt'; +import { timestampToDayjs } from '../../../../utils/timestamp'; +import { ProjectsCell } from './projects-cell'; + +export interface ServiceAccountMenuPayload { + serviceAccountId: string; + canManageAccess: boolean; + canDelete: boolean; +} + +type MenuHandle = ReturnType; + +interface GetColumnsOptions { + dateFormat: string; + menuHandle: MenuHandle; + canUpdateWorkspace: boolean; + orgId: string; +} + +export const getColumns = ({ + dateFormat, + menuHandle, + canUpdateWorkspace, + orgId +}: GetColumnsOptions): DataTableColumnDef[] => [ + { + header: 'Name', + accessorKey: 'title', + cell: ({ getValue }) => { + const value = getValue() as string; + return {value}; + } + }, + { + header: 'Projects', + id: 'projects', + accessorKey: 'id', + enableSorting: false, + cell: ({ getValue }) => { + const serviceUserId = getValue() as string; + return ; + } + }, + { + header: 'Created On', + accessorKey: 'createdAt', + cell: ({ getValue }) => { + const value = getValue() as Timestamp | undefined; + return ( + + {timestampToDayjs(value)?.format(dateFormat) ?? '-'} + + ); + } + }, + { + header: '', + id: 'actions', + accessorKey: 'id', + enableSorting: false, + styles: { + cell: { width: '48px' } + }, + cell: ({ getValue }) => { + const serviceAccountId = getValue() as string; + + if (!canUpdateWorkspace) return null; + + return ( + + + } + > + + + + ); + } + } +]; diff --git a/web/sdk/react/views-new/service-accounts/index.ts b/web/sdk/react/views-new/service-accounts/index.ts new file mode 100644 index 000000000..4b1514be4 --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/index.ts @@ -0,0 +1,4 @@ +export { ServiceAccountsView } from './service-accounts-view'; +export type { ServiceAccountsViewProps } from './service-accounts-view'; +export { ServiceAccountDetailsView } from './service-account-details-view'; +export type { ServiceAccountDetailsViewProps } from './service-account-details-view'; diff --git a/web/sdk/react/views-new/service-accounts/service-account-details-view.module.css b/web/sdk/react/views-new/service-accounts/service-account-details-view.module.css new file mode 100644 index 000000000..e9d71e182 --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/service-account-details-view.module.css @@ -0,0 +1,58 @@ +.addTokenRow { + gap: var(--rs-space-10); +} + +.addTokenInput { + flex: 1; +} + +.tokenList { + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + overflow: hidden; +} + +.tokenItem { + padding: var(--rs-space-5); + border-bottom: 1px solid var(--rs-color-border-base-primary); +} + +.tokenItem:last-child { + border-bottom: none; +} + +.tokenBox { + padding: var(--rs-space-3) var(--rs-space-4); + border-radius: var(--rs-radius-2); + border: 1px solid var(--rs-color-border-base-primary); + background: var(--rs-color-background-base-primary-hover); +} + +.tokenText { + max-width: 90%; + word-break: break-all; +} + +.menuContent { + min-width: 180px; +} + +.manageAccessDialogContent { + max-height: 80vh; +} + +.manageAccessDialogBody { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.manageAccessTableRoot { + border: none; + max-height: 560px; + overflow: auto; +} + +.accessSelectTrigger { + min-width: 120px; +} diff --git a/web/sdk/react/views-new/service-accounts/service-account-details-view.tsx b/web/sdk/react/views-new/service-accounts/service-account-details-view.tsx new file mode 100644 index 000000000..e69b4cec5 --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/service-account-details-view.tsx @@ -0,0 +1,327 @@ +'use client'; + +import { useState, useMemo, useCallback, MouseEvent } from 'react'; +import { + DotsHorizontalIcon, + CheckCircledIcon, + CopyIcon +} from '@radix-ui/react-icons'; +import { + Breadcrumb, + Skeleton, + Flex, + Text, + Button, + Menu, + AlertDialog, + Dialog, + IconButton, + Image +} from '@raystack/apsara-v1'; +import deleteIcon from '../../assets/delete.svg'; +import { useQuery } from '@connectrpc/connect-query'; +import { create } from '@bufbuild/protobuf'; +import { + FrontierServiceQueries, + GetServiceUserRequestSchema, + type ServiceUserToken +} from '@raystack/proton/frontier'; +import { useFrontier } from '../../contexts/FrontierContext'; +import { useTerminology } from '../../hooks/useTerminology'; +import { usePermissions } from '../../hooks/usePermissions'; +import { useCopyToClipboard } from '../../hooks/useCopyToClipboard'; +import { PERMISSIONS, shouldShowComponent } from '../../../utils'; +import { useServiceUserTokens } from '../../views/api-keys/hooks/useServiceUserTokens'; +import { ViewContainer } from '../../components/view-container'; +import { ViewHeader } from '../../components/view-header'; +import { AddTokenForm } from './components/add-token-form'; +import { + RevokeTokenDialog, + type RevokeTokenPayload +} from './components/revoke-token-dialog'; +import { + DeleteServiceAccountDialog, + type DeleteServiceAccountPayload +} from './components/delete-service-account-dialog'; +import { ManageProjectAccessDialog } from './components/manage-project-access-dialog'; +import styles from './service-account-details-view.module.css'; + +const actionsMenuHandle = Menu.createHandle(); +const revokeTokenDialogHandle = AlertDialog.createHandle(); +const deleteDialogHandle = AlertDialog.createHandle(); +const manageAccessDialogHandle = Dialog.createHandle(); + +export interface ServiceAccountDetailsViewProps { + serviceAccountId: string; + serviceAccountsLabel?: string; + onNavigateToServiceAccounts?: () => void; + onDeleteSuccess?: () => void; +} + +export function ServiceAccountDetailsView({ + serviceAccountId, + serviceAccountsLabel = 'Service accounts', + onNavigateToServiceAccounts, + onDeleteSuccess +}: ServiceAccountDetailsViewProps) { + const { activeOrganization: organization } = useFrontier(); + const t = useTerminology(); + const orgId = organization?.id || ''; + + const { data: serviceUser, isLoading: isServiceUserLoading } = useQuery( + FrontierServiceQueries.getServiceUser, + create(GetServiceUserRequestSchema, { + id: serviceAccountId, + orgId + }), + { + enabled: Boolean(serviceAccountId) && Boolean(orgId), + select: data => data?.serviceuser + } + ); + + const { + tokens: serviceUserTokens, + isLoading: isTokensLoading, + addToken, + removeToken + } = useServiceUserTokens({ + id: serviceAccountId, + orgId, + enableFetch: true + }); + + const resource = `app/organization:${orgId}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { permission: PERMISSIONS.UpdatePermission, resource } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!orgId + ); + + const canUpdateWorkspace = useMemo( + () => + shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ), + [permissions, resource] + ); + + const isLoading = isServiceUserLoading || isTokensLoading || isPermissionsFetching; + + const serviceAccountTitle = serviceUser?.title || ''; + + const handleDeleteSuccess = useCallback(() => { + onDeleteSuccess?.(); + }, [onDeleteSuccess]); + + return ( + + + { + e.preventDefault(); + onNavigateToServiceAccounts?.(); + }} + > + {serviceAccountsLabel} + + + + {isServiceUserLoading ? ( + + ) : ( + serviceAccountTitle + )} + + + } + > + {!isLoading && canUpdateWorkspace && ( + + )} + + + + {isServiceUserLoading ? ( + + ) : ( + + Create API key for accessing {t.appName()} and its features + + )} + + + + {serviceUserTokens.length > 0 && ( + + )} + + + + + + + ); +} + +interface ActionsMenuProps { + serviceAccountId: string; +} + +function ActionsMenu({ serviceAccountId }: ActionsMenuProps) { + return ( + <> + + } + > + + + + + manageAccessDialogHandle.open(null)} + data-test-id="frontier-sdk-service-account-manage-access-btn" + > + Manage access + + + } + onClick={() => + deleteDialogHandle.openWithPayload({ + serviceAccountId + }) + } + data-test-id="frontier-sdk-service-account-delete-btn" + style={{ color: 'var(--rs-color-foreground-danger-primary)' }} + > + Delete account + + + + + ); +} + +function TokenList({ + tokens, + isLoading +}: { + tokens: ServiceUserToken[]; + isLoading: boolean; +}) { + if (isLoading) { + return ( + + + + + ); + } + + return ( +
+ {tokens.map(token => ( + + ))} +
+ ); +} + +function TokenItem({ token }: { token: ServiceUserToken }) { + const [isCopied, setIsCopied] = useState(false); + const { copy } = useCopyToClipboard(); + + const encodedToken = 'Basic ' + btoa(`${token?.id}:${token?.token}`); + + async function onCopy() { + const res = await copy(encodedToken); + if (res) { + setIsCopied(true); + setTimeout(() => setIsCopied(false), 1000); + } + } + + return ( + + + + {token?.title} + + + + {token?.token ? ( + + + Note: Please save your key securely, it cannot be recovered after + leaving this page + + + + {encodedToken} + + {isCopied ? ( + + ) : ( + + )} + + + ) : null} + + ); +} diff --git a/web/sdk/react/views-new/service-accounts/service-accounts-view.module.css b/web/sdk/react/views-new/service-accounts/service-accounts-view.module.css new file mode 100644 index 000000000..71f576f85 --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/service-accounts-view.module.css @@ -0,0 +1,7 @@ +.tableRoot { + border: none; +} + +.menuContent { + min-width: 180px; +} diff --git a/web/sdk/react/views-new/service-accounts/service-accounts-view.tsx b/web/sdk/react/views-new/service-accounts/service-accounts-view.tsx new file mode 100644 index 000000000..caaaeaae7 --- /dev/null +++ b/web/sdk/react/views-new/service-accounts/service-accounts-view.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { ExclamationTriangleIcon, KeyboardIcon, TrashIcon } from '@radix-ui/react-icons'; +import { + Button, + Tooltip, + Skeleton, + Flex, + EmptyState, + DataTable, + Dialog, + AlertDialog, + Menu +} from '@raystack/apsara-v1'; +import { useQuery } from '@connectrpc/connect-query'; +import { create } from '@bufbuild/protobuf'; +import { + FrontierServiceQueries, + ListOrganizationServiceUsersRequestSchema +} from '@raystack/proton/frontier'; +import { useFrontier } from '../../contexts/FrontierContext'; +import { usePermissions } from '../../hooks/usePermissions'; +import { useTerminology } from '../../hooks/useTerminology'; +import { AuthTooltipMessage } from '../../utils'; +import { PERMISSIONS, shouldShowComponent } from '../../../utils'; +import { DEFAULT_DATE_FORMAT } from '../../utils/constants'; +import { ViewContainer } from '../../components/view-container'; +import { ViewHeader } from '../../components/view-header'; +import { + getColumns, + type ServiceAccountMenuPayload +} from './components/service-account-columns'; +import { AddServiceAccountDialog } from './components/add-service-account-dialog'; +import { + DeleteServiceAccountDialog, + type DeleteServiceAccountPayload +} from './components/delete-service-account-dialog'; +import { ManageProjectAccessDialog } from './components/manage-project-access-dialog'; +import styles from './service-accounts-view.module.css'; + +const serviceAccountMenuHandle = Menu.createHandle(); +const addDialogHandle = Dialog.createHandle(); +const deleteDialogHandle = AlertDialog.createHandle(); +const manageAccessDialogHandle = Dialog.createHandle(); + +export interface ServiceAccountsViewProps { + onServiceAccountClick?: (id: string) => void; +} + +export function ServiceAccountsView({ + onServiceAccountClick +}: ServiceAccountsViewProps) { + const { + activeOrganization: organization, + isActiveOrganizationLoading, + config + } = useFrontier(); + const t = useTerminology(); + + const resource = `app/organization:${organization?.id}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.UpdatePermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!organization?.id + ); + + const canUpdateWorkspace = useMemo( + () => + shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ), + [permissions, resource] + ); + + const orgId = organization?.id ?? ''; + const [manageAccessServiceUserId, setManageAccessServiceUserId] = useState(''); + + const { + data: serviceUsersData, + isLoading: isServiceUsersLoading, + refetch + } = useQuery( + FrontierServiceQueries.listOrganizationServiceUsers, + create(ListOrganizationServiceUsersRequestSchema, { + id: orgId + }), + { + enabled: Boolean(orgId) && canUpdateWorkspace + } + ); + + const serviceUsers = useMemo( + () => serviceUsersData?.serviceusers ?? [], + [serviceUsersData] + ); + + const isPermissionsLoading = + isActiveOrganizationLoading || isPermissionsFetching; + + const isLoading = isPermissionsLoading || isServiceUsersLoading; + + const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT; + + const columns = useMemo( + () => + getColumns({ + dateFormat, + menuHandle: serviceAccountMenuHandle, + canUpdateWorkspace, + orgId + }), + [dateFormat, canUpdateWorkspace, orgId] + ); + + const handleCreated = (serviceUserId: string) => { + onServiceAccountClick?.(serviceUserId); + }; + + const handleRefetch = () => { + refetch(); + }; + + const hasNoAccess = !canUpdateWorkspace && !isPermissionsLoading; + const hasNoServiceAccounts = + canUpdateWorkspace && !isLoading && serviceUsers.length === 0; + + return ( + + + + {hasNoAccess ? ( + } + heading="Restricted Access" + subHeading="Admin access required, please reach out to your admin incase you want to generate a key." + /> + ) : hasNoServiceAccounts ? ( + } + heading="No Service Account Found" + subHeading={`Create a new account to use the APIs of ${t.appName()} platform`} + primaryAction={ + + } + /> + ) : ( + onServiceAccountClick?.(row.id)} + > + + + + {isLoading ? ( + + ) : ( + + )} + + {isLoading ? ( + + ) : ( + + } + > + + + {!canUpdateWorkspace && ( + {AuthTooltipMessage} + )} + + )} + + } + heading="No service accounts found" + subHeading="Try adjusting your search" + /> + } + classNames={{ + root: styles.tableRoot + }} + /> + + + )} + + + {({ payload: rawPayload }) => { + const payload = rawPayload as ServiceAccountMenuPayload | undefined; + return ( + + {payload?.canManageAccess && ( + } + onClick={() => { + if (payload) { + setManageAccessServiceUserId(payload.serviceAccountId); + manageAccessDialogHandle.open(null); + } + }} + data-test-id="frontier-sdk-manage-access-menu-item" + > + Manage Access + + )} + {payload?.canDelete && ( + } + onClick={() => + payload && + deleteDialogHandle.openWithPayload({ + serviceAccountId: payload.serviceAccountId + }) + } + data-test-id="frontier-sdk-delete-account-menu-item" + > + Delete Account + + )} + + ); + }} + + + + + {manageAccessServiceUserId && ( + + )} + + ); +} diff --git a/web/sdk/react/views-new/teams/components/add-member-menu.tsx b/web/sdk/react/views-new/teams/components/add-member-menu.tsx index 893672348..2728c204d 100644 --- a/web/sdk/react/views-new/teams/components/add-member-menu.tsx +++ b/web/sdk/react/views-new/teams/components/add-member-menu.tsx @@ -19,8 +19,8 @@ import { } from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; import { useFrontier } from '../../../contexts/FrontierContext'; -import { AuthTooltipMessage } from '~/react/utils'; -import { filterUsersfromUsers, getInitials } from '~/utils'; +import { AuthTooltipMessage } from '../../../utils'; +import { filterUsersfromUsers, getInitials } from '../../../../utils'; import styles from '../team-details-view.module.css'; interface AddMemberMenuProps {