Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions moon/apps/web/components/Setting/PersonalToken.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import React, {useState} from 'react'
import {LoadingSpinner, LockIcon, Button, PlusIcon} from '@gitmono/ui'
import {useGetTokenList} from '@/hooks/useGetTokenList'
import {usePostTokenGenerate} from '@/hooks/usePostTokenGenerate'
import {useDeleteTokenById} from '@/hooks/useDeleteTokenById'
import {useQueryClient} from '@tanstack/react-query'
import {legacyApiClient} from '@/utils/queryClient'
import {ListToken} from '@gitmono/types'
import toast from "react-hot-toast";
import HandleTime from "@/components/MrView/components/HandleTime";

const TokenItem = ({item}: { item: ListToken }) => {
const {mutate: deleteToken} = useDeleteTokenById()
const queryClient = useQueryClient()
const fetchTokenList = legacyApiClient.v1.getApiUserTokenList()

return (
<div className="flex items-center justify-between py-4 border-b border-gray-200 last:border-b-0">
<div className="flex items-start">
<LockIcon className="w-6 h-6 text-gray-400" aria-hidden="true"/>
<div className="ml-4">
<p className="text-base font-bold text-gray-900">Token #{item.id}</p>
<p className="text-sm font-mono text-gray-500 mt-1 break-all">{item.token}</p>
<p className="text-xs text-gray-500 mt-2">
<HandleTime created_at={item.created_at}/>
</p>
</div>
</div>
<button
onClick={() =>
deleteToken(
{keyId: item.id},
{
onSuccess: () => {
queryClient.invalidateQueries({queryKey: fetchTokenList.requestKey()})
}
}
)
}
className="px-4 py-1 text-sm font-semibold text-red-500 border border-gray-300 rounded-md hover:bg-red-500 hover:text-white transition-colors duration-200"
>
Delete
</button>
</div>
)
}

const PersonalToken = () => {
const {tokenList, isLoading} = useGetTokenList()
const {mutate: generateToken, isPending: isGenerating} = usePostTokenGenerate()
const queryClient = useQueryClient()
const fetchTokenList = legacyApiClient.v1.getApiUserTokenList()

const [generated, setGenerated] = useState<string | null>(null)
const [copied, setCopied] = useState(false)

const handleGenerate = () => {
generateToken(undefined, {
onSuccess: (result) => {
setGenerated(result?.data ?? null)
queryClient.invalidateQueries({queryKey: fetchTokenList.requestKey()})
}
})
}

const handleCopy = async () => {
if (!generated) return
if (navigator.clipboard) {
await navigator.clipboard
.writeText(generated)
.then(() => toast.success("Copied to clipboard"))
.catch(() => toast.error("Copied failed"))
} else {
const textArea = document
Comment thread
yumeowo marked this conversation as resolved.
.createElement('textarea')

textArea.value = generated
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
Comment thread
yumeowo marked this conversation as resolved.
toast.success('Copied to clipboard')
document.body.removeChild(textArea)
} catch {
toast.error("Copied failed")
}
}
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}

return (
<div className="bg-white text-gray-700 p-8 rounded-lg border border-gray-200 max-w-4xl mx-auto font-sans mt-8">
<header className="flex items-center justify-between pb-4">
<h1 className="text-3xl font-bold text-gray-900">Personal tokens</h1>
<Button
variant="primary"
leftSlot={<PlusIcon/>}
onClick={handleGenerate}
disabled={isGenerating}
loading={isGenerating}
className="bg-[#1f883d]"
>
New token
</Button>
</header>

<p className="mb-8">
This is a list of personal access tokens associated with your account. Remove any tokens that you do not
recognize.
</p>

{generated && (
<div className="mb-8 p-4 border border-green-200 rounded-md bg-green-50">
<p className="text-sm text-gray-700">Your new token has been generated.</p>
<div className="mt-2 flex items-center">
<code
className="px-3 py-2 bg-white border border-gray-200 rounded font-mono text-sm break-all flex-1">{generated}</code>
<Button variant="flat" className="ml-3" onClick={handleCopy}>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<p className="text-xs text-gray-500 mt-2">Make sure to copy your new token now. You won’t be able to see it
again.</p>
</div>
)}

<section>
<h2 className="text-xl font-semibold text-gray-900 pb-2 border-b border-gray-200">Tokens</h2>
{isLoading ? (
<div className="flex h-[400px] items-center justify-center">
<LoadingSpinner/>
</div>
) : (
<div>
{tokenList.map((item) => (
<TokenItem key={item.id} item={item}/>
))}
</div>
)}
</section>
</div>
)
}

