@xenide-io/suite-client 0.2.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/index.mjs ADDED
@@ -0,0 +1,1111 @@
1
+ // src/errors/codes.ts
2
+ var ERROR_CODES = {
3
+ AUTH_001: "E-AUTH-001",
4
+ AUTH_002: "E-AUTH-002",
5
+ AUTH_003: "E-AUTH-003",
6
+ VALID_001: "E-VALID-001",
7
+ DATA_001: "E-DATA-001",
8
+ DATA_002: "E-DATA-002",
9
+ LIMIT_001: "E-LIMIT-001",
10
+ NET_001: "E-NET-001",
11
+ EXT_001: "E-EXT-001",
12
+ EXT_002: "E-EXT-002",
13
+ SYS_001: "E-SYS-001",
14
+ SYS_002: "E-SYS-002",
15
+ CLIENT_001: "E-CLIENT-001",
16
+ CLIENT_002: "E-CLIENT-002"
17
+ };
18
+ var DEFAULT_ERROR_CODE = "E-SYS-001";
19
+ var ERROR_COPY = {
20
+ "E-AUTH-001": "Sign-in was not accepted. Check your details and try again.",
21
+ "E-AUTH-002": "Your session has ended. Sign in again to continue.",
22
+ "E-AUTH-003": "You do not have access to this. Ask an administrator for access.",
23
+ "E-VALID-001": "Some details need correcting before this can be saved.",
24
+ "E-DATA-001": "We could not find what you asked for. It may have been removed.",
25
+ "E-DATA-002": "This conflicts with existing information. Review it and try again.",
26
+ "E-LIMIT-001": "Too many requests right now. Wait a moment and try again.",
27
+ "E-NET-001": "We could not reach the service. Try again in a moment.",
28
+ "E-EXT-001": "The <capability> is unavailable right now. Try again shortly.",
29
+ "E-EXT-002": "The <capability> could not complete that request. Try again, or adjust the input.",
30
+ "E-SYS-001": "Something went wrong on our side. We have recorded it. Try again.",
31
+ "E-SYS-002": "This feature is temporarily unavailable while we carry out maintenance.",
32
+ "E-CLIENT-001": "You appear to be offline. Check your connection and try again.",
33
+ "E-CLIENT-002": "We could not read the response. Reload the page and try again."
34
+ };
35
+ var ERROR_RETRYABLE = {
36
+ "E-AUTH-001": false,
37
+ "E-AUTH-002": true,
38
+ "E-AUTH-003": false,
39
+ "E-VALID-001": false,
40
+ "E-DATA-001": false,
41
+ "E-DATA-002": false,
42
+ "E-LIMIT-001": true,
43
+ "E-NET-001": true,
44
+ "E-EXT-001": true,
45
+ "E-EXT-002": true,
46
+ "E-SYS-001": true,
47
+ "E-SYS-002": false,
48
+ "E-CLIENT-001": true,
49
+ "E-CLIENT-002": true
50
+ };
51
+ var ERROR_CODE_SET = new Set(Object.values(ERROR_CODES));
52
+ function isErrorCode(value) {
53
+ return typeof value === "string" && ERROR_CODE_SET.has(value);
54
+ }
55
+ function copyFor(code, capability) {
56
+ const copy = ERROR_COPY[code] ?? ERROR_COPY[DEFAULT_ERROR_CODE];
57
+ if (copy.includes("<capability>")) {
58
+ return copy.replace("<capability>", capability ?? "service");
59
+ }
60
+ return copy;
61
+ }
62
+
63
+ // src/errors/normalize.ts
64
+ var STATUS_TO_CODE = {
65
+ 400: ERROR_CODES.VALID_001,
66
+ 401: ERROR_CODES.AUTH_001,
67
+ 403: ERROR_CODES.AUTH_003,
68
+ 404: ERROR_CODES.DATA_001,
69
+ 405: ERROR_CODES.SYS_001,
70
+ 409: ERROR_CODES.DATA_002,
71
+ 422: ERROR_CODES.VALID_001,
72
+ 429: ERROR_CODES.LIMIT_001,
73
+ 500: ERROR_CODES.SYS_001,
74
+ 502: ERROR_CODES.EXT_002,
75
+ 503: ERROR_CODES.NET_001
76
+ };
77
+ function codeForStatus(status) {
78
+ if (STATUS_TO_CODE[status]) return STATUS_TO_CODE[status];
79
+ if (status >= 500) return ERROR_CODES.SYS_001;
80
+ if (status >= 400) return ERROR_CODES.VALID_001;
81
+ return ERROR_CODES.SYS_001;
82
+ }
83
+ function build(code, correlationId, retryable, capability) {
84
+ return {
85
+ code,
86
+ message: copyFor(code, capability),
87
+ ...correlationId ? { correlationId } : {},
88
+ retryable: retryable ?? ERROR_RETRYABLE[code]
89
+ };
90
+ }
91
+ function extractEnvelope(input) {
92
+ if (typeof input !== "object" || input === null) return null;
93
+ const error = input.error;
94
+ if (typeof error !== "object" || error === null) return null;
95
+ const record = error;
96
+ return {
97
+ code: typeof record.code === "string" ? record.code : "",
98
+ correlationId: typeof record.correlation_id === "string" ? record.correlation_id : void 0,
99
+ retryable: typeof record.retryable === "boolean" ? record.retryable : void 0
100
+ };
101
+ }
102
+ function normalizeError(input, fallback = DEFAULT_ERROR_CODE, capability) {
103
+ const envelope = extractEnvelope(input);
104
+ if (!envelope) return build(fallback, void 0, void 0, capability);
105
+ const resolved = isErrorCode(envelope.code) ? envelope.code : fallback;
106
+ return build(
107
+ resolved,
108
+ envelope.correlationId,
109
+ envelope.retryable,
110
+ capability
111
+ );
112
+ }
113
+ async function appErrorFromResponse(response, capability) {
114
+ const fallback = codeForStatus(response.status);
115
+ let body;
116
+ try {
117
+ body = await response.json();
118
+ } catch {
119
+ body = void 0;
120
+ }
121
+ return normalizeError(body, fallback, capability);
122
+ }
123
+ function normalizeThrown(input, capability) {
124
+ if (typeof navigator !== "undefined" && navigator.onLine === false) {
125
+ return build(ERROR_CODES.CLIENT_001, void 0, void 0, capability);
126
+ }
127
+ if (input instanceof DOMException && input.name === "AbortError") {
128
+ return build(ERROR_CODES.NET_001, void 0, void 0, capability);
129
+ }
130
+ return build(ERROR_CODES.NET_001, void 0, void 0, capability);
131
+ }
132
+ function formatAppError(error) {
133
+ return `${error.message} (Reference: ${error.code})`;
134
+ }
135
+
136
+ // src/api/create-api-client.ts
137
+ function createApiClient(config) {
138
+ const API_URL = config.baseUrl ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
139
+ const SHARED_WORKSPACE_COOKIE = config.workspaceCookieName ?? "xenide_active_workspace_id";
140
+ const SHARED_WORKSPACE_COOKIE_DOMAIN = config.workspaceCookieDomain ?? process.env.NEXT_PUBLIC_WORKSPACE_COOKIE_DOMAIN;
141
+ const ACCESS_KEY = config.accessTokenKey ?? `${config.storagePrefix}_access_token`;
142
+ const REFRESH_KEY = config.refreshTokenKey ?? `${config.storagePrefix}_refresh_token`;
143
+ const WORKSPACE_KEY = config.workspaceKey ?? `${config.storagePrefix}_active_workspace_id`;
144
+ let accessToken = null;
145
+ let refreshToken = null;
146
+ let activeWorkspaceId = null;
147
+ let authFailureHandler = null;
148
+ let authFailureFired = false;
149
+ let refreshPromise = null;
150
+ function readSharedWorkspaceId() {
151
+ if (typeof document === "undefined") return null;
152
+ const prefix = `${SHARED_WORKSPACE_COOKIE}=`;
153
+ const entry = document.cookie.split("; ").find((item) => item.startsWith(prefix));
154
+ if (!entry) return null;
155
+ const raw = entry.slice(prefix.length);
156
+ if (!raw) return null;
157
+ try {
158
+ return decodeURIComponent(raw) || null;
159
+ } catch {
160
+ return raw;
161
+ }
162
+ }
163
+ function writeSharedWorkspaceId(id) {
164
+ if (typeof document === "undefined") return;
165
+ const value = id ? encodeURIComponent(id) : "";
166
+ const maxAge = id ? 60 * 60 * 24 * 365 : 0;
167
+ const domain = SHARED_WORKSPACE_COOKIE_DOMAIN ? `; domain=${SHARED_WORKSPACE_COOKIE_DOMAIN}` : "";
168
+ document.cookie = `${SHARED_WORKSPACE_COOKIE}=${value}; path=/; max-age=${maxAge}; SameSite=Lax${domain}`;
169
+ }
170
+ function onAuthFailure(handler) {
171
+ authFailureHandler = handler;
172
+ authFailureFired = false;
173
+ }
174
+ function triggerAuthFailure() {
175
+ if (authFailureFired) return;
176
+ authFailureFired = true;
177
+ authFailureHandler?.();
178
+ }
179
+ function setTokens(access, refresh) {
180
+ accessToken = access;
181
+ refreshToken = refresh;
182
+ if (typeof window !== "undefined") {
183
+ window.localStorage.setItem(ACCESS_KEY, access);
184
+ window.localStorage.setItem(REFRESH_KEY, refresh);
185
+ }
186
+ }
187
+ function getAccessToken() {
188
+ if (accessToken) return accessToken;
189
+ if (typeof window !== "undefined") {
190
+ accessToken = window.localStorage.getItem(ACCESS_KEY);
191
+ }
192
+ return accessToken;
193
+ }
194
+ function getRefreshToken() {
195
+ if (refreshToken) return refreshToken;
196
+ if (typeof window !== "undefined") {
197
+ refreshToken = window.localStorage.getItem(REFRESH_KEY);
198
+ }
199
+ return refreshToken;
200
+ }
201
+ function setActiveWorkspaceId(id, options) {
202
+ const changed = activeWorkspaceId !== id;
203
+ activeWorkspaceId = id;
204
+ if (typeof window !== "undefined") {
205
+ if (id) {
206
+ window.localStorage.setItem(WORKSPACE_KEY, id);
207
+ } else {
208
+ window.localStorage.removeItem(WORKSPACE_KEY);
209
+ }
210
+ if (changed) config.onWorkspaceChange?.(id);
211
+ }
212
+ if (options?.share) writeSharedWorkspaceId(id);
213
+ }
214
+ function getActiveWorkspaceId() {
215
+ if (activeWorkspaceId) return activeWorkspaceId;
216
+ if (typeof window !== "undefined") {
217
+ activeWorkspaceId = readSharedWorkspaceId() ?? window.localStorage.getItem(WORKSPACE_KEY);
218
+ }
219
+ return activeWorkspaceId;
220
+ }
221
+ function restoreWorkspace() {
222
+ if (typeof window !== "undefined") {
223
+ activeWorkspaceId = readSharedWorkspaceId() ?? window.localStorage.getItem(WORKSPACE_KEY);
224
+ }
225
+ }
226
+ function clearAuth() {
227
+ accessToken = null;
228
+ refreshToken = null;
229
+ activeWorkspaceId = null;
230
+ if (typeof window !== "undefined") {
231
+ window.localStorage.removeItem(ACCESS_KEY);
232
+ window.localStorage.removeItem(REFRESH_KEY);
233
+ window.localStorage.removeItem(WORKSPACE_KEY);
234
+ const prefixes = config.sessionStorageClearPrefixes ?? [];
235
+ if (prefixes.length > 0) {
236
+ try {
237
+ for (let i = window.sessionStorage.length - 1; i >= 0; i -= 1) {
238
+ const key = window.sessionStorage.key(i);
239
+ if (key && prefixes.some((prefix) => key.startsWith(prefix))) {
240
+ window.sessionStorage.removeItem(key);
241
+ }
242
+ }
243
+ } catch {
244
+ }
245
+ }
246
+ }
247
+ }
248
+ function restoreTokens() {
249
+ if (typeof window !== "undefined") {
250
+ accessToken = window.localStorage.getItem(ACCESS_KEY);
251
+ refreshToken = window.localStorage.getItem(REFRESH_KEY);
252
+ activeWorkspaceId = readSharedWorkspaceId() ?? window.localStorage.getItem(WORKSPACE_KEY);
253
+ }
254
+ }
255
+ async function refreshAccessToken() {
256
+ if (refreshPromise) return refreshPromise;
257
+ refreshPromise = refreshAccessTokenInternal().finally(() => {
258
+ refreshPromise = null;
259
+ });
260
+ return refreshPromise;
261
+ }
262
+ async function refreshAccessTokenInternal() {
263
+ const refresh = getRefreshToken();
264
+ if (!refresh) {
265
+ triggerAuthFailure();
266
+ return false;
267
+ }
268
+ try {
269
+ const res = await fetch(`${API_URL}${config.refreshPath}`, {
270
+ method: "POST",
271
+ headers: {
272
+ "Content-Type": "application/json",
273
+ Accept: "application/json"
274
+ },
275
+ body: JSON.stringify({ refresh })
276
+ });
277
+ if (!res.ok) {
278
+ clearAuth();
279
+ triggerAuthFailure();
280
+ return false;
281
+ }
282
+ const data = await res.json();
283
+ setTokens(data.access, data.refresh);
284
+ authFailureFired = false;
285
+ return true;
286
+ } catch {
287
+ clearAuth();
288
+ triggerAuthFailure();
289
+ return false;
290
+ }
291
+ }
292
+ async function apiFetch(path, options = {}) {
293
+ const token = getAccessToken();
294
+ const headers = {
295
+ Accept: "application/json",
296
+ ...options.headers
297
+ };
298
+ const body = options.body;
299
+ const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
300
+ if (!isFormData) {
301
+ headers["Content-Type"] = "application/json";
302
+ }
303
+ if (token) {
304
+ headers["Authorization"] = `Bearer ${token}`;
305
+ }
306
+ const wsId = getActiveWorkspaceId();
307
+ if (wsId) {
308
+ headers["X-Active-Workspace-Id"] = wsId;
309
+ }
310
+ const url = path.startsWith("http") ? path : `${API_URL}${path}`;
311
+ async function doFetch() {
312
+ try {
313
+ return await fetch(url, { ...options, headers });
314
+ } catch {
315
+ throw new Error(
316
+ `Could not reach the API at ${API_URL}. Is the Django backend running?`
317
+ );
318
+ }
319
+ }
320
+ let res = await doFetch();
321
+ if (res.status === 401 && token) {
322
+ const refreshed = await refreshAccessToken();
323
+ if (refreshed) {
324
+ const newToken = getAccessToken();
325
+ if (newToken) {
326
+ headers["Authorization"] = `Bearer ${newToken}`;
327
+ }
328
+ res = await doFetch();
329
+ if (res.status === 401) {
330
+ triggerAuthFailure();
331
+ }
332
+ }
333
+ }
334
+ return res;
335
+ }
336
+ async function parseApiError(res) {
337
+ return formatAppError(await appErrorFromResponse(res));
338
+ }
339
+ const api = {
340
+ get: (path) => apiFetch(path),
341
+ post: (path, body, options = {}) => apiFetch(path, {
342
+ ...options,
343
+ method: "POST",
344
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : void 0
345
+ }),
346
+ put: (path, body) => apiFetch(path, {
347
+ method: "PUT",
348
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : void 0
349
+ }),
350
+ patch: (path, body) => apiFetch(path, {
351
+ method: "PATCH",
352
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : void 0
353
+ }),
354
+ delete: (path) => apiFetch(path, { method: "DELETE" })
355
+ };
356
+ return {
357
+ API_URL,
358
+ onAuthFailure,
359
+ setTokens,
360
+ getAccessToken,
361
+ getRefreshToken,
362
+ setActiveWorkspaceId,
363
+ getActiveWorkspaceId,
364
+ restoreWorkspace,
365
+ clearAuth,
366
+ restoreTokens,
367
+ refreshAccessToken,
368
+ parseApiError,
369
+ api
370
+ };
371
+ }
372
+
373
+ // src/auth/create-auth-return-path.ts
374
+ function createAuthReturnPath(config) {
375
+ const RETURN_KEY = config.returnKey;
376
+ const HANDOFF_KEY = config.handoffKey ?? "ss_portal_handoff";
377
+ const DEFAULT_NEXT = config.defaultNext ?? "/today";
378
+ const PORTAL_CALLBACK_PATH = config.portalCallbackPath ?? "/auth/portal/callback";
379
+ const REDEEM_PATH = config.redeemPath ?? "/api/portal/auth/switch/redeem";
380
+ function isSafeAuthReturnPath(path) {
381
+ if (!path || path[0] !== "/" || path.startsWith("//") || path.startsWith("/\\")) {
382
+ return false;
383
+ }
384
+ if (path.includes("\\") || path.includes("://")) return false;
385
+ return true;
386
+ }
387
+ function stashAuthReturnPath(path) {
388
+ if (typeof window === "undefined" || !isSafeAuthReturnPath(path)) return;
389
+ sessionStorage.setItem(RETURN_KEY, path);
390
+ }
391
+ function portalHandoffUrl(base, data, next, fallbackNext = DEFAULT_NEXT) {
392
+ const dest = isSafeAuthReturnPath(next) ? next : fallbackNext;
393
+ const hash = new URLSearchParams();
394
+ if (data.code) {
395
+ hash.set("code", data.code);
396
+ } else {
397
+ const token = data.token || data.access || "";
398
+ if (token) hash.set("token", token);
399
+ if (data.refresh) hash.set("refresh", data.refresh);
400
+ }
401
+ hash.set("next", dest);
402
+ return `${base.replace(/\/$/, "")}${PORTAL_CALLBACK_PATH}#${hash.toString()}`;
403
+ }
404
+ function cachedHandoff(fallbackNext) {
405
+ try {
406
+ const raw = sessionStorage.getItem(HANDOFF_KEY);
407
+ if (!raw) return null;
408
+ const parsed = JSON.parse(raw);
409
+ if (!parsed.code && !parsed.token) return null;
410
+ return {
411
+ code: parsed.code || null,
412
+ token: parsed.token || null,
413
+ refresh: parsed.refresh || null,
414
+ next: isSafeAuthReturnPath(parsed.next) ? parsed.next : fallbackNext
415
+ };
416
+ } catch {
417
+ sessionStorage.removeItem(HANDOFF_KEY);
418
+ return null;
419
+ }
420
+ }
421
+ function readPortalHandoff(fallbackNext = DEFAULT_NEXT) {
422
+ const cached = cachedHandoff(fallbackNext);
423
+ if (cached) return cached;
424
+ const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
425
+ const query = new URLSearchParams(window.location.search);
426
+ const nextRaw = hash.get("next") || query.get("next");
427
+ const result = {
428
+ code: hash.get("code") || query.get("code"),
429
+ token: hash.get("token") || query.get("token"),
430
+ refresh: hash.get("refresh") || query.get("refresh"),
431
+ next: isSafeAuthReturnPath(nextRaw) ? nextRaw : fallbackNext
432
+ };
433
+ if (result.code || result.token) {
434
+ sessionStorage.setItem(HANDOFF_KEY, JSON.stringify(result));
435
+ }
436
+ return result;
437
+ }
438
+ let redeemInflight = null;
439
+ async function redeemPortalHandoff(apiUrl, fallbackNext = DEFAULT_NEXT) {
440
+ redeemInflight ?? (redeemInflight = (async () => {
441
+ const { code, token, refresh, next } = readPortalHandoff(fallbackNext);
442
+ if (!code) return { code: null, token, refresh, next };
443
+ const response = await fetch(`${apiUrl.replace(/\/$/, "")}${REDEEM_PATH}`, {
444
+ method: "POST",
445
+ headers: {
446
+ Accept: "application/json",
447
+ "Content-Type": "application/json"
448
+ },
449
+ body: JSON.stringify({ code })
450
+ });
451
+ if (!response.ok) return { code: null, token: null, refresh: null, next };
452
+ const data = await response.json();
453
+ sessionStorage.removeItem(HANDOFF_KEY);
454
+ return {
455
+ code: null,
456
+ token: data.token || data.access || null,
457
+ refresh: data.refresh || null,
458
+ next
459
+ };
460
+ })());
461
+ return redeemInflight;
462
+ }
463
+ function readAuthReturnPath() {
464
+ if (typeof window === "undefined") return null;
465
+ const path = sessionStorage.getItem(RETURN_KEY);
466
+ return isSafeAuthReturnPath(path) ? path : null;
467
+ }
468
+ function signInHrefWithReturn(fallbackRedirect) {
469
+ const path = isSafeAuthReturnPath(fallbackRedirect) ? fallbackRedirect : readAuthReturnPath();
470
+ if (!path) return "/login";
471
+ return `/login?redirect=${encodeURIComponent(path)}`;
472
+ }
473
+ function registerHrefWithReturn(returnPath) {
474
+ return `/register?redirect=${encodeURIComponent(returnPath)}`;
475
+ }
476
+ return {
477
+ isSafeAuthReturnPath,
478
+ stashAuthReturnPath,
479
+ portalHandoffUrl,
480
+ readPortalHandoff,
481
+ redeemPortalHandoff,
482
+ readAuthReturnPath,
483
+ signInHrefWithReturn,
484
+ registerHrefWithReturn
485
+ };
486
+ }
487
+
488
+ // src/workspace/access.ts
489
+ var WORKSPACE_SEAT_HELP = "Each member uses one workspace seat. Inviting someone reserves a seat until the invite is revoked or accepted.";
490
+ var ORGANISATION_SEAT_HELP = "Each organisation member uses one seat across all your workspaces. One invite gives them access to every workspace in your organisation.";
491
+ var TIDES_ACCESS_HELP = "Tides \u2014 project management.";
492
+ var TURTLETIME_ACCESS_HELP = "TurtleTime \u2014 time tracking add-on (Basic plan and above).";
493
+ var FREE_PLAN_SEAT_HELP = "Free personal workspaces include one product app only. Create an organisation on ShellStack for both apps and integration.";
494
+ function hasAppAccess(canAccessTurtletime, canAccessTides) {
495
+ return canAccessTurtletime || canAccessTides;
496
+ }
497
+ function canDisableAppAccess(app, canAccessTurtletime, canAccessTides) {
498
+ if (app === "turtletime") return canAccessTides;
499
+ return canAccessTurtletime;
500
+ }
501
+
502
+ // src/workspace/subscription.ts
503
+ var TIER_LABELS = {
504
+ free: "Free",
505
+ plus: "Plus",
506
+ ai_plus: "AI Plus",
507
+ basic: "Basic",
508
+ pro: "Pro",
509
+ enterprise: "Enterprise"
510
+ };
511
+ function tierLabel(tier) {
512
+ if (tier === "basic" || tier === "pro") return "Plus";
513
+ return TIER_LABELS[tier] ?? "Free";
514
+ }
515
+ function resolveSubscriptionTier(workspace) {
516
+ const tier = workspace?.subscription?.tier ?? workspace?.subscription_tier;
517
+ if (tier === "plus" || tier === "ai_plus" || tier === "basic" || tier === "pro" || tier === "enterprise" || tier === "free") {
518
+ return tier === "basic" || tier === "pro" ? "plus" : tier;
519
+ }
520
+ return "free";
521
+ }
522
+ function tierAllowsTurtletime(tier) {
523
+ return tier !== "free";
524
+ }
525
+ function defaultInviteSeatType(tier) {
526
+ return tierAllowsTurtletime(tier) ? "integrated" : "tides";
527
+ }
528
+ function tierAllowsTidesOnlySeats() {
529
+ return false;
530
+ }
531
+ function seatTypeFromFlags(canAccessTurtletime, canAccessTides) {
532
+ if (canAccessTurtletime && canAccessTides) return "integrated";
533
+ return "tides";
534
+ }
535
+ function flagsFromSeatType(seatType) {
536
+ if (seatType === "integrated") {
537
+ return { can_access_turtletime: true, can_access_tides: true };
538
+ }
539
+ return { can_access_turtletime: false, can_access_tides: true };
540
+ }
541
+ function seatTypeLabel(seatType) {
542
+ return seatType === "integrated" ? "Tides + TurtleTime" : "Tides";
543
+ }
544
+ function seatTypeDescription(tier, seatType) {
545
+ if (seatType === "integrated") {
546
+ return "Project management and time tracking with TT\u2194Tides integration.";
547
+ }
548
+ if (!tierAllowsTidesOnlySeats()) {
549
+ return "Project management included on the Free plan.";
550
+ }
551
+ return "Project management only \u2014 no TurtleTime time tracking.";
552
+ }
553
+ function planSeatSummary(subscription) {
554
+ if (!subscription) return "";
555
+ const { tier, limits, usage } = subscription;
556
+ const seatCap = limits.seats ?? limits.integrated_seats;
557
+ const seatCount = usage.seats ?? usage.integrated_seats;
558
+ if (tier === "enterprise") {
559
+ return "Unlimited seats";
560
+ }
561
+ if (seatCap != null) {
562
+ return `${seatCount}/${seatCap} seats`;
563
+ }
564
+ return "";
565
+ }
566
+
567
+ // src/workspace/create-brand-settings.ts
568
+ function createBrandSettings(config) {
569
+ const DEFAULT_BRAND_TITLE = config.defaultTitle;
570
+ const BRAND_SETTINGS_CHANGED = config.changedEvent;
571
+ const brandState = {
572
+ logo: null,
573
+ title: null,
574
+ showTitle: true
575
+ };
576
+ function notifyBrandChange() {
577
+ if (typeof window !== "undefined") {
578
+ window.dispatchEvent(new Event(BRAND_SETTINGS_CHANGED));
579
+ }
580
+ }
581
+ function applyWorkspaceBrand(payload) {
582
+ if ("brand_logo" in payload) {
583
+ brandState.logo = payload.brand_logo ?? null;
584
+ }
585
+ if ("brand_title" in payload) {
586
+ const title = payload.brand_title;
587
+ brandState.title = title?.trim() ? title.trim() : null;
588
+ }
589
+ if ("brand_show_title" in payload && payload.brand_show_title !== void 0) {
590
+ brandState.showTitle = payload.brand_show_title;
591
+ }
592
+ notifyBrandChange();
593
+ }
594
+ function getBrandLogo() {
595
+ return brandState.logo;
596
+ }
597
+ function getCustomBrandTitle() {
598
+ return brandState.title;
599
+ }
600
+ function getBrandShowTitle() {
601
+ return brandState.showTitle;
602
+ }
603
+ function getDisplayBrandTitle() {
604
+ if (!getBrandShowTitle()) return null;
605
+ const custom = getCustomBrandTitle();
606
+ if (custom === null) return DEFAULT_BRAND_TITLE;
607
+ return custom || DEFAULT_BRAND_TITLE;
608
+ }
609
+ function setBrandLogo(dataUrl) {
610
+ brandState.logo = dataUrl;
611
+ notifyBrandChange();
612
+ }
613
+ function setCustomBrandTitle(title) {
614
+ brandState.title = title?.trim() ? title.trim() : null;
615
+ notifyBrandChange();
616
+ }
617
+ function setBrandShowTitle(show) {
618
+ brandState.showTitle = show;
619
+ notifyBrandChange();
620
+ }
621
+ function resetBrandTitle() {
622
+ brandState.title = null;
623
+ brandState.showTitle = true;
624
+ notifyBrandChange();
625
+ }
626
+ return {
627
+ DEFAULT_BRAND_TITLE,
628
+ BRAND_SETTINGS_CHANGED,
629
+ /** @deprecated Use BRAND_SETTINGS_CHANGED */
630
+ BRAND_LOGO_CHANGED: BRAND_SETTINGS_CHANGED,
631
+ applyWorkspaceBrand,
632
+ getBrandLogo,
633
+ getCustomBrandTitle,
634
+ getBrandShowTitle,
635
+ getDisplayBrandTitle,
636
+ setBrandLogo,
637
+ setCustomBrandTitle,
638
+ setBrandShowTitle,
639
+ resetBrandTitle
640
+ };
641
+ }
642
+
643
+ // src/workspace/create-workspace-appearance.ts
644
+ function createWorkspaceAppearance(config) {
645
+ const WORKSPACE_CHANGED = config.changedEvent;
646
+ const THEME_STORAGE_BASE = config.themeStorageBase;
647
+ function notifyWorkspaceChanged() {
648
+ if (typeof window !== "undefined") {
649
+ window.dispatchEvent(new Event(WORKSPACE_CHANGED));
650
+ }
651
+ }
652
+ function scopedStorageKey(base, workspaceId) {
653
+ const wsId = workspaceId ?? config.getActiveWorkspaceId();
654
+ return wsId ? `${base}:${wsId}` : base;
655
+ }
656
+ function getScopedStorageItem(base, workspaceId) {
657
+ if (typeof window === "undefined") return null;
658
+ try {
659
+ const scoped = localStorage.getItem(scopedStorageKey(base, workspaceId));
660
+ if (scoped !== null) return scoped;
661
+ const wsId = workspaceId ?? config.getActiveWorkspaceId();
662
+ if (wsId) {
663
+ return localStorage.getItem(base);
664
+ }
665
+ return null;
666
+ } catch {
667
+ return null;
668
+ }
669
+ }
670
+ function setScopedStorageItem(base, value, workspaceId) {
671
+ if (typeof window === "undefined") return;
672
+ const key = scopedStorageKey(base, workspaceId);
673
+ if (value === null) {
674
+ localStorage.removeItem(key);
675
+ } else {
676
+ localStorage.setItem(key, value);
677
+ }
678
+ }
679
+ return {
680
+ WORKSPACE_CHANGED,
681
+ THEME_STORAGE_BASE,
682
+ notifyWorkspaceChanged,
683
+ scopedStorageKey,
684
+ getScopedStorageItem,
685
+ setScopedStorageItem
686
+ };
687
+ }
688
+
689
+ // src/workspace/create-workspace-appearance-api.ts
690
+ var WorkspaceAppearanceError = class extends Error {
691
+ constructor(message, status) {
692
+ super(message);
693
+ this.status = status;
694
+ }
695
+ };
696
+ function normalizeTheme(value) {
697
+ return value === "light" ? "light" : "dark";
698
+ }
699
+ function parseAppearancePayload(data) {
700
+ const raw = data.appearance ?? data;
701
+ return {
702
+ theme: normalizeTheme(raw.theme),
703
+ brand_logo: raw.brand_logo ?? null,
704
+ brand_title: raw.brand_title ?? null,
705
+ brand_show_title: Boolean(raw.brand_show_title)
706
+ };
707
+ }
708
+ function createWorkspaceAppearanceApi(config) {
709
+ const { api, parseApiError, basePath, applyWorkspaceBrand } = config;
710
+ async function fetchWorkspaceAppearance() {
711
+ const res = await api.get(basePath);
712
+ if (!res.ok) {
713
+ throw new WorkspaceAppearanceError(await parseApiError(res), res.status);
714
+ }
715
+ const data = await res.json();
716
+ return parseAppearancePayload(data);
717
+ }
718
+ async function updateWorkspaceAppearance(patch) {
719
+ const hasFileUpload = patch.brand_logo_file instanceof File;
720
+ const shouldClearLogo = patch.clear_brand_logo === true;
721
+ if (hasFileUpload || shouldClearLogo) {
722
+ const formData = new FormData();
723
+ if (patch.theme) formData.append("theme", patch.theme);
724
+ if (patch.brand_title !== void 0) {
725
+ formData.append("brand_title", patch.brand_title ?? "");
726
+ }
727
+ if (patch.brand_show_title !== void 0) {
728
+ formData.append(
729
+ "brand_show_title",
730
+ patch.brand_show_title ? "true" : "false"
731
+ );
732
+ }
733
+ if (hasFileUpload && patch.brand_logo_file) {
734
+ formData.append("brand_logo", patch.brand_logo_file);
735
+ }
736
+ if (shouldClearLogo) {
737
+ formData.append("clear_brand_logo", "true");
738
+ }
739
+ const res2 = await api.patch(basePath, formData);
740
+ if (!res2.ok) {
741
+ throw new Error(await parseApiError(res2));
742
+ }
743
+ const data2 = await res2.json();
744
+ return parseAppearancePayload(data2);
745
+ }
746
+ const res = await api.patch(basePath, {
747
+ theme: patch.theme,
748
+ brand_title: patch.brand_title,
749
+ brand_show_title: patch.brand_show_title
750
+ });
751
+ if (!res.ok) {
752
+ throw new Error(await parseApiError(res));
753
+ }
754
+ const data = await res.json();
755
+ return parseAppearancePayload(data);
756
+ }
757
+ function cacheWorkspaceAppearanceLocally(appearance) {
758
+ applyWorkspaceBrand({
759
+ brand_logo: appearance.brand_logo,
760
+ brand_title: appearance.brand_title,
761
+ brand_show_title: appearance.brand_show_title
762
+ });
763
+ }
764
+ return {
765
+ WorkspaceAppearanceError,
766
+ fetchWorkspaceAppearance,
767
+ updateWorkspaceAppearance,
768
+ cacheWorkspaceAppearanceLocally
769
+ };
770
+ }
771
+
772
+ // src/sync/create-cross-app-sync.ts
773
+ function createCrossAppSync(config) {
774
+ const pollMs = config.pollMs ?? 5e3;
775
+ const lastKnown = {
776
+ time: 0,
777
+ integration: 0,
778
+ tasks: 0
779
+ };
780
+ const subscribers = [];
781
+ let pollInterval = null;
782
+ let pollListeners = 0;
783
+ async function fetchCheckpoint() {
784
+ try {
785
+ const res = await config.api.get(config.checkpointPath);
786
+ if (!res.ok) return null;
787
+ return await res.json();
788
+ } catch {
789
+ return null;
790
+ }
791
+ }
792
+ function notify(topic) {
793
+ for (const sub of subscribers) {
794
+ if (!sub.topic || sub.topic === topic) {
795
+ if (lastKnown[topic] > sub.lastSync) {
796
+ sub.lastSync = lastKnown[topic];
797
+ sub.callback();
798
+ }
799
+ }
800
+ }
801
+ }
802
+ function checkAndNotify(data) {
803
+ const tasks = data.tasks_synced_at || 0;
804
+ const time = data.time_synced_at || 0;
805
+ const integration = data.integration_synced_at || 0;
806
+ if (tasks > lastKnown.tasks) {
807
+ lastKnown.tasks = tasks;
808
+ notify("tasks");
809
+ }
810
+ if (time > lastKnown.time) {
811
+ lastKnown.time = time;
812
+ notify("time");
813
+ }
814
+ if (integration > lastKnown.integration) {
815
+ lastKnown.integration = integration;
816
+ notify("integration");
817
+ }
818
+ }
819
+ function startPolling() {
820
+ if (pollInterval !== null || typeof window === "undefined") return;
821
+ void fetchCheckpoint().then((data) => data && checkAndNotify(data));
822
+ pollInterval = window.setInterval(() => {
823
+ if (document.visibilityState === "visible") {
824
+ void fetchCheckpoint().then((data) => data && checkAndNotify(data));
825
+ }
826
+ }, pollMs);
827
+ }
828
+ function stopPolling() {
829
+ if (pollListeners > 0) return;
830
+ if (pollInterval !== null) {
831
+ window.clearInterval(pollInterval);
832
+ pollInterval = null;
833
+ }
834
+ }
835
+ function broadcastCrossAppSync(_topic) {
836
+ void _topic;
837
+ }
838
+ function subscribeCrossAppSync(onSync, topic) {
839
+ if (typeof window === "undefined") return () => {
840
+ };
841
+ const sub = {
842
+ topic,
843
+ callback: onSync,
844
+ lastSync: lastKnown[topic || "tasks"]
845
+ };
846
+ subscribers.push(sub);
847
+ pollListeners += 1;
848
+ startPolling();
849
+ return () => {
850
+ const index = subscribers.indexOf(sub);
851
+ if (index >= 0) subscribers.splice(index, 1);
852
+ pollListeners -= 1;
853
+ if (pollListeners <= 0) stopPolling();
854
+ };
855
+ }
856
+ function lastCrossAppSyncTime(topic) {
857
+ return topic ? lastKnown[topic] : Math.max(...Object.values(lastKnown));
858
+ }
859
+ return {
860
+ broadcastCrossAppSync,
861
+ subscribeCrossAppSync,
862
+ lastCrossAppSyncTime
863
+ };
864
+ }
865
+
866
+ // src/sync/create-live-refresh.ts
867
+ function createLiveRefresh(sync) {
868
+ function hasPendingCrossAppSync(topic, since) {
869
+ return sync.lastCrossAppSyncTime(topic) > since;
870
+ }
871
+ function subscribeLiveRefresh(refresh, options = {}) {
872
+ if (typeof window === "undefined") return () => {
873
+ };
874
+ const cleanups = [];
875
+ let lastRefreshAt = Date.now();
876
+ const trigger = () => {
877
+ lastRefreshAt = Date.now();
878
+ refresh();
879
+ };
880
+ for (const eventName of options.localEvents ?? []) {
881
+ window.addEventListener(eventName, trigger);
882
+ cleanups.push(() => window.removeEventListener(eventName, trigger));
883
+ }
884
+ cleanups.push(sync.subscribeCrossAppSync(trigger, options.crossAppTopic));
885
+ const shouldRefreshOnFocus = () => hasPendingCrossAppSync(options.crossAppTopic, lastRefreshAt);
886
+ const onVisibility = () => {
887
+ if (document.visibilityState === "visible" && shouldRefreshOnFocus()) {
888
+ trigger();
889
+ }
890
+ };
891
+ document.addEventListener("visibilitychange", onVisibility);
892
+ cleanups.push(
893
+ () => document.removeEventListener("visibilitychange", onVisibility)
894
+ );
895
+ const onFocus = () => {
896
+ if (shouldRefreshOnFocus()) {
897
+ trigger();
898
+ }
899
+ };
900
+ window.addEventListener("focus", onFocus);
901
+ cleanups.push(() => window.removeEventListener("focus", onFocus));
902
+ if (options.pollMs && options.pollMs > 0) {
903
+ const interval = window.setInterval(() => {
904
+ if (document.visibilityState === "visible") trigger();
905
+ }, options.pollMs);
906
+ cleanups.push(() => window.clearInterval(interval));
907
+ }
908
+ return () => {
909
+ for (const cleanup of cleanups) cleanup();
910
+ };
911
+ }
912
+ return { subscribeLiveRefresh, hasPendingCrossAppSync };
913
+ }
914
+
915
+ // src/notifications/create-notifications-client.ts
916
+ function createNotificationsClient(config) {
917
+ const { api, parseApiError } = config;
918
+ const CACHE_MS = config.cacheMs ?? 15e3;
919
+ const LIST_PATH = config.listPath ?? "/api/notifications/";
920
+ let notificationsCache = null;
921
+ let notificationsInflight = null;
922
+ async function fetchSuiteNotifications() {
923
+ if (notificationsCache && Date.now() - notificationsCache.at < CACHE_MS) {
924
+ return notificationsCache.data;
925
+ }
926
+ if (notificationsInflight) return notificationsInflight;
927
+ notificationsInflight = (async () => {
928
+ try {
929
+ const res = await api.get(LIST_PATH);
930
+ if (!res.ok) throw new Error(await parseApiError(res));
931
+ const data = await res.json();
932
+ const result = {
933
+ notifications: data.notifications ?? [],
934
+ unread_count: data.unread_count ?? 0
935
+ };
936
+ notificationsCache = { data: result, at: Date.now() };
937
+ return result;
938
+ } finally {
939
+ notificationsInflight = null;
940
+ }
941
+ })();
942
+ return notificationsInflight;
943
+ }
944
+ async function markSuiteNotificationRead(id) {
945
+ const res = await api.post(`${LIST_PATH}${id}/read/`);
946
+ if (!res.ok) throw new Error(await parseApiError(res));
947
+ notificationsCache = null;
948
+ }
949
+ async function markAllSuiteNotificationsRead() {
950
+ const res = await api.post(`${LIST_PATH}read-all/`);
951
+ if (!res.ok) throw new Error(await parseApiError(res));
952
+ notificationsCache = null;
953
+ }
954
+ return {
955
+ fetchSuiteNotifications,
956
+ markSuiteNotificationRead,
957
+ markAllSuiteNotificationsRead
958
+ };
959
+ }
960
+
961
+ // src/ai/suite-ai-job.ts
962
+ function activeSuiteAiJobs(jobs) {
963
+ return jobs.filter(
964
+ (job) => job.status === "pending" || job.status === "running"
965
+ );
966
+ }
967
+ function sortedSuiteAiEvents(events) {
968
+ return [...events].sort((a, b) => a.sequence - b.sequence);
969
+ }
970
+ function suiteAiEventSummary(event) {
971
+ const summary = event?.summary.replace(/[\u0000-\u001f]/g, " ").replace(/\s+/g, " ").trim();
972
+ if (summary) return summary;
973
+ if (event?.event_type === "queued") return "Shelly request queued";
974
+ if (event?.event_type === "started")
975
+ return "Shelly started preparing a reply";
976
+ if (event?.event_type === "completed") return "Shelly reply ready";
977
+ if (event?.event_type === "failed") return "AI request failed";
978
+ return "Shelly is working";
979
+ }
980
+
981
+ // src/webhooks/webhook-activity-actions.ts
982
+ var WEBHOOK_ACTIVITY_ACTIONS = [
983
+ { id: "created", label: "Task created" },
984
+ { id: "updated", label: "Task updated" },
985
+ { id: "status_changed", label: "Task status changed" },
986
+ { id: "assigned", label: "Task assigned" },
987
+ { id: "commented", label: "Comment posted" },
988
+ { id: "project_renamed", label: "Project renamed" },
989
+ { id: "project_status_changed", label: "Project archived or restored" }
990
+ ];
991
+ var ALL_WEBHOOK_ACTION_IDS = WEBHOOK_ACTIVITY_ACTIONS.map(
992
+ (action) => action.id
993
+ );
994
+ function webhookActionsToSelected(stored) {
995
+ if (!stored || stored.length === 0) {
996
+ return new Set(ALL_WEBHOOK_ACTION_IDS);
997
+ }
998
+ return new Set(stored);
999
+ }
1000
+ function selectedToWebhookActions(selected) {
1001
+ if (selected.size >= ALL_WEBHOOK_ACTION_IDS.length) {
1002
+ return [];
1003
+ }
1004
+ return ALL_WEBHOOK_ACTION_IDS.filter((id) => selected.has(id));
1005
+ }
1006
+
1007
+ // src/seo/create-json-ld.ts
1008
+ function createJsonLd(config) {
1009
+ const publisherName = config.publisherName ?? "Xenide";
1010
+ const publisherUrl = config.publisherUrl ?? "https://xenide.io";
1011
+ return JSON.stringify({
1012
+ "@context": "https://schema.org",
1013
+ "@type": "SoftwareApplication",
1014
+ name: config.name,
1015
+ url: config.url,
1016
+ description: config.description,
1017
+ applicationCategory: config.applicationCategory ?? "BusinessApplication",
1018
+ ...config.applicationSubCategory ? { applicationSubCategory: config.applicationSubCategory } : {},
1019
+ operatingSystem: config.operatingSystem ?? "Web",
1020
+ browserRequirements: "Requires JavaScript. Requires HTML5.",
1021
+ softwareVersion: config.softwareVersion ?? "1.0",
1022
+ offers: {
1023
+ "@type": "Offer",
1024
+ price: config.price ?? "0",
1025
+ priceCurrency: config.priceCurrency ?? "AUD"
1026
+ },
1027
+ publisher: {
1028
+ "@type": "Organization",
1029
+ name: publisherName,
1030
+ url: publisherUrl
1031
+ },
1032
+ author: {
1033
+ "@type": "Organization",
1034
+ name: publisherName,
1035
+ url: publisherUrl
1036
+ },
1037
+ inLanguage: config.inLanguage ?? "en-AU"
1038
+ });
1039
+ }
1040
+
1041
+ // src/hooks/use-is-night.ts
1042
+ import { useEffect, useState } from "react";
1043
+ var NIGHT_START_HOUR = 18;
1044
+ var NIGHT_END_HOUR = 6;
1045
+ function useIsNight() {
1046
+ const [isNight, setIsNight] = useState(false);
1047
+ useEffect(() => {
1048
+ const check = () => {
1049
+ const hour = (/* @__PURE__ */ new Date()).getHours();
1050
+ return hour >= NIGHT_START_HOUR || hour < NIGHT_END_HOUR;
1051
+ };
1052
+ setIsNight(check());
1053
+ let timeout;
1054
+ const update = () => {
1055
+ const next = check();
1056
+ setIsNight((current) => current !== next ? next : current);
1057
+ timeout = setTimeout(update, 6e4);
1058
+ };
1059
+ timeout = setTimeout(update, 6e4);
1060
+ return () => clearTimeout(timeout);
1061
+ }, []);
1062
+ return isNight;
1063
+ }
1064
+ export {
1065
+ DEFAULT_ERROR_CODE,
1066
+ ERROR_CODES,
1067
+ ERROR_COPY,
1068
+ ERROR_RETRYABLE,
1069
+ FREE_PLAN_SEAT_HELP,
1070
+ ORGANISATION_SEAT_HELP,
1071
+ TIDES_ACCESS_HELP,
1072
+ TURTLETIME_ACCESS_HELP,
1073
+ WEBHOOK_ACTIVITY_ACTIONS,
1074
+ WORKSPACE_SEAT_HELP,
1075
+ WorkspaceAppearanceError,
1076
+ activeSuiteAiJobs,
1077
+ appErrorFromResponse,
1078
+ canDisableAppAccess,
1079
+ codeForStatus,
1080
+ copyFor,
1081
+ createApiClient,
1082
+ createAuthReturnPath,
1083
+ createBrandSettings,
1084
+ createCrossAppSync,
1085
+ createJsonLd,
1086
+ createLiveRefresh,
1087
+ createNotificationsClient,
1088
+ createWorkspaceAppearance,
1089
+ createWorkspaceAppearanceApi,
1090
+ defaultInviteSeatType,
1091
+ flagsFromSeatType,
1092
+ formatAppError,
1093
+ hasAppAccess,
1094
+ isErrorCode,
1095
+ normalizeError,
1096
+ normalizeThrown,
1097
+ planSeatSummary,
1098
+ resolveSubscriptionTier,
1099
+ seatTypeDescription,
1100
+ seatTypeFromFlags,
1101
+ seatTypeLabel,
1102
+ selectedToWebhookActions,
1103
+ sortedSuiteAiEvents,
1104
+ suiteAiEventSummary,
1105
+ tierAllowsTidesOnlySeats,
1106
+ tierAllowsTurtletime,
1107
+ tierLabel,
1108
+ useIsNight,
1109
+ webhookActionsToSelected
1110
+ };
1111
+ //# sourceMappingURL=index.mjs.map