diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx index 4c156a251a..f5ddca2094 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx @@ -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([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [open, setOpen] = useState(false) + const [selectedProject, setSelectedProject] = useState(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 ( - + + + + + + + + + + {loading ? ( +
+ + Loading projects... +
+ ) : error ? ( +
+

{error}

+
+ ) : !credential || !teamId ? ( +
+

Missing credentials or team

+

+ Please configure Linear credentials and select a team. +

+
+ ) : ( +
+

No projects found

+

+ No projects available for the selected team. +

+
+ )} +
+ + {projects.length > 0 && ( + +
+ Projects +
+ {projects.map((project) => ( + handleSelectProject(project)} + className='cursor-pointer' + > +
+ + {project.name} +
+ {project.id === value && } +
+ ))} +
+ )} +
+
+
+
) } diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx index 13178e5366..9a35dff08d 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx @@ -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([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [open, setOpen] = useState(false) + const [selectedTeam, setSelectedTeam] = useState(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 ( - + + + + + + + + + + {loading ? ( +
+ + Loading teams... +
+ ) : error ? ( +
+

{error}

+
+ ) : !credential ? ( +
+

Missing credentials

+

+ Please configure Linear credentials. +

+
+ ) : ( +
+

No teams found

+

+ No teams available for this Linear account. +

+
+ )} +
+ + {teams.length > 0 && ( + +
Teams
+ {teams.map((team) => ( + handleSelectTeam(team)} + className='cursor-pointer' + > +
+ + {team.name} +
+ {team.id === value && } +
+ ))} +
+ )} +
+
+
+
) } diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 32448ca2f2..cb3b8405d7 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2283,13 +2283,13 @@ export function LinearIcon(props: React.SVGProps) { diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 8b5233a54d..cdf181e97d 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -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 } }, },