From 60b0137232c657eda980233e025f4823aa899cee Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 28 Jul 2026 15:09:30 -0500 Subject: [PATCH 1/3] Allow Linear credential reconfiguration --- apps/docs/integrations/linear.mdx | 7 + .../components/settings/Integrations.test.tsx | 134 ++++++- .../src/components/settings/Integrations.tsx | 13 +- .../settings/LinearOauthSetupDialog.tsx | 330 +++++++++++------- apps/web/src/hooks/linear/index.ts | 1 + .../linear/useInvalidateLinearOauthSetup.ts | 27 ++ .../hooks/linear/useRemoveLinearOauthSetup.ts | 18 + .../hooks/linear/useSaveLinearOauthSetup.ts | 23 +- .../src/trpc/commands/linear/index.test.ts | 65 +++- apps/web/src/trpc/commands/linear/index.ts | 1 + .../src/trpc/commands/linear/oauth-setup.ts | 30 +- apps/web/src/trpc/routers/_app.ts | 5 + 12 files changed, 507 insertions(+), 147 deletions(-) create mode 100644 apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts create mode 100644 apps/web/src/hooks/linear/useRemoveLinearOauthSetup.ts diff --git a/apps/docs/integrations/linear.mdx b/apps/docs/integrations/linear.mdx index c1cb5ed0..5dcb92ac 100644 --- a/apps/docs/integrations/linear.mdx +++ b/apps/docs/integrations/linear.mdx @@ -29,6 +29,13 @@ priority, or discussion. `R_LINEAR_CLIENT_SECRET`, and `R_LINEAR_WEBHOOK_SECRET` in the runtime environment continue to use those values and skip the in-app setup. + + A deployment administrator can return to **Settings > Integrations** and + select **Configure** on Linear. Saving a new client ID or client secret + disconnects the current workspace so it can be authorized again. Removing + saved credentials disables Linear but does not delete the app in Linear. + Credentials managed by the deployment environment must be changed there. + Link your Linear identity when prompted so Roomote can associate issue activity with your Roomote user. diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index c0ea6e07..9c1386bb 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -62,9 +62,21 @@ const state = vi.hoisted(() => ({ webhookUrl: 'https://roomote.example/api/webhooks/linear', manifestUrl: 'https://linear.app/settings/api/applications/new?manifest=x', fields: { - clientId: { configured: false, managedByEnvironment: false }, - clientSecret: { configured: false, managedByEnvironment: false }, - webhookSecret: { configured: false, managedByEnvironment: false }, + clientId: { + configured: false, + managedByEnvironment: false, + savedInRoomote: false, + }, + clientSecret: { + configured: false, + managedByEnvironment: false, + savedInRoomote: false, + }, + webhookSecret: { + configured: false, + managedByEnvironment: false, + savedInRoomote: false, + }, }, }, linearRedirectPath: '', @@ -84,6 +96,7 @@ const { mutations, selectMock } = vi.hoisted(() => ({ saveSnowflakeConnection: vi.fn(), saveVercelConnection: vi.fn(), saveLinearOauthSetup: vi.fn(), + removeLinearOauthSetup: vi.fn(), }, selectMock: { latestOnValueChange: null as null | ((value: string) => void), @@ -175,6 +188,10 @@ vi.mock('@/hooks/linear', () => ({ isPending: false, mutate: mutations.saveLinearOauthSetup, }), + useRemoveLinearOauthSetup: () => ({ + isPending: false, + mutate: mutations.removeLinearOauthSetup, + }), })); vi.mock('@/hooks/mcp-connections', () => ({ @@ -426,6 +443,23 @@ describe('Integrations settings', () => { state.featureFlags = {}; state.snowflakeConnection = null; state.searchParams = ''; + state.linearOauthSetup.fields = { + clientId: { + configured: false, + managedByEnvironment: false, + savedInRoomote: false, + }, + clientSecret: { + configured: false, + managedByEnvironment: false, + savedInRoomote: false, + }, + webhookSecret: { + configured: false, + managedByEnvironment: false, + savedInRoomote: false, + }, + }; }); it('returns Linear OAuth to a service-specific integrations URL', () => { @@ -501,7 +535,7 @@ describe('Integrations settings', () => { expect(screen.getByLabelText('Webhook secret')).toBeInTheDocument(); }); - it('only offers app creation while Linear is unconfigured', () => { + it('offers administrators configuration access after Linear is configured', () => { state.linearInstallation = null; state.oauthReadiness = [{ mcpId: 'linear', status: 'ready' }]; @@ -510,11 +544,103 @@ describe('Integrations settings', () => { expect( screen.queryByRole('button', { name: 'Set up Linear' }), ).not.toBeInTheDocument(); + const configureButton = screen.getByRole('button', { + name: 'Configure Linear', + }); + expect(configureButton).toBeInTheDocument(); + expect(configureButton).not.toHaveTextContent('Configure'); expect( screen.getByRole('button', { name: 'Enable Linear' }), ).toBeInTheDocument(); }); + it('does not offer Linear credential configuration to non-admins', () => { + state.isAdmin = false; + + render(); + + expect( + screen.queryByRole('button', { name: 'Configure Linear' }), + ).not.toBeInTheDocument(); + }); + + it('removes saved Linear credentials after confirmation', () => { + state.linearInstallation = null; + state.linearOauthSetup.fields = { + clientId: { + configured: true, + managedByEnvironment: false, + savedInRoomote: true, + }, + clientSecret: { + configured: true, + managedByEnvironment: false, + savedInRoomote: true, + }, + webhookSecret: { + configured: true, + managedByEnvironment: false, + savedInRoomote: true, + }, + }; + mutations.removeLinearOauthSetup.mockImplementation((_variables, options) => + options?.onSuccess?.({ success: true }), + ); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Configure Linear' })); + + expect( + screen.getByRole('heading', { name: 'Configure Linear' }), + ).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { name: 'Remove saved credentials' }), + ); + expect( + screen.getByRole('heading', { + name: 'Remove saved Linear credentials?', + }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Remove credentials' })); + + expect(mutations.removeLinearOauthSetup).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith( + 'Saved Linear OAuth credentials removed.', + ); + }); + + it('does not offer to remove environment-managed Linear credentials', () => { + state.linearInstallation = null; + state.linearOauthSetup.fields = { + clientId: { + configured: true, + managedByEnvironment: true, + savedInRoomote: false, + }, + clientSecret: { + configured: true, + managedByEnvironment: true, + savedInRoomote: false, + }, + webhookSecret: { + configured: true, + managedByEnvironment: true, + savedInRoomote: false, + }, + }; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Configure Linear' })); + + expect( + screen.queryByRole('button', { name: 'Remove saved credentials' }), + ).not.toBeInTheDocument(); + expect( + screen.getAllByText('Managed by the deployment environment.'), + ).toHaveLength(3); + }); + it('offers setup for a legacy workspace when deployment credentials are missing', () => { state.linearInstallation = { linearOrganizationName: 'Legacy workspace' }; state.oauthReadiness = [{ mcpId: 'linear', status: 'missing' }]; diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index b2e39708..06125cef 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -1146,7 +1146,7 @@ export function Integrations() { const linearOauthUnavailable = linearOauthStatus === 'missing' || linearOauthStatus === 'partial'; const linearOauthSetup = useLinearOauthSetup( - isAdmin && linearOauthUnavailable, + isAdmin && (linearOauthUnavailable || isLinearOauthSetupOpen), ); const setDeploymentEnabled = useSetDeploymentMcpEnabled(); const userMcpConnections = useUserMcpConnections(); @@ -1290,6 +1290,7 @@ export function Integrations() { (userMcpConnections.data ?? []).map((entry) => [entry.mcpId, entry]), ); const canSetUpLinearOauth = isAdmin && linearOauthUnavailable; + const canConfigureLinearOauth = isAdmin && !linearOauthUnavailable; const canReconnectLinear = isAdmin && Boolean(linearInstallation.data) && !linearOauthUnavailable; const startLinearConnection = () => { @@ -1370,6 +1371,15 @@ export function Integrations() { icon: , } : undefined, + utilityAction: canConfigureLinearOauth + ? { + label: 'Configure credentials', + ariaLabel: 'Configure Linear', + onAction: () => setIsLinearOauthSetupOpen(true), + isPending: isLinearOauthSetupOpen && linearOauthSetup.isPending, + icon: , + } + : undefined, onAction: linearOauthUnavailable ? undefined : () => { @@ -1632,6 +1642,7 @@ export function Integrations() { oauthReadiness.isPending, isAdmin, isGrafanaDialogOpen, + isLinearOauthSetupOpen, saveAsanaConnection.isPending, saveGrafanaConnection.isPending, saveVercelConnection.isPending, diff --git a/apps/web/src/components/settings/LinearOauthSetupDialog.tsx b/apps/web/src/components/settings/LinearOauthSetupDialog.tsx index 929ae287..fb301fdf 100644 --- a/apps/web/src/components/settings/LinearOauthSetupDialog.tsx +++ b/apps/web/src/components/settings/LinearOauthSetupDialog.tsx @@ -5,7 +5,10 @@ import Link from 'next/link'; import { toast } from 'sonner'; import { DOCS_LINEAR_INTEGRATION_URL } from '@/lib/docs'; -import { useSaveLinearOauthSetup } from '@/hooks/linear'; +import { + useRemoveLinearOauthSetup, + useSaveLinearOauthSetup, +} from '@/hooks/linear'; import { Alert, AlertDescription, @@ -28,6 +31,7 @@ import { type SetupFieldStatus = { configured: boolean; managedByEnvironment: boolean; + savedInRoomote: boolean; }; type LinearOauthSetupDetails = { @@ -94,7 +98,9 @@ function CredentialField({ onChange: (value: string) => void; }) { const helperText = status.managedByEnvironment - ? 'Managed by the deployment environment.' + ? status.savedInRoomote + ? 'Managed by the deployment environment. A saved fallback is also stored in Roomote.' + : 'Managed by the deployment environment.' : status.configured ? 'Already saved. Leave blank to keep the current value.' : 'Required.'; @@ -136,12 +142,15 @@ export function LinearOauthSetupDialog({ }) { const [form, setForm] = useState(EMPTY_FORM); const [formError, setFormError] = useState(null); + const [removeConfirmationOpen, setRemoveConfirmationOpen] = useState(false); const saveSetup = useSaveLinearOauthSetup(); + const removeSetup = useRemoveLinearOauthSetup(); useEffect(() => { if (open) { setForm(EMPTY_FORM); setFormError(null); + setRemoveConfirmationOpen(false); } }, [open]); @@ -171,138 +180,217 @@ export function LinearOauthSetupDialog({ }; const publicUrlUsesHttps = setup?.webhookUrl.startsWith('https://') ?? true; + const fieldStatuses = setup ? Object.values(setup.fields) : []; + const hasConfiguredCredentials = fieldStatuses.some( + (field) => field.configured, + ); + const hasSavedCredentials = fieldStatuses.some( + (field) => field.savedInRoomote, + ); + + const remove = () => { + removeSetup.mutate(undefined, { + onSuccess: () => { + toast.success('Saved Linear OAuth credentials removed.'); + setRemoveConfirmationOpen(false); + onOpenChange(false); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : 'Failed to remove saved Linear OAuth credentials.', + ); + }, + }); + }; return ( - - - Set up Linear - - Create a Linear app for this deployment, then connect it to your - workspace. - - - - {!setup ? ( -
- - -
- ) : ( -
- {!publicUrlUsesHttps ? ( - - - - Linear requires a public HTTPS webhook URL. Configure - R_PUBLIC_URL with your deployment's HTTPS address before - creating the app. - - - ) : null} - -
-
-

- Create a Linear app for this deployment. -

-

- Self-hosted Roomote needs its own Linear app. The manifest - pre-fills the callback, webhook, and agent event settings. - Review it in Linear, then create the app. -

-
+ + {removeConfirmationOpen ? ( + <> + + Remove saved Linear credentials? + + This disables Linear and removes credentials stored by Roomote. + It does not delete the app in Linear or change credentials + managed by the deployment environment. + + + + -
- - + + + ) : ( + <> + + + {hasConfiguredCredentials + ? 'Configure Linear' + : 'Set up Linear'} + + + {hasConfiguredCredentials + ? 'Update or remove the Linear app credentials saved for this deployment.' + : 'Create a Linear app for this deployment, then connect it to your workspace.'} + + + + {!setup ? ( +
+ + +
+ ) : ( +
+ {!publicUrlUsesHttps ? ( + + + + Linear requires a public HTTPS webhook URL. Configure + R_PUBLIC_URL with your deployment's HTTPS address + before creating the app. + + + ) : null} + +
+
+

+ Create a Linear app for this deployment. +

+

+ Self-hosted Roomote needs its own Linear app. The manifest + pre-fills the callback, webhook, and agent event settings. + Review it in Linear, then create the app. +

+
+ +
+ + +
+
+ +
+
+

Then save the credentials.

+

+ Copy these values from the new app's settings. + Roomote encrypts values saved here. After they are saved, + Enable Linear connects the app to your workspace. +

+
+
+ updateField('clientId', value)} + /> + updateField('clientSecret', value)} + /> + updateField('webhookSecret', value)} + /> +
+ {formError ? ( +

{formError}

+ ) : null} +
-
+ )} -
-
-

Then save the credentials.

-

- Copy these values from the new app's settings. Roomote - encrypts values saved here. After they are saved, Enable - Linear connects the app to your workspace. -

+ +
+ + {hasSavedCredentials ? ( + + ) : null}
-
- updateField('clientId', value)} - /> - updateField('clientSecret', value)} - /> - updateField('webhookSecret', value)} - /> +
+ +
- {formError ? ( -

{formError}

- ) : null} -
-
+ + )} - - - -
- - -
-
); diff --git a/apps/web/src/hooks/linear/index.ts b/apps/web/src/hooks/linear/index.ts index 91524f32..977ca8dd 100644 --- a/apps/web/src/hooks/linear/index.ts +++ b/apps/web/src/hooks/linear/index.ts @@ -3,4 +3,5 @@ export { useConnectLinear } from './useConnectLinear'; export { useDisconnectLinear } from './useDisconnectLinear'; export { useLinearOauthSetup } from './useLinearOauthSetup'; export { useSaveLinearOauthSetup } from './useSaveLinearOauthSetup'; +export { useRemoveLinearOauthSetup } from './useRemoveLinearOauthSetup'; export { useAuthenticateLinearAccount } from './useAuthenticateLinearAccount'; diff --git a/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts b/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts new file mode 100644 index 00000000..81d6413d --- /dev/null +++ b/apps/web/src/hooks/linear/useInvalidateLinearOauthSetup.ts @@ -0,0 +1,27 @@ +'use client'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useInvalidateLinearOauthSetup() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.linear.oauthSetup.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.oauthReadiness.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.linear.installation.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), + }), + ]); + }; +} diff --git a/apps/web/src/hooks/linear/useRemoveLinearOauthSetup.ts b/apps/web/src/hooks/linear/useRemoveLinearOauthSetup.ts new file mode 100644 index 00000000..19ad4387 --- /dev/null +++ b/apps/web/src/hooks/linear/useRemoveLinearOauthSetup.ts @@ -0,0 +1,18 @@ +'use client'; + +import { useMutation } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +import { useInvalidateLinearOauthSetup } from './useInvalidateLinearOauthSetup'; + +export function useRemoveLinearOauthSetup() { + const trpc = useTRPC(); + const invalidateLinearOauthSetup = useInvalidateLinearOauthSetup(); + + return useMutation( + trpc.linear.removeOauthSetup.mutationOptions({ + onSuccess: invalidateLinearOauthSetup, + }), + ); +} diff --git a/apps/web/src/hooks/linear/useSaveLinearOauthSetup.ts b/apps/web/src/hooks/linear/useSaveLinearOauthSetup.ts index 2a3fcb31..4adb3099 100644 --- a/apps/web/src/hooks/linear/useSaveLinearOauthSetup.ts +++ b/apps/web/src/hooks/linear/useSaveLinearOauthSetup.ts @@ -1,31 +1,18 @@ 'use client'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { useTRPC } from '@/trpc/client'; +import { useInvalidateLinearOauthSetup } from './useInvalidateLinearOauthSetup'; + export function useSaveLinearOauthSetup() { const trpc = useTRPC(); - const queryClient = useQueryClient(); + const invalidateLinearOauthSetup = useInvalidateLinearOauthSetup(); return useMutation( trpc.linear.saveOauthSetup.mutationOptions({ - onSuccess: async () => { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.linear.oauthSetup.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.oauthReadiness.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.linear.installation.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), - }), - ]); - }, + onSuccess: invalidateLinearOauthSetup, }), ); } diff --git a/apps/web/src/trpc/commands/linear/index.test.ts b/apps/web/src/trpc/commands/linear/index.test.ts index 98eec192..97c298ea 100644 --- a/apps/web/src/trpc/commands/linear/index.test.ts +++ b/apps/web/src/trpc/commands/linear/index.test.ts @@ -1,11 +1,17 @@ const { envState, deletedConnectionsState, + persistedEnvVarNamesState, + deleteDeploymentEnvironmentVariablesMock, + getPersistedEnvironmentVariableNamesMock, resolveDeploymentEnvVarMock, upsertDeploymentEnvironmentVariablesMock, } = vi.hoisted(() => ({ envState: {} as Record, deletedConnectionsState: [] as Array<{ id: string }>, + persistedEnvVarNamesState: [] as string[], + deleteDeploymentEnvironmentVariablesMock: vi.fn(), + getPersistedEnvironmentVariableNamesMock: vi.fn(), resolveDeploymentEnvVarMock: vi.fn(), upsertDeploymentEnvironmentVariablesMock: vi.fn(), })); @@ -15,6 +21,10 @@ vi.mock('@/lib/server/get-public-app-url', () => ({ getPublicAppUrl: () => 'https://roomote.example', })); vi.mock('../environment-variables', () => ({ + deleteDeploymentEnvironmentVariables: + deleteDeploymentEnvironmentVariablesMock, + getPersistedEnvironmentVariableNames: + getPersistedEnvironmentVariableNamesMock, upsertDeploymentEnvironmentVariables: upsertDeploymentEnvironmentVariablesMock, })); @@ -54,6 +64,7 @@ vi.mock('@roomote/sdk/server', () => ({ import { getLinearOauthSetupCommand, + removeLinearOauthSetupCommand, saveLinearOauthSetupCommand, } from './index'; @@ -69,6 +80,10 @@ describe('Linear OAuth setup', () => { delete envState[key]; } deletedConnectionsState.splice(0); + persistedEnvVarNamesState.splice(0); + getPersistedEnvironmentVariableNamesMock.mockImplementation( + async () => persistedEnvVarNamesState, + ); resolveDeploymentEnvVarMock.mockResolvedValue(null); }); @@ -99,7 +114,36 @@ describe('Linear OAuth setup', () => { }); }); - it('requires an administrator to view or save app setup', async () => { + it('identifies credentials saved in Roomote separately from runtime values', async () => { + persistedEnvVarNamesState.push( + 'R_LINEAR_CLIENT_ID', + 'R_LINEAR_CLIENT_SECRET', + ); + envState.R_LINEAR_CLIENT_ID = 'runtime-client'; + resolveDeploymentEnvVarMock.mockResolvedValue('configured'); + + const setup = await getLinearOauthSetupCommand(ADMIN); + + expect(setup.fields).toEqual({ + clientId: { + configured: true, + managedByEnvironment: true, + savedInRoomote: true, + }, + clientSecret: { + configured: true, + managedByEnvironment: false, + savedInRoomote: true, + }, + webhookSecret: { + configured: true, + managedByEnvironment: false, + savedInRoomote: false, + }, + }); + }); + + it('requires an administrator to view, save, or remove app setup', async () => { const nonAdmin = { ...ADMIN, isAdmin: false }; await expect(getLinearOauthSetupCommand(nonAdmin)).rejects.toThrow( @@ -112,6 +156,25 @@ describe('Linear OAuth setup', () => { webhookSecret: 'webhook-secret', }), ).rejects.toThrow('Unauthorized'); + await expect(removeLinearOauthSetupCommand(nonAdmin)).rejects.toThrow( + 'Unauthorized', + ); + }); + + it('removes saved credentials and disconnects the current workspace', async () => { + deletedConnectionsState.push({ id: 'linear-connection' }); + + const result = await removeLinearOauthSetupCommand(ADMIN); + + expect(deleteDeploymentEnvironmentVariablesMock).toHaveBeenCalledWith( + expect.anything(), + [ + 'R_LINEAR_CLIENT_ID', + 'R_LINEAR_CLIENT_SECRET', + 'R_LINEAR_WEBHOOK_SECRET', + ], + ); + expect(result).toEqual({ success: true }); }); it('saves all three credentials in the encrypted deployment store', async () => { diff --git a/apps/web/src/trpc/commands/linear/index.ts b/apps/web/src/trpc/commands/linear/index.ts index ae1a763c..d93a9828 100644 --- a/apps/web/src/trpc/commands/linear/index.ts +++ b/apps/web/src/trpc/commands/linear/index.ts @@ -10,6 +10,7 @@ import { clearLinearDeploymentConnection } from './oauth-setup'; export { getLinearOauthSetupCommand, + removeLinearOauthSetupCommand, saveLinearOauthSetupCommand, } from './oauth-setup'; diff --git a/apps/web/src/trpc/commands/linear/oauth-setup.ts b/apps/web/src/trpc/commands/linear/oauth-setup.ts index 007715f0..06243151 100644 --- a/apps/web/src/trpc/commands/linear/oauth-setup.ts +++ b/apps/web/src/trpc/commands/linear/oauth-setup.ts @@ -18,7 +18,11 @@ import { import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; import type { UserAuthSuccess } from '@/types'; -import { upsertDeploymentEnvironmentVariables } from '../environment-variables'; +import { + deleteDeploymentEnvironmentVariables, + getPersistedEnvironmentVariableNames, + upsertDeploymentEnvironmentVariables, +} from '../environment-variables'; const LINEAR_OAUTH_ENV_FIELDS = { clientId: 'R_LINEAR_CLIENT_ID', @@ -130,6 +134,9 @@ export async function getLinearOauthSetupCommand(auth: UserAuthSuccess) { const publicOrigin = getPublicAppUrl(Env); const runtimeEnv = getLinearRuntimeEnv(); const urls = buildLinearOauthSetup(publicOrigin); + const persistedEnvVarNames = new Set( + await getPersistedEnvironmentVariableNames(), + ); const fieldEntries = await Promise.all( Object.entries(LINEAR_OAUTH_ENV_FIELDS).map(async ([field, envName]) => { const runtimeValue = getRuntimeEnvValue(envName); @@ -144,6 +151,7 @@ export async function getLinearOauthSetupCommand(auth: UserAuthSuccess) { { configured: Boolean(effectiveValue), managedByEnvironment: Boolean(runtimeValue), + savedInRoomote: persistedEnvVarNames.has(envName), }, ] as const; }), @@ -153,11 +161,29 @@ export async function getLinearOauthSetupCommand(auth: UserAuthSuccess) { ...urls, fields: Object.fromEntries(fieldEntries) as Record< LinearOauthSetupField, - { configured: boolean; managedByEnvironment: boolean } + { + configured: boolean; + managedByEnvironment: boolean; + savedInRoomote: boolean; + } >, }; } +export async function removeLinearOauthSetupCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + + await db.transaction(async (tx) => { + await deleteDeploymentEnvironmentVariables( + tx, + Object.values(LINEAR_OAUTH_ENV_FIELDS), + ); + await clearLinearDeploymentConnection(tx, auth.userId); + }); + + return { success: true as const }; +} + export async function saveLinearOauthSetupCommand( auth: UserAuthSuccess, input: SaveLinearOauthSetupInput, diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index e72d6ac8..8d25233a 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -113,6 +113,7 @@ import { getLinearInstallationCommand, disconnectLinearAppCommand, getLinearOauthSetupCommand, + removeLinearOauthSetupCommand, saveLinearOauthSetupCommand, } from '../commands/linear'; import { getTeamsIntegrationStatusCommand } from '../commands/teams'; @@ -1153,6 +1154,10 @@ export const appRouter = createRouter({ getLinearOauthSetupCommand(auth), ), + removeOauthSetup: protectedProcedure.mutation(({ ctx: { auth } }) => + removeLinearOauthSetupCommand(auth), + ), + saveOauthSetup: protectedProcedure .input( z.object({ From f5aa97a5a4d9303a119a476f9a1f89aa6c2bedaf Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 28 Jul 2026 16:28:49 -0500 Subject: [PATCH 2/3] Polish Linear setup copy --- apps/web/src/components/settings/LinearOauthSetupDialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/LinearOauthSetupDialog.tsx b/apps/web/src/components/settings/LinearOauthSetupDialog.tsx index fb301fdf..c4ffa328 100644 --- a/apps/web/src/components/settings/LinearOauthSetupDialog.tsx +++ b/apps/web/src/components/settings/LinearOauthSetupDialog.tsx @@ -276,9 +276,9 @@ export function LinearOauthSetupDialog({ Create a Linear app for this deployment.

- Self-hosted Roomote needs its own Linear app. The manifest - pre-fills the callback, webhook, and agent event settings. - Review it in Linear, then create the app. + Roomote needs its own Linear app. The manifest pre-fills + the callback, webhook, and agent event settings. Review it + in Linear, then create the app.