Skip to content

Commit

Permalink
feat(ui, server): enable admin to generate user reset password links (#…
Browse files Browse the repository at this point in the history
…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 <[email protected]>

* Update ee/tabby-ui/app/(dashboard)/settings/team/components/user-table.tsx

Co-authored-by: Meng Zhang <[email protected]>

* update: text

* update: lint

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Meng Zhang <[email protected]>
  • Loading branch information
3 people authored Sep 14, 2024
1 parent 6bddf96 commit cc9f7ef
Show file tree
Hide file tree
Showing 2 changed files with 154 additions and 48 deletions.
198 changes: 152 additions & 46 deletions ee/tabby-ui/app/(dashboard)/settings/team/components/user-table.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,38 @@
'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'

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,
Expand All @@ -46,6 +58,12 @@ const updateUserActiveMutation = graphql(/* GraphQL */ `
}
`)

const generateResetPasswordUrlMutation = graphql(/* GraphQL */ `
mutation generateResetPasswordUrl($userId: ID!) {
generateResetPasswordUrl(userId: $userId)
}
`)

type UserNode = ArrayElementType<ListUsersQuery['users']['edges']>['node']

const PAGE_SIZE = DEFAULT_PAGE_SIZE
Expand Down Expand Up @@ -148,49 +166,11 @@ export default function UsersTable() {
</TableCell>
<TableCell className="text-end">
{showOperation && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<IconMore />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
collisionPadding={{ right: 16 }}
>
{!!x.node.active && (
<DropdownMenuItem
onSelect={() => onUpdateUserRole(x.node)}
className="cursor-pointer"
>
<span className="ml-2">
{x.node.isAdmin
? 'Downgrade to member'
: 'Upgrade to admin'}
</span>
</DropdownMenuItem>
)}
{!!x.node.active && (
<DropdownMenuItem
onSelect={() =>
onUpdateUserActive(x.node, false)
}
className="cursor-pointer"
>
<span className="ml-2">Deactivate</span>
</DropdownMenuItem>
)}
{!x.node.active && (
<DropdownMenuItem
onSelect={() =>
onUpdateUserActive(x.node, true)
}
className="cursor-pointer"
>
<span className="ml-2">Activate</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<OperationView
user={x}
onUpdateUserActive={onUpdateUserActive}
onUpdateUserRole={onUpdateUserRole}
/>
)}
</TableCell>
</TableRow>
Expand Down Expand Up @@ -243,3 +223,129 @@ export default function UsersTable() {
</>
)
}

function OperationView({
user,
onUpdateUserActive,
onUpdateUserRole
}: {
user: ArrayElementType<ListUsersQuery['users']['edges']>
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 (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<IconMore />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent collisionPadding={{ right: 16 }}>
{!!user.node.active && (
<DropdownMenuItem
onSelect={() => onUpdateUserRole(user.node)}
className="cursor-pointer"
>
<span className="ml-2">
{user.node.isAdmin ? 'Downgrade to member' : 'Upgrade to admin'}
</span>
</DropdownMenuItem>
)}
{!!user.node.active && (
<DropdownMenuItem
onSelect={() => onUpdateUserActive(user.node, false)}
className="cursor-pointer"
>
<span className="ml-2">Deactivate</span>
</DropdownMenuItem>
)}
{!user.node.active && (
<DropdownMenuItem
onSelect={() => onUpdateUserActive(user.node, true)}
className="cursor-pointer"
>
<span className="ml-2">Activate</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => setOpen(true)}
className="cursor-pointer gap-1"
>
<span className="ml-2">Reset password</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset password</AlertDialogTitle>
<AlertDialogDescription>
By clicking {'"'}Yes{'"'}, a password reset link will be generated
for{' '}
<span className="font-bold">
{user.node.name || user.node.email}
</span>
. The password won&apos;t be modified until the user follows the
instructions in the link to make the change.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants(), 'gap-1')}
disabled={submitting}
onClick={handleGenerateResetPassworkURL}
>
{submitting && <IconSpinner />}
Yes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
4 changes: 2 additions & 2 deletions ee/tabby-webserver/src/service/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down

0 comments on commit cc9f7ef

Please sign in to comment.