-
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({