mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(tools): fix linear block
This commit is contained in:
+121
-27
@@ -1,11 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { LinearIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
export interface LinearProjectInfo {
|
||||
id: string
|
||||
@@ -32,11 +37,15 @@ export function LinearProjectSelector({
|
||||
const [projects, setProjects] = useState<LinearProjectInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedProject, setSelectedProject] = useState<LinearProjectInfo | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!credential || !teamId) return
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
fetch('/api/tools/linear/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -56,6 +65,12 @@ export function LinearProjectSelector({
|
||||
setProjects([])
|
||||
} else {
|
||||
setProjects(data.projects)
|
||||
|
||||
// Find selected project info if we have a value
|
||||
if (value) {
|
||||
const projectInfo = data.projects.find((p: LinearProjectInfo) => p.id === value)
|
||||
setSelectedProject(projectInfo || null)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -65,28 +80,107 @@ export function LinearProjectSelector({
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [credential, teamId])
|
||||
}, [credential, teamId, value])
|
||||
|
||||
// Sync selected project with value prop
|
||||
useEffect(() => {
|
||||
if (value && projects.length > 0) {
|
||||
const projectInfo = projects.find((p) => p.id === value)
|
||||
setSelectedProject(projectInfo || null)
|
||||
} else if (!value) {
|
||||
setSelectedProject(null)
|
||||
}
|
||||
}, [value, projects])
|
||||
|
||||
const handleSelectProject = (project: LinearProjectInfo) => {
|
||||
setSelectedProject(project)
|
||||
onChange(project.id, project)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(projectId) => {
|
||||
const projectInfo = projects.find((p) => p.id === projectId)
|
||||
onChange(projectId, projectInfo)
|
||||
}}
|
||||
disabled={disabled || loading || !credential || !teamId}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder={loading ? 'Loading projects...' : label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{error && <div className='px-2 py-1 text-red-500'>{error}</div>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !credential || !teamId}
|
||||
>
|
||||
{selectedProject ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{selectedProject.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search projects...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading projects...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : !credential || !teamId ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Missing credentials or team</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please configure Linear credentials and select a team.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No projects found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No projects available for the selected team.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Projects
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={`project-${project.id}-${project.name}`}
|
||||
onSelect={() => handleSelectProject(project)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{project.name}</span>
|
||||
</div>
|
||||
{project.id === value && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
+119
-27
@@ -1,11 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { LinearIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
export interface LinearTeamInfo {
|
||||
id: string
|
||||
@@ -31,11 +36,15 @@ export function LinearTeamSelector({
|
||||
const [teams, setTeams] = useState<LinearTeamInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedTeam, setSelectedTeam] = useState<LinearTeamInfo | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!credential) return
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
fetch('/api/tools/linear/teams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -52,6 +61,12 @@ export function LinearTeamSelector({
|
||||
setTeams([])
|
||||
} else {
|
||||
setTeams(data.teams)
|
||||
|
||||
// Find selected team info if we have a value
|
||||
if (value) {
|
||||
const teamInfo = data.teams.find((t: LinearTeamInfo) => t.id === value)
|
||||
setSelectedTeam(teamInfo || null)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -61,28 +76,105 @@ export function LinearTeamSelector({
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [credential])
|
||||
}, [credential, value])
|
||||
|
||||
// Sync selected team with value prop
|
||||
useEffect(() => {
|
||||
if (value && teams.length > 0) {
|
||||
const teamInfo = teams.find((t) => t.id === value)
|
||||
setSelectedTeam(teamInfo || null)
|
||||
} else if (!value) {
|
||||
setSelectedTeam(null)
|
||||
}
|
||||
}, [value, teams])
|
||||
|
||||
const handleSelectTeam = (team: LinearTeamInfo) => {
|
||||
setSelectedTeam(team)
|
||||
onChange(team.id, team)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(teamId) => {
|
||||
const teamInfo = teams.find((t) => t.id === teamId)
|
||||
onChange(teamId, teamInfo)
|
||||
}}
|
||||
disabled={disabled || loading || !credential}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder={loading ? 'Loading teams...' : label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teams.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{error && <div className='px-2 py-1 text-red-500'>{error}</div>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !credential}
|
||||
>
|
||||
{selectedTeam ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{selectedTeam.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground'>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search teams...' />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading teams...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : !credential ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Missing credentials</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please configure Linear credentials.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No teams found</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No teams available for this Linear account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{teams.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Teams</div>
|
||||
{teams.map((team) => (
|
||||
<CommandItem
|
||||
key={team.id}
|
||||
value={`team-${team.id}-${team.name}`}
|
||||
onSelect={() => handleSelectTeam(team)}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<LinearIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{team.name}</span>
|
||||
</div>
|
||||
{team.id === value && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2283,13 +2283,13 @@ export function LinearIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
fill='currentColor'
|
||||
width='200'
|
||||
height='200'
|
||||
viewBox='0 0 100 100'
|
||||
>
|
||||
<path
|
||||
fill='#fff'
|
||||
fill='currentColor'
|
||||
d='M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z'
|
||||
/>
|
||||
</svg>
|
||||
|
||||
+55
-32
@@ -261,7 +261,6 @@ export const auth = betterAuth({
|
||||
) || emails[0]
|
||||
if (primaryEmail) {
|
||||
profile.email = primaryEmail.email
|
||||
// Add information about email verification
|
||||
profile.emailVerified = primaryEmail.verified || false
|
||||
}
|
||||
} else {
|
||||
@@ -785,42 +784,66 @@ export const auth = betterAuth({
|
||||
tokenUrl: 'https://api.linear.app/oauth/token',
|
||||
scopes: ['read', 'write'],
|
||||
responseType: 'code',
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
pkce: false,
|
||||
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/linear`,
|
||||
pkce: true,
|
||||
prompt: 'consent',
|
||||
accessType: 'offline',
|
||||
getUserInfo: async (tokens) => {
|
||||
const response = await fetch('https://api.linear.app/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `\n query {\n viewer {\n id\n name\n email\n avatarUrl\n }\n }\n `,
|
||||
}),
|
||||
})
|
||||
try {
|
||||
const response = await fetch('https://api.linear.app/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `{
|
||||
viewer {
|
||||
id
|
||||
email
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}`,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch Linear user info')
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Linear API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorText,
|
||||
})
|
||||
throw new Error(`Linear API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
throw new Error(data.errors.map((e: any) => e.message).join('; '))
|
||||
}
|
||||
const user = data?.viewer
|
||||
if (!user) throw new Error('No user info returned from Linear')
|
||||
const { data, errors } = await response.json()
|
||||
|
||||
const now = new Date()
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.avatarUrl,
|
||||
emailVerified: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
if (errors) {
|
||||
console.error('GraphQL errors:', errors)
|
||||
throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`)
|
||||
}
|
||||
|
||||
if (!data?.viewer) {
|
||||
console.error('No viewer data in response:', data)
|
||||
throw new Error('No viewer data in response')
|
||||
}
|
||||
|
||||
const viewer = data.viewer
|
||||
|
||||
return {
|
||||
id: viewer.id,
|
||||
email: viewer.email,
|
||||
name: viewer.name,
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
image: viewer.avatarUrl || null,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in getUserInfo:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user