From cc9f7eff4ac7de0236bf23d6b187f7ab5fc43971 Mon Sep 17 00:00:00 2001 From: aliang <1098486429@qq.com> Date: Sat, 14 Sep 2024 16:18:56 +0800 Subject: [PATCH] feat(ui, server): enable admin to generate user reset password links (#3140) * feat(ui, server): enable admin to generate user reset password links * [autofix.ci] apply automated fixes * update * update * [autofix.ci] apply automated fixes * update * Update ee/tabby-ui/app/(dashboard)/settings/team/components/user-table.tsx Co-authored-by: Meng Zhang * Update ee/tabby-ui/app/(dashboard)/settings/team/components/user-table.tsx Co-authored-by: Meng Zhang * update: text * update: lint --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Meng Zhang --- .../settings/team/components/user-table.tsx | 198 ++++++++++++++---- ee/tabby-webserver/src/service/auth.rs | 4 +- 2 files changed, 154 insertions(+), 48 deletions(-) diff --git a/ee/tabby-ui/app/(dashboard)/settings/team/components/user-table.tsx b/ee/tabby-ui/app/(dashboard)/settings/team/components/user-table.tsx index 012121a8cff0..c78862ce7506 100644 --- a/ee/tabby-ui/app/(dashboard)/settings/team/components/user-table.tsx +++ b/ee/tabby-ui/app/(dashboard)/settings/team/components/user-table.tsx @@ -1,6 +1,6 @@ 'use client' -import React from 'react' +import React, { MouseEventHandler, useEffect, useState } from 'react' import moment from 'moment' import { toast } from 'sonner' import { useQuery } from 'urql' @@ -8,19 +8,31 @@ import { useQuery } from 'urql' import { DEFAULT_PAGE_SIZE } from '@/lib/constants' import { graphql } from '@/lib/gql/generates' import type { ListUsersQuery } from '@/lib/gql/generates/graphql' +import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' import { useMe } from '@/lib/hooks/use-me' import { QueryVariables, useMutation } from '@/lib/tabby/gql' import { listSecuredUsers } from '@/lib/tabby/query' import type { ArrayElementType } from '@/lib/types' +import { cn } from '@/lib/utils' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' +import { Button, buttonVariants } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { IconMore } from '@/components/ui/icons' +import { IconMore, IconSpinner } from '@/components/ui/icons' import { Pagination, PaginationContent, @@ -46,6 +58,12 @@ const updateUserActiveMutation = graphql(/* GraphQL */ ` } `) +const generateResetPasswordUrlMutation = graphql(/* GraphQL */ ` + mutation generateResetPasswordUrl($userId: ID!) { + generateResetPasswordUrl(userId: $userId) + } +`) + type UserNode = ArrayElementType['node'] const PAGE_SIZE = DEFAULT_PAGE_SIZE @@ -148,49 +166,11 @@ export default function UsersTable() { {showOperation && ( - - - - - - {!!x.node.active && ( - onUpdateUserRole(x.node)} - className="cursor-pointer" - > - - {x.node.isAdmin - ? 'Downgrade to member' - : 'Upgrade to admin'} - - - )} - {!!x.node.active && ( - - onUpdateUserActive(x.node, false) - } - className="cursor-pointer" - > - Deactivate - - )} - {!x.node.active && ( - - onUpdateUserActive(x.node, true) - } - className="cursor-pointer" - > - Activate - - )} - - + )} @@ -243,3 +223,129 @@ export default function UsersTable() { ) } + +function OperationView({ + user, + onUpdateUserActive, + onUpdateUserRole +}: { + user: ArrayElementType + onUpdateUserActive: (node: UserNode, active: boolean) => void + onUpdateUserRole: (node: UserNode) => void +}) { + const [open, setOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + const onOpenChange = (open: boolean) => { + if (submitting) return + setOpen(open) + } + const { copyToClipboard, isCopied } = useCopyToClipboard({ + timeout: 1000 + }) + const generateResetPasswordUrl = useMutation(generateResetPasswordUrlMutation) + const handleGenerateResetPassworkURL: MouseEventHandler< + HTMLButtonElement + > = e => { + e.preventDefault() + if (submitting) return + + setSubmitting(true) + generateResetPasswordUrl({ userId: user.node.id }) + .then(res => { + const link = res?.data?.generateResetPasswordUrl + if (link) { + copyToClipboard(link) + setOpen(false) + } else { + toast.error( + res?.error?.message || 'Failed to generate password reset link' + ) + } + }) + .catch(error => { + toast.error(error?.message || 'Failed to generate password reset link') + }) + .finally(() => { + setSubmitting(false) + }) + } + + useEffect(() => { + if (isCopied) { + toast.success('Password reset link copied to clipboard') + } + }, [isCopied]) + + return ( + <> + + + + + + {!!user.node.active && ( + onUpdateUserRole(user.node)} + className="cursor-pointer" + > + + {user.node.isAdmin ? 'Downgrade to member' : 'Upgrade to admin'} + + + )} + {!!user.node.active && ( + onUpdateUserActive(user.node, false)} + className="cursor-pointer" + > + Deactivate + + )} + {!user.node.active && ( + onUpdateUserActive(user.node, true)} + className="cursor-pointer" + > + Activate + + )} + setOpen(true)} + className="cursor-pointer gap-1" + > + Reset password + + + + + + + Reset password + + By clicking {'"'}Yes{'"'}, a password reset link will be generated + for{' '} + + {user.node.name || user.node.email} + + . The password won't be modified until the user follows the + instructions in the link to make the change. + + + + Cancel + + {submitting && } + Yes + + + + + + ) +} diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index 4f638a6f990b..8df4f01f4f15 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -163,7 +163,7 @@ impl AuthenticationService for AuthenticationServiceImpl { bail!("Inactive user's password cannot be reset"); } let code = self.db.create_password_reset(id).await?; - let url = format!("{}/reset-password?code={}", external_url, code); + let url = format!("{}/auth/reset-password?code={}", external_url, code); Ok(url) } @@ -1651,7 +1651,7 @@ mod tests { .generate_reset_password_url(&active_user.id) .await .unwrap(); - assert!(url.contains("/reset-password?code=")); + assert!(url.contains("/auth/reset-password?code=")); // Create an inactive user let id = service