export default PersonalToken
184 changes: 184 additions & 0 deletions moon/apps/web/components/Setting/SSHKeys.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React, {useState} from 'react';
import {LoadingSpinner, LockIcon, Button, TextField, PlusIcon} from '@gitmono/ui'
import * as Dialog from '@gitmono/ui/src/Dialog'
import {ListSSHKey} from "@gitmono/types";
import {useGetSSHList} from '@/hooks/useGetSSHList'
import {usePostSSHKey} from '@/hooks/usePostSSHKey'
import {useDeleteSSHKeyById} from '@/hooks/useDeleteSSHKeyById'
import {legacyApiClient} from "@/utils/queryClient";
import {useQueryClient} from "@tanstack/react-query";
import HandleTime from "@/components/MrView/components/HandleTime";

const SshKeyItem = ({keyData}: { keyData: ListSSHKey }) => {
const {mutate: deleteSSHKey} = useDeleteSSHKeyById()
const fetchSSHList = legacyApiClient.v1.getApiUserSshList()
const queryClient = useQueryClient()

return (
<div className="flex items-center justify-between py-4 border-b border-gray-200 last:border-b-0">
<div className="flex items-start">
<LockIcon className="w-6 h-6 text-gray-400" aria-hidden="true"/>
<div className="ml-4">
<p className="text-base font-bold text-gray-900">{keyData.title}</p>
<p className="text-sm font-mono text-gray-500 mt-1">{keyData.finger}</p>
<p className="text-xs text-gray-500 mt-2">
<HandleTime created_at={keyData.created_at}/>
</p>
</div>
</div>
<button
onClick={() => deleteSSHKey(
{keyId: keyData.id},
{
onSuccess: () => {
queryClient.invalidateQueries({queryKey: fetchSSHList.requestKey()})
}
})
}
className="px-4 py-1 text-sm font-semibold text-red-500 border border-gray-300 rounded-md hover:bg-red-500 hover:text-white transition-colors duration-200"
>
Delete
</button>
</div>
)
}

interface NewSSHKeyDialogProps {
open: boolean;
setOpen: (open: boolean) => void;
}

const NewSSHKeyDialog = ({open, setOpen}: NewSSHKeyDialogProps) => {
const {mutate: postSSHKey, isPending} = usePostSSHKey()
const [title, setTitle] = useState('')
const [sshKey, setSshKey] = useState('')
const [errors, setErrors] = useState<{ title?: string; sshKey?: string }>({})

const fetchSSHList = legacyApiClient.v1.getApiUserSshList()
const queryClient = useQueryClient()

const handleSubmit = (e?: React.FormEvent | React.MouseEvent) => {
if (e) e.preventDefault()
const nextErrors: { title?: string; sshKey?: string } = {}

if (!title.trim()) nextErrors.title = 'Title is required'
if (!sshKey.trim()) nextErrors.sshKey = 'SSH key is required'
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
postSSHKey(
{data: {title: title.trim(), ssh_key: sshKey}},
{
onSuccess: () => {
setOpen(false)
setTitle('')
setSshKey('')
setErrors({})

queryClient.invalidateQueries({queryKey: fetchSSHList.requestKey()})
}
}
)
}

return (
<Dialog.Root
open={open}
onOpenChange={setOpen}
visuallyHiddenDescription='Add a new SSH key'
>
<Dialog.Title className="p-4 w-full">
Add SSH key
</Dialog.Title>
<Dialog.Content className="p-4 w-full max-w-md">
<div className='mb-4'>
<TextField
autoFocus
label='title'
value={title}
onChange={setTitle}
/>
{errors.title && <span className='text-red-500 text-xs'>{errors.title}</span>}
</div>

<div className='mb-4'>
<TextField
placeholder='begins with "ssh-rsa" or "ssh-ed25519"'
multiline
minRows={5}
label='ssh_key'
value={sshKey}
onChange={setSshKey}
/>
{errors.sshKey && <span className='text-red-500 text-xs'>{errors.sshKey}</span>}
</div>
</Dialog.Content>

<Dialog.Footer>
<Dialog.TrailingActions>
<Button variant='flat' onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant='primary'
className="bg-[#1f883d]"
onClick={handleSubmit}
disabled={isPending || !title.trim() || !sshKey.trim()}
loading={isPending}
>
Add key
</Button>
</Dialog.TrailingActions>
</Dialog.Footer>
</Dialog.Root>
)
}

