collavre_linear 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/collavre_linear/auth_controller.rb +19 -0
- data/app/javascript/__tests__/linear_modal_reopen.test.js +178 -0
- data/app/javascript/collavre_linear.js +30 -2
- data/app/javascript/linear_modal_reopen.js +74 -0
- data/app/services/collavre_linear/client.rb +27 -4
- data/app/views/collavre_linear/auth/setup.html.erb +24 -4
- data/app/views/collavre_linear/integrations/_modal.html.erb +34 -0
- data/config/locales/en.yml +3 -0
- data/config/locales/ko.yml +3 -0
- data/lib/collavre_linear/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 537f2de71190fa76fd77b07faf193af07b63b414b7881a9e50881d5b421c621d
|
|
4
|
+
data.tar.gz: b0f53edb5658448d2c93e4fcd0d3b72edd2bc2af7a998d57c86e57b97383e216
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a3de41a79cb89bd5ebb34a8ee71bf3d507343daff1e9025ff927eb6188a41cdab048e7a558a8afa5e1e48551b20033848950f6c626ef4b5686be8b70f43637a
|
|
7
|
+
data.tar.gz: 16d8da660a36a5e7abc28cdc551d51727b1437fb00d83c9ddfece65793a9bfde65455e8c8a0b4bbc6ed30561557c48b301fd850e5b012f9dcdcab2550592e15a
|
|
@@ -43,6 +43,25 @@ module CollavreLinear
|
|
|
43
43
|
account = CollavreLinear::Account.find_or_initialize_by(
|
|
44
44
|
user_id: Current.user.id
|
|
45
45
|
)
|
|
46
|
+
|
|
47
|
+
# A reconnect that lands in a DIFFERENT Linear workspace than the one the
|
|
48
|
+
# existing project links were created under would orphan those links: their
|
|
49
|
+
# team_id / linear_project_id belong to the old workspace, so resync and
|
|
50
|
+
# outbound jobs would run them against the new token and fail or target the
|
|
51
|
+
# wrong Linear context. Refuse a linked reconnect unless we can PROVE it
|
|
52
|
+
# stays in the same workspace; the admin must unlink first. A blank stored
|
|
53
|
+
# workspace_id ("old workspace unknown") is unprovable, so it must also
|
|
54
|
+
# block rather than fall through — a blank id never equals the incoming
|
|
55
|
+
# organization, so `!=` covers both the known-different and unknown cases.
|
|
56
|
+
# Unlinked accounts skip this and refresh in place (the button's purpose).
|
|
57
|
+
if account.persisted? &&
|
|
58
|
+
account.workspace_id != viewer[:organization_id] &&
|
|
59
|
+
account.project_links.exists?
|
|
60
|
+
redirect_to collavre.creatives_path,
|
|
61
|
+
alert: I18n.t("collavre_linear.auth.workspace_changed_relink")
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
46
65
|
account.linear_uid = viewer[:user_id]
|
|
47
66
|
account.access_token = tokens[:access_token]
|
|
48
67
|
account.refresh_token = tokens[:refresh_token]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LINEAR_REOPEN_KEY,
|
|
3
|
+
markReopenAfterConnect,
|
|
4
|
+
consumeReopenAfterConnect,
|
|
5
|
+
safeSessionStorage,
|
|
6
|
+
} from '../linear_modal_reopen.js';
|
|
7
|
+
|
|
8
|
+
// Minimal in-memory Storage stand-in (jsdom's is fine too, but this keeps the
|
|
9
|
+
// unit test free of environment setup and lets us model a throwing storage).
|
|
10
|
+
function fakeStorage() {
|
|
11
|
+
const map = new Map();
|
|
12
|
+
return {
|
|
13
|
+
setItem: (k, v) => map.set(k, String(v)),
|
|
14
|
+
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
|
15
|
+
removeItem: (k) => map.delete(k),
|
|
16
|
+
_size: () => map.size,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('safeSessionStorage', () => {
|
|
21
|
+
test('returns the storage object when Web Storage is available', () => {
|
|
22
|
+
// jsdom provides a working window.sessionStorage.
|
|
23
|
+
expect(safeSessionStorage()).toBe(window.sessionStorage);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('returns null when the window.sessionStorage getter itself throws', () => {
|
|
27
|
+
// Web Storage disabled by policy (packaged WKWebView, storage-blocked
|
|
28
|
+
// browser) throws on the property *access*, before any value is passed to
|
|
29
|
+
// mark/consume. safeSessionStorage must absorb it so callers never throw.
|
|
30
|
+
const original = Object.getOwnPropertyDescriptor(window, 'sessionStorage');
|
|
31
|
+
Object.defineProperty(window, 'sessionStorage', {
|
|
32
|
+
configurable: true,
|
|
33
|
+
get() { throw new Error('SecurityError: storage disabled'); },
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
expect(() => safeSessionStorage()).not.toThrow();
|
|
37
|
+
expect(safeSessionStorage()).toBeNull();
|
|
38
|
+
} finally {
|
|
39
|
+
if (original) Object.defineProperty(window, 'sessionStorage', original);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('markReopenAfterConnect', () => {
|
|
45
|
+
test('persists the unscoped sentinel when no creative id is given', () => {
|
|
46
|
+
const storage = fakeStorage();
|
|
47
|
+
markReopenAfterConnect(storage);
|
|
48
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).toBe('*');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('the unscoped sentinel cannot equal any serialized creative id', () => {
|
|
52
|
+
// Creative ids are positive integers → digit strings. The sentinel must be a
|
|
53
|
+
// non-digit so a real id never gets misread as "unscoped".
|
|
54
|
+
const storage = fakeStorage();
|
|
55
|
+
markReopenAfterConnect(storage);
|
|
56
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).not.toMatch(/^\d+$/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('persists the creative id when given (scoped reopen)', () => {
|
|
60
|
+
const storage = fakeStorage();
|
|
61
|
+
markReopenAfterConnect(storage, 42);
|
|
62
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).toBe('42');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('falls back to the unscoped sentinel for an empty creative id', () => {
|
|
66
|
+
const storage = fakeStorage();
|
|
67
|
+
markReopenAfterConnect(storage, '');
|
|
68
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).toBe('*');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('tolerates a null storage without throwing', () => {
|
|
72
|
+
expect(() => markReopenAfterConnect(null)).not.toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('swallows storage errors (private mode / WKWebView)', () => {
|
|
76
|
+
const throwing = { setItem: () => { throw new Error('QuotaExceeded'); } };
|
|
77
|
+
expect(() => markReopenAfterConnect(throwing)).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('consumeReopenAfterConnect', () => {
|
|
82
|
+
test('returns true exactly once, then clears the flag (regression)', () => {
|
|
83
|
+
// Without the one-shot clear, every subsequent reload — including the one
|
|
84
|
+
// after a successful link — would reopen the modal. It must fire once.
|
|
85
|
+
const storage = fakeStorage();
|
|
86
|
+
markReopenAfterConnect(storage);
|
|
87
|
+
|
|
88
|
+
expect(consumeReopenAfterConnect(storage)).toBe(true);
|
|
89
|
+
expect(consumeReopenAfterConnect(storage)).toBe(false);
|
|
90
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('reopens only when the current creative matches the stored one', () => {
|
|
94
|
+
const storage = fakeStorage();
|
|
95
|
+
markReopenAfterConnect(storage, 42); // popup connected creative 42
|
|
96
|
+
// Opener is on the same creative → reopen.
|
|
97
|
+
expect(consumeReopenAfterConnect(storage, 42)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('does NOT reopen when the opener navigated to a different creative', () => {
|
|
101
|
+
// Codex P2: mid-flow navigation must not surface the wrong creative's modal.
|
|
102
|
+
const storage = fakeStorage();
|
|
103
|
+
markReopenAfterConnect(storage, 42); // popup connected creative 42
|
|
104
|
+
// Opener moved to creative 99 before the reload → no reopen, flag cleared.
|
|
105
|
+
expect(consumeReopenAfterConnect(storage, 99)).toBe(false);
|
|
106
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('creative id 1 stays scoped and does NOT reopen a different creative', () => {
|
|
110
|
+
// Regression: the sentinel used to be '1', colliding with creative id 1 — so
|
|
111
|
+
// OAuth from creative 1 was misread as unscoped and reopened whatever modal
|
|
112
|
+
// the opener had navigated to. It must behave like any other scoped id.
|
|
113
|
+
const storage = fakeStorage();
|
|
114
|
+
markReopenAfterConnect(storage, 1); // popup connected creative 1
|
|
115
|
+
expect(consumeReopenAfterConnect(storage, 99)).toBe(false); // opener moved away
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('creative id 1 reopens when the opener is still on creative 1', () => {
|
|
119
|
+
const storage = fakeStorage();
|
|
120
|
+
markReopenAfterConnect(storage, 1);
|
|
121
|
+
expect(consumeReopenAfterConnect(storage, 1)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('clears a mismatched intent so it never fires on a later matching visit', () => {
|
|
125
|
+
const storage = fakeStorage();
|
|
126
|
+
markReopenAfterConnect(storage, 42);
|
|
127
|
+
consumeReopenAfterConnect(storage, 99); // mismatch on the reload
|
|
128
|
+
// Later navigation back to creative 42 must NOT auto-open — intent was spent.
|
|
129
|
+
expect(consumeReopenAfterConnect(storage, 42)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('matches across id string/number coercion', () => {
|
|
133
|
+
const storage = fakeStorage();
|
|
134
|
+
markReopenAfterConnect(storage, 42); // stored as '42'
|
|
135
|
+
expect(consumeReopenAfterConnect(storage, '42')).toBe(true); // dataset is a string
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('unscoped sentinel reopens regardless of current creative (legacy)', () => {
|
|
139
|
+
const storage = fakeStorage();
|
|
140
|
+
markReopenAfterConnect(storage); // no id → '1'
|
|
141
|
+
expect(consumeReopenAfterConnect(storage, 7)).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('returns false when no reopen was pending', () => {
|
|
145
|
+
expect(consumeReopenAfterConnect(fakeStorage())).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('tolerates a null storage', () => {
|
|
149
|
+
expect(consumeReopenAfterConnect(null)).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('swallows storage errors and reports no pending reopen', () => {
|
|
153
|
+
const throwing = { getItem: () => { throw new Error('SecurityError'); } };
|
|
154
|
+
expect(consumeReopenAfterConnect(throwing)).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('end-to-end via safeSessionStorage: mark on connect, consume once', () => {
|
|
158
|
+
// The call sites pass safeSessionStorage() rather than window.sessionStorage
|
|
159
|
+
// directly, so the whole flow must survive a storage-backed session.
|
|
160
|
+
const s = safeSessionStorage();
|
|
161
|
+
if (!s) return; // jsdom always provides it; guard just in case
|
|
162
|
+
s.removeItem(LINEAR_REOPEN_KEY);
|
|
163
|
+
markReopenAfterConnect(s);
|
|
164
|
+
expect(consumeReopenAfterConnect(s)).toBe(true);
|
|
165
|
+
expect(consumeReopenAfterConnect(s)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('end-to-end: mark on connect, consume on the next load', () => {
|
|
169
|
+
// Mirrors the real flow: the postMessage handler marks, the reload happens,
|
|
170
|
+
// then turbo:load consumes the flag and opens the modal exactly once.
|
|
171
|
+
const storage = fakeStorage();
|
|
172
|
+
markReopenAfterConnect(storage); // linearConnected received
|
|
173
|
+
const reopenedFirstLoad = consumeReopenAfterConnect(storage); // turbo:load
|
|
174
|
+
const reopenedSecondLoad = consumeReopenAfterConnect(storage); // later nav
|
|
175
|
+
expect(reopenedFirstLoad).toBe(true);
|
|
176
|
+
expect(reopenedSecondLoad).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -15,19 +15,38 @@
|
|
|
15
15
|
// unconfirmed and errors would vanish. Route through the shared in-app modal
|
|
16
16
|
// like the GitHub/Slack/Notion engines do.
|
|
17
17
|
import { alertDialog, confirmDialog } from 'collavre/lib/utils/dialog';
|
|
18
|
+
import {
|
|
19
|
+
markReopenAfterConnect,
|
|
20
|
+
consumeReopenAfterConnect,
|
|
21
|
+
safeSessionStorage,
|
|
22
|
+
} from './linear_modal_reopen.js';
|
|
18
23
|
|
|
19
24
|
let linearIntegrationInitialized = false;
|
|
20
25
|
|
|
21
26
|
if (!linearIntegrationInitialized) {
|
|
22
27
|
linearIntegrationInitialized = true;
|
|
23
28
|
|
|
24
|
-
// The OAuth popup posts `linearConnected` to the opener
|
|
25
|
-
// Reload so the modal re-renders in the connected (link-a-project)
|
|
29
|
+
// The OAuth popup posts `linearConnected` to the opener once the account is
|
|
30
|
+
// connected. Reload so the modal re-renders in the connected (link-a-project)
|
|
31
|
+
// state, then reopen it (see turbo:load) so the user lands directly on the
|
|
32
|
+
// project settings step instead of a closed modal.
|
|
26
33
|
// Bound to `window` once — it survives Turbo navigations, unlike the
|
|
27
34
|
// per-navigation element listeners set up in turbo:load below.
|
|
28
35
|
window.addEventListener('message', function (event) {
|
|
29
36
|
if (event.origin !== window.location.origin) return;
|
|
30
37
|
if (event.data && event.data.type === 'linearConnected') {
|
|
38
|
+
// Close the popup from here. window.close() inside the popup is unreliable
|
|
39
|
+
// after the cross-origin OAuth round trip and in the desktop WebView, so
|
|
40
|
+
// the popup's own "Close" button looked dead — closing the window we
|
|
41
|
+
// opened is the reliable path.
|
|
42
|
+
try {
|
|
43
|
+
if (event.source && !event.source.closed) event.source.close();
|
|
44
|
+
} catch (e) { /* cross-origin or already closed */ }
|
|
45
|
+
// Survive the reload: reopen the modal on the next load so the flow lands
|
|
46
|
+
// on the project-link step automatically. Scope it to the creative the
|
|
47
|
+
// popup was opened for (event.data.creativeId) so a mid-flow navigation to
|
|
48
|
+
// a different creative doesn't auto-open the wrong creative's link modal.
|
|
49
|
+
markReopenAfterConnect(safeSessionStorage(), event.data.creativeId);
|
|
31
50
|
window.location.reload();
|
|
32
51
|
}
|
|
33
52
|
});
|
|
@@ -191,6 +210,15 @@ if (!linearIntegrationInitialized) {
|
|
|
191
210
|
showModal();
|
|
192
211
|
});
|
|
193
212
|
|
|
213
|
+
// Just returned from the OAuth popup: open the modal straight to the
|
|
214
|
+
// project-link step instead of leaving the (display:none) modal closed, so
|
|
215
|
+
// the user doesn't have to reopen the Linear menu by hand. Only reopen when
|
|
216
|
+
// this page's creative matches the one the popup connected for — otherwise a
|
|
217
|
+
// mid-flow navigation would surface the wrong creative's link modal.
|
|
218
|
+
if (consumeReopenAfterConnect(safeSessionStorage(), modal.dataset.creativeId)) {
|
|
219
|
+
showModal();
|
|
220
|
+
}
|
|
221
|
+
|
|
194
222
|
closeBtn?.addEventListener('click', closeModal);
|
|
195
223
|
|
|
196
224
|
modal.addEventListener('click', function (event) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Reopen-after-connect intent for the Linear integration modal.
|
|
2
|
+
//
|
|
3
|
+
// When the OAuth popup finishes it posts `linearConnected` and the opener does a
|
|
4
|
+
// full `window.location.reload()` so the server re-renders the modal in its
|
|
5
|
+
// project-linking state (the account now exists). But the modal defaults to
|
|
6
|
+
// `display:none` and nothing re-opens it after the reload — so the connect→link
|
|
7
|
+
// step is invisible and the user has to click the Linear connect button again to
|
|
8
|
+
// reach it.
|
|
9
|
+
//
|
|
10
|
+
// This module persists a one-shot "reopen the modal" flag across that reload.
|
|
11
|
+
// Factored out of the side-effectful collavre_linear.js opener so the behavior
|
|
12
|
+
// is unit-testable (that file is imported only for its window/turbo listeners).
|
|
13
|
+
|
|
14
|
+
export const LINEAR_REOPEN_KEY = 'linearReopenModal';
|
|
15
|
+
|
|
16
|
+
// Read window.sessionStorage defensively. When Web Storage is disabled by policy
|
|
17
|
+
// (packaged WKWebView, a browser blocking storage for the site) the property
|
|
18
|
+
// getter *itself* throws — before any value reaches mark/consume — so guarding
|
|
19
|
+
// only inside those functions is not enough; the access site must be guarded
|
|
20
|
+
// too. Returns null when unavailable so callers never take an uncaught throw.
|
|
21
|
+
export function safeSessionStorage() {
|
|
22
|
+
try {
|
|
23
|
+
return window.sessionStorage;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Unscoped sentinel. Stored when the OAuth creative id is unknown so the reopen
|
|
30
|
+
// still fires (matches any page) — the scoped path stores the id instead. Must
|
|
31
|
+
// be a value no serialized creative id can equal: ids are positive integers and
|
|
32
|
+
// serialize to digit strings, so a non-digit marker never collides. (A previous
|
|
33
|
+
// '1' sentinel collided with creative id 1 — that page's scoped reopen was
|
|
34
|
+
// misread as unscoped and skipped the mismatch guard.)
|
|
35
|
+
const UNSCOPED = '*';
|
|
36
|
+
|
|
37
|
+
// Record that the modal should reopen after the next full-page reload, scoped to
|
|
38
|
+
// the creative the OAuth popup was opened for. The popup posts its own
|
|
39
|
+
// creativeId; storing it lets consume verify the reloaded page still shows that
|
|
40
|
+
// same creative before reopening — otherwise, if the opener navigated to a
|
|
41
|
+
// different creative while the popup was open, the reload would auto-open the
|
|
42
|
+
// wrong creative's link modal and the user could link the project to it.
|
|
43
|
+
// Tolerates a missing or throwing storage (private-mode Safari, packaged
|
|
44
|
+
// WKWebView) — the reopen is a convenience and must never throw inside the
|
|
45
|
+
// postMessage handler.
|
|
46
|
+
export function markReopenAfterConnect(storage, creativeId) {
|
|
47
|
+
const value =
|
|
48
|
+
creativeId != null && String(creativeId) !== '' ? String(creativeId) : UNSCOPED;
|
|
49
|
+
try {
|
|
50
|
+
if (storage) storage.setItem(LINEAR_REOPEN_KEY, value);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
/* storage unavailable — skip the reopen nicety */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Consume the reopen intent: returns true at most once, then clears the flag so
|
|
57
|
+
// a later unrelated reload (e.g. after linking succeeds) does not reopen it. The
|
|
58
|
+
// flag is cleared on the first consume regardless of match, so a stale intent
|
|
59
|
+
// (opener navigated elsewhere) never lingers to reopen the modal on some later
|
|
60
|
+
// visit. Returns true only when the stored creative id matches the current page
|
|
61
|
+
// (or when the legacy unscoped sentinel was stored).
|
|
62
|
+
export function consumeReopenAfterConnect(storage, currentCreativeId) {
|
|
63
|
+
try {
|
|
64
|
+
if (!storage) return false;
|
|
65
|
+
const stored = storage.getItem(LINEAR_REOPEN_KEY);
|
|
66
|
+
if (!stored) return false;
|
|
67
|
+
storage.removeItem(LINEAR_REOPEN_KEY); // one-shot regardless of match
|
|
68
|
+
if (stored === UNSCOPED) return true;
|
|
69
|
+
return String(currentCreativeId == null ? '' : currentCreativeId) === stored;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
/* storage unavailable — treat as no pending reopen */
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
@@ -423,7 +423,7 @@ module CollavreLinear
|
|
|
423
423
|
parsed = begin
|
|
424
424
|
JSON.parse(response.body)
|
|
425
425
|
rescue JSON::ParserError
|
|
426
|
-
raise Error, "Linear returned non-JSON response (HTTP #{response.code}): #{response.body.to_s[0, 200]}"
|
|
426
|
+
raise Error, "Linear returned non-JSON response (HTTP #{response.code}) from #{endpoint}: #{response.body.to_s[0, 200]}"
|
|
427
427
|
end
|
|
428
428
|
|
|
429
429
|
if parsed["errors"].present?
|
|
@@ -470,9 +470,32 @@ module CollavreLinear
|
|
|
470
470
|
end
|
|
471
471
|
|
|
472
472
|
def resolve_endpoint
|
|
473
|
-
|
|
474
|
-
Collavre::IntegrationSettings::Resolver
|
|
475
|
-
|
|
473
|
+
configured =
|
|
474
|
+
if defined?(Collavre::IntegrationSettings::Resolver)
|
|
475
|
+
Collavre::IntegrationSettings::Resolver.get(:linear_api_endpoint).presence
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
normalize_endpoint(configured) || DEFAULT_ENDPOINT
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Guard a misconfigured `linear_api_endpoint` override. Linear's GraphQL API
|
|
482
|
+
# lives at the `/graphql` path; an admin who pastes only a base URL
|
|
483
|
+
# (e.g. `https://host:port` or `.../`) would otherwise POST to `/`, which a
|
|
484
|
+
# non-Linear server answers with a 404 ("Cannot POST /"). When the configured
|
|
485
|
+
# value carries no meaningful path, point it at `/graphql`. A value that
|
|
486
|
+
# already specifies a non-root path is left untouched.
|
|
487
|
+
def normalize_endpoint(value)
|
|
488
|
+
return nil if value.blank?
|
|
489
|
+
|
|
490
|
+
uri = URI.parse(value.strip)
|
|
491
|
+
return value if uri.path.present? && uri.path != "/"
|
|
492
|
+
|
|
493
|
+
uri.path = "/graphql"
|
|
494
|
+
uri.to_s
|
|
495
|
+
rescue URI::InvalidURIError
|
|
496
|
+
# Not a parseable URI: return as-is and let the request surface the failure
|
|
497
|
+
# rather than silently rewriting an unrecognizable value.
|
|
498
|
+
value
|
|
476
499
|
end
|
|
477
500
|
|
|
478
501
|
# Convert symbol keys to camelCase strings for GraphQL variables.
|
|
@@ -50,13 +50,33 @@
|
|
|
50
50
|
</div>
|
|
51
51
|
|
|
52
52
|
<script>
|
|
53
|
-
|
|
53
|
+
// Tell the opener the account is connected so it advances straight to the
|
|
54
|
+
// project-link step. The opener also closes this popup (window.close() below
|
|
55
|
+
// is unreliable after the cross-origin OAuth round trip and in the desktop
|
|
56
|
+
// WebView, which is why the "Close" button used to look dead).
|
|
57
|
+
function notifyOpener() {
|
|
54
58
|
try {
|
|
55
|
-
if (window.opener) {
|
|
56
|
-
|
|
59
|
+
if (window.opener && !window.opener.closed) {
|
|
60
|
+
// Include the creative this popup was opened for so the opener only
|
|
61
|
+
// reopens the modal when it's still showing that same creative — not
|
|
62
|
+
// whatever creative it may have navigated to while the popup was open.
|
|
63
|
+
var wizard = document.getElementById('linear-wizard');
|
|
64
|
+
var creativeId = wizard ? wizard.dataset.creativeId : '';
|
|
65
|
+
window.opener.postMessage(
|
|
66
|
+
{ type: 'linearConnected', creativeId: creativeId },
|
|
67
|
+
window.location.origin
|
|
68
|
+
);
|
|
57
69
|
}
|
|
58
70
|
} catch (e) {}
|
|
59
|
-
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Auto-advance on load: no click required — the opener reloads and jumps to
|
|
74
|
+
// the project settings ("link a project") step.
|
|
75
|
+
notifyOpener();
|
|
76
|
+
|
|
77
|
+
document.getElementById('close-btn').addEventListener('click', function() {
|
|
78
|
+
notifyOpener();
|
|
79
|
+
try { window.close(); } catch (e) {}
|
|
60
80
|
});
|
|
61
81
|
</script>
|
|
62
82
|
</body>
|
|
@@ -181,6 +181,40 @@
|
|
|
181
181
|
<% end %>
|
|
182
182
|
<% end %>
|
|
183
183
|
|
|
184
|
+
<%# --- Reconnect affordance (shown in BOTH connected sub-states) ---
|
|
185
|
+
An Account row existing only means "some token was stored once", not
|
|
186
|
+
"that token still works". If the admin revokes the app in Linear's
|
|
187
|
+
settings (or the token expires past refresh), the link form's options
|
|
188
|
+
fetch 502s and the modal has no way back to OAuth — the connect button
|
|
189
|
+
only lives in the not-connected branch below, so the user is stranded
|
|
190
|
+
on "Couldn't load your Linear projects and teams. Please try again."
|
|
191
|
+
Re-running OAuth updates the SAME Account row in place (auth#callback
|
|
192
|
+
find_or_initialize_by user_id), so existing project links and the
|
|
193
|
+
account id survive and only the dead token is replaced. Reuses the
|
|
194
|
+
connect form/button ids: connected and not-connected are mutually
|
|
195
|
+
exclusive, so each id still appears exactly once in the DOM and the
|
|
196
|
+
existing JS opener wiring drives this unchanged. %>
|
|
197
|
+
<div class="linear-reconnect"
|
|
198
|
+
style="margin-top:1em;padding-top:0.75em;border-top:1px solid var(--border-color);">
|
|
199
|
+
<p style="margin:0 0 0.5em;color:var(--text-muted);font-size:0.85em;">
|
|
200
|
+
<%= t("collavre_linear.integration.reconnect_hint") %>
|
|
201
|
+
</p>
|
|
202
|
+
<form id="linear-connect-form"
|
|
203
|
+
action="/linear/auth/store_creative"
|
|
204
|
+
method="post"
|
|
205
|
+
target="linear-auth-window"
|
|
206
|
+
style="display:none;">
|
|
207
|
+
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
|
|
208
|
+
<input type="hidden" name="creative_id" value="<%= creative_id %>">
|
|
209
|
+
</form>
|
|
210
|
+
<div>
|
|
211
|
+
<button type="button" id="linear-connect-btn" class="btn btn-secondary btn-sm"
|
|
212
|
+
data-window-width="620" data-window-height="720">
|
|
213
|
+
<%= t("collavre_linear.integration.reconnect_button") %>
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
184
218
|
<% else %>
|
|
185
219
|
<%# --- Not connected — show OAuth connect button --- %>
|
|
186
220
|
<p style="margin-bottom:0.75em;">
|
data/config/locales/en.yml
CHANGED
|
@@ -5,6 +5,7 @@ en:
|
|
|
5
5
|
invalid_state: "Invalid OAuth state. Please try connecting again."
|
|
6
6
|
login_first: "Please log in first."
|
|
7
7
|
already_linked_other: "This Linear account is already connected to a different Collavre user."
|
|
8
|
+
workspace_changed_relink: "This reconnect uses a different Linear workspace than the one your linked projects belong to. Unlink the existing project(s) first, then reconnect."
|
|
8
9
|
oauth_config_missing: "Linear OAuth is not configured (%{keys}). Set these before connecting."
|
|
9
10
|
integration:
|
|
10
11
|
label: "Linear"
|
|
@@ -12,6 +13,8 @@ en:
|
|
|
12
13
|
setup: "Setup"
|
|
13
14
|
connect_prompt: "Connect your Linear account to start syncing."
|
|
14
15
|
connect_button: "Connect Linear"
|
|
16
|
+
reconnect_hint: "Can't load your Linear projects? The connection may have expired or been revoked in Linear. Reconnect to fix it."
|
|
17
|
+
reconnect_button: "Reconnect Linear"
|
|
15
18
|
link_prompt: "Select a Linear project and team to link this creative."
|
|
16
19
|
team_id_label: "Team"
|
|
17
20
|
project_id_label: "Project"
|
data/config/locales/ko.yml
CHANGED
|
@@ -5,6 +5,7 @@ ko:
|
|
|
5
5
|
invalid_state: "OAuth 상태가 유효하지 않습니다. 다시 연결해 주세요."
|
|
6
6
|
login_first: "먼저 로그인해 주세요."
|
|
7
7
|
already_linked_other: "이 Linear 계정은 이미 다른 Collavre 사용자에 연결되어 있습니다."
|
|
8
|
+
workspace_changed_relink: "다시 연결하려는 Linear 워크스페이스가 기존에 연결된 프로젝트의 워크스페이스와 다릅니다. 먼저 기존 프로젝트 연결을 해제한 뒤 다시 연결해 주세요."
|
|
8
9
|
oauth_config_missing: "Linear OAuth가 설정되지 않았습니다 (%{keys}). 연결 전에 설정해 주세요."
|
|
9
10
|
integration:
|
|
10
11
|
label: "Linear"
|
|
@@ -12,6 +13,8 @@ ko:
|
|
|
12
13
|
setup: "설정"
|
|
13
14
|
connect_prompt: "Linear 계정을 연결하여 동기화를 시작하세요."
|
|
14
15
|
connect_button: "Linear 연결"
|
|
16
|
+
reconnect_hint: "Linear 프로젝트를 불러오지 못하나요? 연결이 만료되었거나 Linear에서 해제되었을 수 있습니다. 다시 연결하세요."
|
|
17
|
+
reconnect_button: "Linear 다시 연결"
|
|
15
18
|
link_prompt: "이 크리에이티브에 연결할 Linear 프로젝트와 팀을 선택하세요."
|
|
16
19
|
team_id_label: "팀"
|
|
17
20
|
project_id_label: "프로젝트"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: collavre_linear
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Collavre
|
|
@@ -49,7 +49,9 @@ files:
|
|
|
49
49
|
- app/controllers/collavre_linear/auth_controller.rb
|
|
50
50
|
- app/controllers/collavre_linear/creatives/integrations_controller.rb
|
|
51
51
|
- app/controllers/collavre_linear/webhooks_controller.rb
|
|
52
|
+
- app/javascript/__tests__/linear_modal_reopen.test.js
|
|
52
53
|
- app/javascript/collavre_linear.js
|
|
54
|
+
- app/javascript/linear_modal_reopen.js
|
|
53
55
|
- app/jobs/collavre_linear/inbound_apply_job.rb
|
|
54
56
|
- app/jobs/collavre_linear/outbound_archive_job.rb
|
|
55
57
|
- app/jobs/collavre_linear/outbound_comment_delete_job.rb
|