@anymux/connect 0.1.0
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.
- package/dist/GitBrowser-BLgTNQyd.js +905 -0
- package/dist/GitBrowser-BLgTNQyd.js.map +1 -0
- package/dist/GitBrowser-CIyWiuX-.js +3 -0
- package/dist/ObjectStorageBrowser-B2YkUxMl.js +3 -0
- package/dist/ObjectStorageBrowser-B_25Emfu.js +267 -0
- package/dist/ObjectStorageBrowser-B_25Emfu.js.map +1 -0
- package/dist/RepoPicker-BprFGOn7.js +3 -0
- package/dist/RepoPicker-CoHMiJ-3.js +168 -0
- package/dist/RepoPicker-CoHMiJ-3.js.map +1 -0
- package/dist/index.d.ts +697 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2539 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +3 -0
- package/dist/scope-labels-B4VAwoL6.js +582 -0
- package/dist/scope-labels-B4VAwoL6.js.map +1 -0
- package/dist/scope-labels-DvdJLcSL.d.ts +50 -0
- package/dist/scope-labels-DvdJLcSL.d.ts.map +1 -0
- package/package.json +87 -0
- package/src/adapters/adapter-registry.ts +177 -0
- package/src/auth/auth-client.ts +101 -0
- package/src/auth/token-manager.ts +27 -0
- package/src/components/ActionHistoryPanel.tsx +137 -0
- package/src/components/CapabilityCell.tsx +97 -0
- package/src/components/CapabilityError.tsx +50 -0
- package/src/components/CapabilityPanel.tsx +530 -0
- package/src/components/CapabilityPill.tsx +56 -0
- package/src/components/ConnectButton.tsx +149 -0
- package/src/components/ConnectedMenu.tsx +142 -0
- package/src/components/ConnectionStatus.tsx +28 -0
- package/src/components/CredentialForm.tsx +246 -0
- package/src/components/FullScreenBrowser.tsx +84 -0
- package/src/components/GitBrowser.tsx +705 -0
- package/src/components/GitHubRepoPicker.tsx +125 -0
- package/src/components/ObjectStorageBrowser.tsx +176 -0
- package/src/components/RepoPicker.tsx +93 -0
- package/src/components/ServiceCard.tsx +77 -0
- package/src/components/ServiceCardGrid.tsx +141 -0
- package/src/components/ServiceDashboard.tsx +84 -0
- package/src/components/ServiceIcon.tsx +37 -0
- package/src/components/ServiceRow.tsx +50 -0
- package/src/components/useAdapter.ts +33 -0
- package/src/demos/ServiceDashboardDemo.tsx +108 -0
- package/src/index.ts +68 -0
- package/src/models/ActionNotificationModel.ts +72 -0
- package/src/models/ConnectionManagerModel.ts +410 -0
- package/src/models/CredentialFormModel.ts +111 -0
- package/src/models/DashboardModel.ts +157 -0
- package/src/models/GitHostBrowserModel.ts +89 -0
- package/src/models/GitRepoBrowserModel.ts +285 -0
- package/src/models/ObjectStorageBrowserModel.ts +131 -0
- package/src/models/RepoPickerModel.ts +132 -0
- package/src/registry/service-registry.ts +46 -0
- package/src/registry/services/apple.ts +22 -0
- package/src/registry/services/bitbucket.ts +24 -0
- package/src/registry/services/box.ts +22 -0
- package/src/registry/services/browser-fs.ts +19 -0
- package/src/registry/services/dropbox.ts +22 -0
- package/src/registry/services/flickr.ts +22 -0
- package/src/registry/services/gitea.ts +24 -0
- package/src/registry/services/github.ts +24 -0
- package/src/registry/services/gitlab.ts +24 -0
- package/src/registry/services/google.ts +24 -0
- package/src/registry/services/icloud.ts +23 -0
- package/src/registry/services/indexeddb.ts +19 -0
- package/src/registry/services/instagram.ts +22 -0
- package/src/registry/services/microsoft.ts +24 -0
- package/src/registry/services/s3.ts +21 -0
- package/src/registry/services/webdav.ts +21 -0
- package/src/registry.ts +4 -0
- package/src/types/connection-state.ts +33 -0
- package/src/types/connection.ts +11 -0
- package/src/types/optional-deps.d.ts +149 -0
- package/src/types/service.ts +18 -0
- package/src/types/user-profile.ts +21 -0
- package/src/utils/action-toast.ts +53 -0
- package/src/utils/scope-labels.ts +91 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { makeAutoObservable, runInAction, flow } from 'mobx';
|
|
2
|
+
import type { ConnectedService, ConnectionStatus } from '../types/connection';
|
|
3
|
+
import type { ConnectAuthClient } from '../auth/auth-client';
|
|
4
|
+
import type { CapabilityId } from '../types/service';
|
|
5
|
+
import type { IUserProfile } from '../types/user-profile';
|
|
6
|
+
import { TokenManager } from '../auth/token-manager';
|
|
7
|
+
import { serviceRegistry } from '../registry/service-registry';
|
|
8
|
+
|
|
9
|
+
const STORAGE_KEY = 'anymux-connect-connections';
|
|
10
|
+
const USER_PROFILES_STORAGE_KEY = 'anymux-connect-user-profiles';
|
|
11
|
+
|
|
12
|
+
export class ConnectionManagerModel {
|
|
13
|
+
connections = new Map<string, ConnectedService>();
|
|
14
|
+
grantedScopes = new Map<string, string[]>();
|
|
15
|
+
initialized = false;
|
|
16
|
+
configuredProviders = new Set<string>();
|
|
17
|
+
configError: string | null = null;
|
|
18
|
+
/** Per-service user profiles with provider-specific name, avatar, and profile link */
|
|
19
|
+
userProfiles = new Map<string, IUserProfile>();
|
|
20
|
+
testCredentials: Record<string, unknown> = {};
|
|
21
|
+
pendingReconnect = new Set<string>();
|
|
22
|
+
private tokenManager = new TokenManager();
|
|
23
|
+
private authClient: ConnectAuthClient | null;
|
|
24
|
+
|
|
25
|
+
constructor(options?: { authClient?: ConnectAuthClient }) {
|
|
26
|
+
this.authClient = options?.authClient ?? null;
|
|
27
|
+
makeAutoObservable(this);
|
|
28
|
+
this.loadFromStorage();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async initialize(): Promise<void> {
|
|
32
|
+
if (this.initialized) return;
|
|
33
|
+
|
|
34
|
+
// BUG-2: Handle pending OAuth return FIRST so only that service updates immediately
|
|
35
|
+
if (this.authClient) {
|
|
36
|
+
const pendingServiceId = this.authClient.getPendingServiceId();
|
|
37
|
+
if (pendingServiceId) {
|
|
38
|
+
await this.handleOAuthReturn(pendingServiceId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// BUG-3: Set loading state for services not yet resolved from storage or OAuth
|
|
43
|
+
for (const service of serviceRegistry.getAll()) {
|
|
44
|
+
if (!this.connections.has(service.id)) {
|
|
45
|
+
this.connections.set(service.id, {
|
|
46
|
+
serviceId: service.id,
|
|
47
|
+
status: 'loading',
|
|
48
|
+
scopes: [],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (this.authClient) {
|
|
54
|
+
// Fetch which OAuth providers have credentials configured on the server
|
|
55
|
+
try {
|
|
56
|
+
const config = await this.authClient.fetchConfiguredProviders();
|
|
57
|
+
runInAction(() => {
|
|
58
|
+
if (!config.database) {
|
|
59
|
+
this.configError = 'Database not configured. Set DATABASE_URL in .env.local';
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!config.authSecret) {
|
|
63
|
+
this.configError = 'Auth secret not configured. Set BETTER_AUTH_SECRET in .env.local';
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const [provider, configured] of Object.entries(config.providers)) {
|
|
67
|
+
if (configured) this.configuredProviders.add(provider);
|
|
68
|
+
}
|
|
69
|
+
// Mark unconfigured OAuth services as not_configured
|
|
70
|
+
for (const service of serviceRegistry.getAll()) {
|
|
71
|
+
if (service.authProvider === 's3' || service.authProvider === 'webdav' || service.authProvider === 'gitea' || service.authProvider === 'browser-fs' || service.authProvider === 'indexeddb') continue;
|
|
72
|
+
if (!this.configuredProviders.has(service.authProvider) && !this.isConnected(service.id)) {
|
|
73
|
+
this.connections.set(service.id, {
|
|
74
|
+
serviceId: service.id,
|
|
75
|
+
status: 'not_configured',
|
|
76
|
+
scopes: [],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.warn('[AnyMux] Failed to fetch configured providers:', err instanceof Error ? err.message : err);
|
|
83
|
+
runInAction(() => {
|
|
84
|
+
this.configError = 'Failed to reach server. Check your connection.';
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fetch granted scopes
|
|
89
|
+
try {
|
|
90
|
+
const scopes = await this.authClient.fetchGrantedScopes();
|
|
91
|
+
runInAction(() => {
|
|
92
|
+
for (const [provider, scopeList] of Object.entries(scopes)) {
|
|
93
|
+
this.grantedScopes.set(provider, scopeList);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.warn('[AnyMux] Failed to fetch granted scopes:', err instanceof Error ? err.message : err);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Fetch test credentials
|
|
101
|
+
try {
|
|
102
|
+
const creds = await this.authClient.fetchTestCredentials();
|
|
103
|
+
runInAction(() => {
|
|
104
|
+
this.testCredentials = creds;
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn('[AnyMux] Failed to fetch test credentials:', err instanceof Error ? err.message : err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// BUG-3: Resolve any remaining 'loading' services to 'disconnected'
|
|
112
|
+
runInAction(() => {
|
|
113
|
+
for (const [id, conn] of this.connections) {
|
|
114
|
+
if (conn.status === 'loading') {
|
|
115
|
+
this.connections.set(id, { ...conn, status: 'disconnected' });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this.initialized = true;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Fetch per-provider user profiles in the background (enriches fallback data with
|
|
122
|
+
// provider-specific avatars, profile URLs, etc.)
|
|
123
|
+
if (this.authClient) {
|
|
124
|
+
this.fetchAndStoreUserProfiles();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Fetch per-provider profiles from the server and update the userProfiles map */
|
|
129
|
+
private async fetchAndStoreUserProfiles(): Promise<void> {
|
|
130
|
+
if (!this.authClient) return;
|
|
131
|
+
try {
|
|
132
|
+
const profilesByProvider = await this.authClient.fetchUserProfiles();
|
|
133
|
+
runInAction(() => {
|
|
134
|
+
// Map providerId → serviceId(s) and store profiles
|
|
135
|
+
for (const service of serviceRegistry.getAll()) {
|
|
136
|
+
const providerProfile = profilesByProvider[service.authProvider];
|
|
137
|
+
if (providerProfile && this.isConnected(service.id)) {
|
|
138
|
+
this.userProfiles.set(service.id, {
|
|
139
|
+
...providerProfile,
|
|
140
|
+
provider: service.id,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
this.persistToStorage();
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.warn('[AnyMux] Failed to fetch user profiles:', err instanceof Error ? err.message : err);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// BUG-2: Extracted OAuth return handler — only updates the single pending service
|
|
152
|
+
private async handleOAuthReturn(pendingServiceId: string): Promise<void> {
|
|
153
|
+
if (!this.authClient) return;
|
|
154
|
+
const service = serviceRegistry.get(pendingServiceId);
|
|
155
|
+
if (service) {
|
|
156
|
+
// Retry token retrieval — after OAuth redirect the server may not have
|
|
157
|
+
// finished processing the callback yet (race condition)
|
|
158
|
+
let token: string | null = null;
|
|
159
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
160
|
+
try {
|
|
161
|
+
token = await this.authClient.getAccessToken(service.authProvider);
|
|
162
|
+
if (token) break;
|
|
163
|
+
} catch {
|
|
164
|
+
// getAccessToken threw — retry
|
|
165
|
+
}
|
|
166
|
+
if (attempt < 3) {
|
|
167
|
+
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (token) {
|
|
172
|
+
// Capture session user info as an immediate fallback for the profile
|
|
173
|
+
let fallbackProfile: IUserProfile | undefined;
|
|
174
|
+
try {
|
|
175
|
+
const session = await this.authClient.getSession();
|
|
176
|
+
if (session) {
|
|
177
|
+
const profile: IUserProfile = {
|
|
178
|
+
id: session.user.id,
|
|
179
|
+
name: session.user.name,
|
|
180
|
+
provider: pendingServiceId,
|
|
181
|
+
};
|
|
182
|
+
if (session.user.image) {
|
|
183
|
+
profile.avatarUrl = session.user.image;
|
|
184
|
+
}
|
|
185
|
+
fallbackProfile = profile;
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.warn(`[AnyMux] Failed to fetch session for ${pendingServiceId}:`, err instanceof Error ? err.message : err);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
runInAction(() => {
|
|
192
|
+
this.tokenManager.setToken(pendingServiceId, token!);
|
|
193
|
+
this.connections.set(pendingServiceId, {
|
|
194
|
+
serviceId: pendingServiceId,
|
|
195
|
+
status: 'connected',
|
|
196
|
+
accessToken: token!,
|
|
197
|
+
scopes: Object.values(service.scopes).flat(),
|
|
198
|
+
connectedAt: new Date(),
|
|
199
|
+
});
|
|
200
|
+
if (fallbackProfile) {
|
|
201
|
+
this.userProfiles.set(pendingServiceId, fallbackProfile);
|
|
202
|
+
}
|
|
203
|
+
this.persistToStorage();
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
// OAuth flow completed but token retrieval failed after retries.
|
|
207
|
+
// Check if we have a session — if so, the user authenticated but
|
|
208
|
+
// the token endpoint is having issues.
|
|
209
|
+
try {
|
|
210
|
+
const session = await this.authClient.getSession();
|
|
211
|
+
if (session) {
|
|
212
|
+
console.warn(`[AnyMux] OAuth for ${pendingServiceId}: session exists but getAccessToken returned null after 4 attempts`);
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// ignore
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
this.authClient.clearPendingServiceId();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private loadFromStorage() {
|
|
223
|
+
try {
|
|
224
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
225
|
+
if (stored) {
|
|
226
|
+
const data = JSON.parse(stored) as Array<[string, ConnectedService]>;
|
|
227
|
+
for (const [key, value] of data) {
|
|
228
|
+
const token = this.tokenManager.getToken(key);
|
|
229
|
+
if (token) {
|
|
230
|
+
this.connections.set(key, { ...value, accessToken: token });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Restore per-service user profiles
|
|
235
|
+
const storedProfiles = localStorage.getItem(USER_PROFILES_STORAGE_KEY);
|
|
236
|
+
if (storedProfiles) {
|
|
237
|
+
const entries = JSON.parse(storedProfiles) as Array<[string, IUserProfile]>;
|
|
238
|
+
for (const [key, value] of entries) {
|
|
239
|
+
this.userProfiles.set(key, value);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// storage unavailable
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private persistToStorage() {
|
|
248
|
+
try {
|
|
249
|
+
const data = Array.from(this.connections.entries()).map(
|
|
250
|
+
([key, conn]) => [key, { ...conn, accessToken: undefined }] as const
|
|
251
|
+
);
|
|
252
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
253
|
+
// Persist per-service user profiles
|
|
254
|
+
const profileData = Array.from(this.userProfiles.entries());
|
|
255
|
+
localStorage.setItem(USER_PROFILES_STORAGE_KEY, JSON.stringify(profileData));
|
|
256
|
+
} catch {
|
|
257
|
+
// storage unavailable
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async connect(serviceId: string): Promise<void> {
|
|
262
|
+
const service = serviceRegistry.get(serviceId);
|
|
263
|
+
if (!service) return;
|
|
264
|
+
|
|
265
|
+
if (!this.authClient) {
|
|
266
|
+
this.connections.set(serviceId, {
|
|
267
|
+
serviceId,
|
|
268
|
+
status: 'error',
|
|
269
|
+
scopes: [],
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
this.connections.set(serviceId, {
|
|
275
|
+
serviceId,
|
|
276
|
+
status: 'connecting',
|
|
277
|
+
scopes: [],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Redirect-based flow: browser navigates away
|
|
281
|
+
await this.authClient.signIn(service.authProvider, serviceId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
connectWithCredentials(serviceId: string, credentialToken: string): void {
|
|
285
|
+
const service = serviceRegistry.get(serviceId);
|
|
286
|
+
if (!service) return;
|
|
287
|
+
|
|
288
|
+
this.tokenManager.setToken(serviceId, credentialToken);
|
|
289
|
+
this.connections.set(serviceId, {
|
|
290
|
+
serviceId,
|
|
291
|
+
status: 'connected',
|
|
292
|
+
accessToken: credentialToken,
|
|
293
|
+
scopes: Object.values(service.scopes).flat(),
|
|
294
|
+
connectedAt: new Date(),
|
|
295
|
+
});
|
|
296
|
+
this.persistToStorage();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
disconnect = flow(function* (this: ConnectionManagerModel, serviceId: string) {
|
|
300
|
+
const service = serviceRegistry.get(serviceId);
|
|
301
|
+
this.tokenManager.removeToken(serviceId);
|
|
302
|
+
this.connections.set(serviceId, {
|
|
303
|
+
serviceId,
|
|
304
|
+
status: 'disconnected',
|
|
305
|
+
scopes: [],
|
|
306
|
+
});
|
|
307
|
+
if (service) {
|
|
308
|
+
this.grantedScopes.delete(service.authProvider);
|
|
309
|
+
}
|
|
310
|
+
this.userProfiles.delete(serviceId);
|
|
311
|
+
this.persistToStorage();
|
|
312
|
+
|
|
313
|
+
if (this.authClient) {
|
|
314
|
+
// Delete the account row first so better-auth creates a fresh one
|
|
315
|
+
// with updated scopes on the next OAuth sign-in
|
|
316
|
+
if (service) {
|
|
317
|
+
try {
|
|
318
|
+
console.info(`[AnyMux] disconnect(${serviceId}): revoking provider ${service.authProvider}…`);
|
|
319
|
+
yield this.authClient.revokeProvider(service.authProvider);
|
|
320
|
+
console.info(`[AnyMux] disconnect(${serviceId}): revoke succeeded`);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.error('[AnyMux] revokeProvider FAILED:', err instanceof Error ? err.message : err);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
yield this.authClient.signOut();
|
|
327
|
+
console.info(`[AnyMux] disconnect(${serviceId}): signOut succeeded`);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
console.warn('[AnyMux] signOut failed:', err instanceof Error ? err.message : err);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
requestReconnect(serviceId: string): void {
|
|
335
|
+
this.pendingReconnect.add(serviceId);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
clearReconnectRequest(serviceId: string): void {
|
|
339
|
+
this.pendingReconnect.delete(serviceId);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
isConnected(serviceId: string): boolean {
|
|
343
|
+
return this.connections.get(serviceId)?.status === 'connected';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
getStatus(serviceId: string): ConnectionStatus {
|
|
347
|
+
return this.connections.get(serviceId)?.status ?? 'disconnected';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
getToken(serviceId: string): string | null {
|
|
351
|
+
return this.tokenManager.getToken(serviceId);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Refresh OAuth token from better-auth. Returns fresh token or null. */
|
|
355
|
+
async refreshToken(serviceId: string): Promise<string | null> {
|
|
356
|
+
const service = serviceRegistry.get(serviceId);
|
|
357
|
+
if (!service || !this.authClient) {
|
|
358
|
+
console.warn(`[AnyMux] refreshToken(${serviceId}): no service or authClient`);
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
// Skip credential-based services — their tokens don't expire
|
|
362
|
+
if (['s3', 'webdav', 'gitea', 'icloud', 'browser-fs', 'indexeddb'].includes(service.authProvider)) {
|
|
363
|
+
return this.tokenManager.getToken(serviceId);
|
|
364
|
+
}
|
|
365
|
+
const oldToken = this.tokenManager.getToken(serviceId);
|
|
366
|
+
try {
|
|
367
|
+
const token = await this.authClient.getAccessToken(service.authProvider);
|
|
368
|
+
if (token) {
|
|
369
|
+
const changed = token !== oldToken;
|
|
370
|
+
console.info(`[AnyMux] refreshToken(${serviceId}): got token (${token.slice(0, 8)}…${token.slice(-4)}), changed=${changed}`);
|
|
371
|
+
this.tokenManager.setToken(serviceId, token);
|
|
372
|
+
const conn = this.connections.get(serviceId);
|
|
373
|
+
if (conn) {
|
|
374
|
+
this.connections.set(serviceId, { ...conn, accessToken: token });
|
|
375
|
+
}
|
|
376
|
+
return token;
|
|
377
|
+
}
|
|
378
|
+
console.warn(`[AnyMux] refreshToken(${serviceId}): getAccessToken returned null`);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
console.warn(`[AnyMux] refreshToken(${serviceId}): error:`, err instanceof Error ? err.message : err);
|
|
381
|
+
}
|
|
382
|
+
console.warn(`[AnyMux] refreshToken(${serviceId}): falling back to stored token (${oldToken ? oldToken.slice(0, 8) + '…' : 'null'})`);
|
|
383
|
+
return oldToken;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Get the full user profile for a connected service */
|
|
387
|
+
getUserProfile(serviceId: string): IUserProfile | undefined {
|
|
388
|
+
return this.userProfiles.get(serviceId);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** @deprecated Use getUserProfile() instead. Kept for backward compat. */
|
|
392
|
+
getUserInfo(serviceId: string): { name: string; image?: string } | null {
|
|
393
|
+
const profile = this.userProfiles.get(serviceId);
|
|
394
|
+
if (!profile) return null;
|
|
395
|
+
return { name: profile.name, ...(profile.avatarUrl ? { image: profile.avatarUrl } : {}) };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
hasCapabilityScopes(serviceId: string, capabilityId: CapabilityId): boolean {
|
|
399
|
+
const service = serviceRegistry.get(serviceId);
|
|
400
|
+
if (!service) return false;
|
|
401
|
+
// S3/WebDAV/Gitea don't use OAuth scopes — treat all as granted
|
|
402
|
+
if (service.authProvider === 's3' || service.authProvider === 'webdav' || service.authProvider === 'gitea' || service.authProvider === 'browser-fs' || service.authProvider === 'indexeddb') return true;
|
|
403
|
+
const requiredScopes = service.scopes[capabilityId];
|
|
404
|
+
if (!requiredScopes || requiredScopes.length === 0) return false;
|
|
405
|
+
// If scopes haven't been loaded yet, assume granted to avoid a flash warning
|
|
406
|
+
if (!this.grantedScopes.has(service.authProvider)) return true;
|
|
407
|
+
const granted = this.grantedScopes.get(service.authProvider)!;
|
|
408
|
+
return requiredScopes.every(s => granted.includes(s));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { makeAutoObservable } from 'mobx';
|
|
2
|
+
|
|
3
|
+
export type CredentialServiceType = 's3' | 'webdav' | 'gitea' | 'icloud';
|
|
4
|
+
|
|
5
|
+
export class CredentialFormModel {
|
|
6
|
+
open = false;
|
|
7
|
+
serviceType: CredentialServiceType = 's3';
|
|
8
|
+
|
|
9
|
+
// S3 fields
|
|
10
|
+
accessKeyId = '';
|
|
11
|
+
secretAccessKey = '';
|
|
12
|
+
region = 'us-east-1';
|
|
13
|
+
bucket = '';
|
|
14
|
+
endpoint = '';
|
|
15
|
+
|
|
16
|
+
// WebDAV fields
|
|
17
|
+
url = '';
|
|
18
|
+
username = '';
|
|
19
|
+
password = '';
|
|
20
|
+
|
|
21
|
+
// Gitea fields
|
|
22
|
+
token = '';
|
|
23
|
+
owner = '';
|
|
24
|
+
repo = '';
|
|
25
|
+
// Gitea also uses url, username, password above
|
|
26
|
+
|
|
27
|
+
// iCloud fields
|
|
28
|
+
email = '';
|
|
29
|
+
appPassword = '';
|
|
30
|
+
|
|
31
|
+
constructor() {
|
|
32
|
+
makeAutoObservable(this);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
openForm(serviceType: CredentialServiceType, prefill?: Record<string, string>): void {
|
|
36
|
+
this.serviceType = serviceType;
|
|
37
|
+
this.resetFields();
|
|
38
|
+
if (prefill) this.applyPrefill(prefill);
|
|
39
|
+
this.open = true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
closeForm(): void {
|
|
43
|
+
this.open = false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setField(field: string, value: string): void {
|
|
47
|
+
if (field in this) {
|
|
48
|
+
(this as Record<string, unknown>)[field] = value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Serialize current form state to JSON credential string */
|
|
53
|
+
serialize(): string {
|
|
54
|
+
switch (this.serviceType) {
|
|
55
|
+
case 's3': {
|
|
56
|
+
const creds: Record<string, string> = {
|
|
57
|
+
accessKeyId: this.accessKeyId,
|
|
58
|
+
secretAccessKey: this.secretAccessKey,
|
|
59
|
+
region: this.region,
|
|
60
|
+
bucket: this.bucket,
|
|
61
|
+
};
|
|
62
|
+
if (this.endpoint) creds.endpoint = this.endpoint;
|
|
63
|
+
return JSON.stringify(creds);
|
|
64
|
+
}
|
|
65
|
+
case 'webdav':
|
|
66
|
+
return JSON.stringify({
|
|
67
|
+
url: this.url,
|
|
68
|
+
username: this.username,
|
|
69
|
+
password: this.password,
|
|
70
|
+
});
|
|
71
|
+
case 'gitea':
|
|
72
|
+
return JSON.stringify({
|
|
73
|
+
url: this.url,
|
|
74
|
+
username: this.username,
|
|
75
|
+
password: this.password,
|
|
76
|
+
token: this.token,
|
|
77
|
+
owner: this.owner,
|
|
78
|
+
repo: this.repo,
|
|
79
|
+
});
|
|
80
|
+
case 'icloud':
|
|
81
|
+
return JSON.stringify({
|
|
82
|
+
email: this.email,
|
|
83
|
+
appPassword: this.appPassword,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private resetFields(): void {
|
|
89
|
+
this.accessKeyId = '';
|
|
90
|
+
this.secretAccessKey = '';
|
|
91
|
+
this.region = 'us-east-1';
|
|
92
|
+
this.bucket = '';
|
|
93
|
+
this.endpoint = '';
|
|
94
|
+
this.url = '';
|
|
95
|
+
this.username = '';
|
|
96
|
+
this.password = '';
|
|
97
|
+
this.token = '';
|
|
98
|
+
this.owner = '';
|
|
99
|
+
this.repo = '';
|
|
100
|
+
this.email = '';
|
|
101
|
+
this.appPassword = '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private applyPrefill(values: Record<string, string>): void {
|
|
105
|
+
for (const [k, v] of Object.entries(values)) {
|
|
106
|
+
if (typeof v === 'string' && k in this) {
|
|
107
|
+
(this as Record<string, unknown>)[k] = v;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { makeAutoObservable } from 'mobx';
|
|
2
|
+
import type { CapabilityId } from '../types/service';
|
|
3
|
+
import type { ConnectionManagerModel } from './ConnectionManagerModel';
|
|
4
|
+
import { ActionNotificationModel } from './ActionNotificationModel';
|
|
5
|
+
import { serviceRegistry } from '../registry/service-registry';
|
|
6
|
+
|
|
7
|
+
export interface SelectedCell {
|
|
8
|
+
serviceId: string;
|
|
9
|
+
capabilityId: CapabilityId;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GitBrowserState {
|
|
13
|
+
serviceId: string;
|
|
14
|
+
owner?: string;
|
|
15
|
+
repo?: string;
|
|
16
|
+
ref?: string;
|
|
17
|
+
path?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const REPO_STORAGE_KEY = 'anymux-selected-repos';
|
|
21
|
+
|
|
22
|
+
export class DashboardModel {
|
|
23
|
+
selectedCell: SelectedCell | null = null;
|
|
24
|
+
panelOpen = false;
|
|
25
|
+
/** Generic repo selection per service (replaces githubRepo) */
|
|
26
|
+
selectedRepos: Record<string, { owner: string; repo: string }> = {};
|
|
27
|
+
browserPath = '/';
|
|
28
|
+
gitBrowserState: GitBrowserState | null = null;
|
|
29
|
+
actionNotifications = new ActionNotificationModel();
|
|
30
|
+
private connectionManager: ConnectionManagerModel;
|
|
31
|
+
/** Optional callback when selected cell changes (for URL sync) */
|
|
32
|
+
onCellChange?: (cell: SelectedCell | null) => void;
|
|
33
|
+
/** Optional callback when browser path changes (for URL sync) */
|
|
34
|
+
onPathChange?: (path: string) => void;
|
|
35
|
+
|
|
36
|
+
constructor(connectionManager: ConnectionManagerModel) {
|
|
37
|
+
this.connectionManager = connectionManager;
|
|
38
|
+
makeAutoObservable(this, { onCellChange: false, onPathChange: false });
|
|
39
|
+
// Load repos from localStorage
|
|
40
|
+
try {
|
|
41
|
+
const stored = localStorage.getItem(REPO_STORAGE_KEY);
|
|
42
|
+
if (stored) this.selectedRepos = JSON.parse(stored);
|
|
43
|
+
// Migrate old github-repo key
|
|
44
|
+
const oldGithub = localStorage.getItem('anymux-github-repo');
|
|
45
|
+
if (oldGithub && !this.selectedRepos['github']) {
|
|
46
|
+
this.selectedRepos['github'] = JSON.parse(oldGithub);
|
|
47
|
+
this.persistRepos();
|
|
48
|
+
localStorage.removeItem('anymux-github-repo');
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @deprecated Use getSelectedRepo('github') instead */
|
|
54
|
+
get githubRepo(): { owner: string; repo: string } | null {
|
|
55
|
+
return this.selectedRepos['github'] ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @deprecated Use setSelectedRepo('github', repo) instead */
|
|
59
|
+
setGitHubRepo(repo: { owner: string; repo: string }): void {
|
|
60
|
+
this.setSelectedRepo('github', repo);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @deprecated Use clearSelectedRepo('github') instead */
|
|
64
|
+
clearGitHubRepo(): void {
|
|
65
|
+
this.clearSelectedRepo('github');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setSelectedRepo(serviceId: string, repo: { owner: string; repo: string }): void {
|
|
69
|
+
this.selectedRepos[serviceId] = repo;
|
|
70
|
+
this.persistRepos();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
clearSelectedRepo(serviceId: string): void {
|
|
74
|
+
delete this.selectedRepos[serviceId];
|
|
75
|
+
this.persistRepos();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getSelectedRepo(serviceId: string): { owner: string; repo: string } | null {
|
|
79
|
+
return this.selectedRepos[serviceId] ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private persistRepos(): void {
|
|
83
|
+
try {
|
|
84
|
+
localStorage.setItem(REPO_STORAGE_KEY, JSON.stringify(this.selectedRepos));
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
selectCell(serviceId: string, capabilityId: CapabilityId): void {
|
|
89
|
+
const service = serviceRegistry.get(serviceId);
|
|
90
|
+
if (!service) return;
|
|
91
|
+
|
|
92
|
+
const capability = service.capabilities.find((c) => c.id === capabilityId);
|
|
93
|
+
if (!capability?.supported) return;
|
|
94
|
+
|
|
95
|
+
// Clear selected repo so the picker always shows when clicking a cell
|
|
96
|
+
if (capabilityId === 'git-repo' || capabilityId === 'git-host' || capabilityId === 'file-system') {
|
|
97
|
+
this.clearSelectedRepo(serviceId);
|
|
98
|
+
}
|
|
99
|
+
if (!this.connectionManager.isConnected(serviceId)) return;
|
|
100
|
+
|
|
101
|
+
this.selectedCell = { serviceId, capabilityId };
|
|
102
|
+
this.panelOpen = true;
|
|
103
|
+
this.onCellChange?.({ serviceId, capabilityId });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Open a cell without checking connection/capability (used for URL restore) */
|
|
107
|
+
openCell(serviceId: string, capabilityId: CapabilityId): void {
|
|
108
|
+
// Clear selected repo — URL doesn't carry repo info, so always show picker
|
|
109
|
+
if (capabilityId === 'git-repo' || capabilityId === 'git-host' || capabilityId === 'file-system') {
|
|
110
|
+
this.clearSelectedRepo(serviceId);
|
|
111
|
+
}
|
|
112
|
+
this.selectedCell = { serviceId, capabilityId };
|
|
113
|
+
this.panelOpen = true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
setBrowserPath(path: string): void {
|
|
117
|
+
this.browserPath = path;
|
|
118
|
+
this.onPathChange?.(path);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setGitBrowserState(state: GitBrowserState | null): void {
|
|
122
|
+
this.gitBrowserState = state;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
closePanel(): void {
|
|
126
|
+
this.selectedCell = null;
|
|
127
|
+
this.panelOpen = false;
|
|
128
|
+
this.browserPath = '/';
|
|
129
|
+
this.gitBrowserState = null;
|
|
130
|
+
this.onCellChange?.(null);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Close panel without triggering onCellChange (used for URL-driven state sync, e.g. browser back) */
|
|
134
|
+
closePanelSilent(): void {
|
|
135
|
+
this.selectedCell = null;
|
|
136
|
+
this.panelOpen = false;
|
|
137
|
+
this.browserPath = '/';
|
|
138
|
+
this.gitBrowserState = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Set browser path without triggering onPathChange (used for URL-driven state sync) */
|
|
142
|
+
setBrowserPathSilent(path: string): void {
|
|
143
|
+
this.browserPath = path;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get selectedService() {
|
|
147
|
+
if (!this.selectedCell) return null;
|
|
148
|
+
return serviceRegistry.get(this.selectedCell.serviceId) ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get selectedCapability() {
|
|
152
|
+
if (!this.selectedCell || !this.selectedService) return null;
|
|
153
|
+
return this.selectedService.capabilities.find(
|
|
154
|
+
(c) => c.id === this.selectedCell!.capabilityId
|
|
155
|
+
) ?? null;
|
|
156
|
+
}
|
|
157
|
+
}
|