const SSHKeys = () => {
const {sshKeys, isLoading} = useGetSSHList()
const [open, setOpen] = useState(false)

return (
<>
<div className="bg-white text-gray-700 p-8 rounded-lg border border-gray-200 max-w-4xl mx-auto font-sans">
<header className="flex items-center justify-between pb-4">
<h1 className="text-3xl font-bold text-gray-900">SSH keys</h1>
<Button
variant='primary'
className="bg-[#1f883d]"
leftSlot={<PlusIcon/>}
onClick={() => setOpen(true)}
>
New SSH key
</Button>
</header>

<p className="mb-8">
This is a list of SSH keys associated with your account. Remove any keys that you do not recognize.
</p>

<section>
<h2 className="text-xl font-semibold text-gray-900 pb-2 border-b border-gray-200">
Authentication keys
</h2>
{isLoading ? (
<div className='flex h-[400px] items-center justify-center'>
<LoadingSpinner/>
</div>
) : (
<div>
{sshKeys?.map((key) => (
<SshKeyItem key={key.id} keyData={key}/>
))}
</div>
)}
</section>
</div>
<NewSSHKeyDialog
open={open}
setOpen={setOpen}
/>
</>
);
};

export default SSHKeys;
9 changes: 9 additions & 0 deletions moon/apps/web/hooks/useDeleteSSHKeyById.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useMutation } from '@tanstack/react-query'
import { DeleteApiUserSshByKeyIdData } from '@gitmono/types'
import { legacyApiClient } from '@/utils/queryClient'

export function useDeleteSSHKeyById() {
return useMutation<DeleteApiUserSshByKeyIdData, Error, { keyId: number }>({
mutationFn: ({ keyId }) => legacyApiClient.v1.deleteApiUserSshByKeyId().request(keyId)
})
}
9 changes: 9 additions & 0 deletions moon/apps/web/hooks/useDeleteTokenById.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useMutation } from '@tanstack/react-query'
import { DeleteApiUserTokenByKeyIdData } from '@gitmono/types'
import { legacyApiClient } from '@/utils/queryClient'

export function useDeleteTokenById() {
return useMutation<DeleteApiUserTokenByKeyIdData, Error, { keyId: number }>({
mutationFn: ({ keyId }) => legacyApiClient.v1.deleteApiUserTokenByKeyId().request(keyId)
})
}
33 changes: 33 additions & 0 deletions moon/apps/web/hooks/useGetSSHList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {legacyApiClient} from "@/utils/queryClient";
import {atomFamily} from "jotai/utils";
import {atomWithWebStorage} from "@/utils/atomWithWebStorage";
import {ListSSHKey} from "@gitmono/types";
import {useAtom} from "jotai";
import { useQuery } from '@tanstack/react-query'
import {useEffect} from 'react'

const fetchSSHList = legacyApiClient.v1.getApiUserSshList()
const getSSHListAtom = atomFamily(() =>
atomWithWebStorage<ListSSHKey[]>(`ssh-key`, [])
)

export const useGetSSHList = () => {
const [sshKeys, setSSHList] = useAtom(getSSHListAtom('ssh-key'))

const { data, isLoading, isPending, isFetching } = useQuery({
queryKey: fetchSSHList.requestKey(),
queryFn: async () => {
const result = await fetchSSHList.request()

return result.data
},
});

useEffect(() => {
if(data){
setSSHList(data)
}
}, [data, setSSHList]);
Comment thread
yumeowo marked this conversation as resolved.

return { sshKeys, isLoading, isPending, isFetching }
}
Loading