@gogitcms/editor 0.23.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/app/index.html +38 -0
- package/app/src/App.tsx +2665 -0
- package/app/src/apollo.ts +119 -0
- package/app/src/auth.ts +248 -0
- package/app/src/config.ts +88 -0
- package/app/src/main.tsx +18 -0
- package/app/src/media.ts +151 -0
- package/app/src/navigation.tsx +321 -0
- package/app/src/plugins.ts +11 -0
- package/app/src/previewHost.tsx +263 -0
- package/app/src/previewToken.ts +68 -0
- package/app/src/queries.ts +591 -0
- package/app/src/virtual-cms-plugins.d.ts +6 -0
- package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
- package/app/vendor/analytics/src/config.ts +94 -0
- package/app/vendor/analytics/src/events.ts +56 -0
- package/app/vendor/analytics/src/index.ts +23 -0
- package/app/vendor/analytics/src/provider.tsx +196 -0
- package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
- package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
- package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
- package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
- package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
- package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
- package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
- package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
- package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
- package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
- package/app/vendor/design-system/src/components/Button.tsx +131 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
- package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
- package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
- package/app/vendor/design-system/src/components/Icon.tsx +28 -0
- package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
- package/app/vendor/design-system/src/components/Input.tsx +106 -0
- package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
- package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
- package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
- package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
- package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
- package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
- package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
- package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
- package/app/vendor/design-system/src/components/Segment.tsx +87 -0
- package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
- package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
- package/app/vendor/design-system/src/components/Text.tsx +85 -0
- package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
- package/app/vendor/design-system/src/components/layout.tsx +284 -0
- package/app/vendor/design-system/src/components/primitives.tsx +143 -0
- package/app/vendor/design-system/src/components/reorder.ts +40 -0
- package/app/vendor/design-system/src/fieldComponents.ts +63 -0
- package/app/vendor/design-system/src/icons.ts +102 -0
- package/app/vendor/design-system/src/index.ts +198 -0
- package/app/vendor/design-system/src/media.ts +229 -0
- package/app/vendor/design-system/src/theme.ts +116 -0
- package/app/vendor/design-system/src/web/Button.tsx +110 -0
- package/app/vendor/design-system/src/web/Icon.tsx +52 -0
- package/app/vendor/design-system/src/web/Input.tsx +39 -0
- package/app/vendor/design-system/src/web/index.ts +43 -0
- package/app/vendor/design-system/src/web/primitives.tsx +119 -0
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
- package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
- package/app/vendor/markdown-editor/src/field.tsx +67 -0
- package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
- package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
- package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
- package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
- package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
- package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
- package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
- package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
- package/app/vendor/markdown-editor/src/icons.tsx +120 -0
- package/app/vendor/markdown-editor/src/index.ts +23 -0
- package/app/vendor/markdown-editor/src/theme.ts +249 -0
- package/app/vendor/markdown-editor/src/types.ts +58 -0
- package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
- package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
- package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
- package/app/vendor/plugin-sdk/src/index.ts +33 -0
- package/app/vendor/plugin-sdk/src/loader.ts +45 -0
- package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
- package/app/vendor/plugin-sdk/src/react.tsx +155 -0
- package/app/vendor/plugin-sdk/src/registry.ts +337 -0
- package/app/vendor/plugin-sdk/src/types.ts +230 -0
- package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
- package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
- package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
- package/app/vendor/realtime/src/collab.ts +308 -0
- package/app/vendor/realtime/src/hooks.ts +57 -0
- package/app/vendor/realtime/src/index.ts +12 -0
- package/app/vendor/realtime/src/pure.ts +43 -0
- package/app/vite.config.mjs +59 -0
- package/bin/gogitcms-editor.mjs +168 -0
- package/npm-shrinkwrap.json +5696 -0
- package/package.json +74 -0
- package/src/commands/build.mjs +100 -0
- package/src/commands/dev.mjs +186 -0
- package/src/commands/init.mjs +144 -0
- package/src/commands/login.mjs +116 -0
- package/src/commands/status.mjs +68 -0
- package/src/lib/api.mjs +122 -0
- package/src/lib/appRoot.mjs +51 -0
- package/src/lib/baked.json +3 -0
- package/src/lib/config.mjs +108 -0
- package/src/lib/credentials.mjs +91 -0
- package/src/lib/defaults.mjs +26 -0
- package/src/lib/graphql.mjs +28 -0
- package/src/lib/localServer.mjs +206 -0
- package/src/lib/open.mjs +23 -0
- package/src/lib/plugins.mjs +164 -0
- package/src/lib/tokenBroker.mjs +85 -0
- package/src/tui/prompts.mjs +92 -0
- package/src/tui/screen.mjs +282 -0
- package/src/tui/theme.mjs +40 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { ApolloClient, InMemoryCache, HttpLink, split } from "@apollo/client";
|
|
2
|
+
import { offsetLimitPagination, getMainDefinition } from "@apollo/client/utilities";
|
|
3
|
+
import { setContext } from "@apollo/client/link/context";
|
|
4
|
+
import { onError } from "@apollo/client/link/error";
|
|
5
|
+
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
|
|
6
|
+
import { createClient } from "graphql-ws";
|
|
7
|
+
import { config } from "./config";
|
|
8
|
+
import { beginAuthorize } from "./auth";
|
|
9
|
+
|
|
10
|
+
// The access token lives in memory only. It is set after a PKCE token exchange
|
|
11
|
+
// and re-minted by bouncing through the issuer — never persisted to storage.
|
|
12
|
+
let token = "";
|
|
13
|
+
export function setAuthToken(t: string) {
|
|
14
|
+
token = t;
|
|
15
|
+
// Clearing the token happens only on an explicit sign-out (see App's onSignOut,
|
|
16
|
+
// which clears it and then navigates to /logout). In that window, in-flight or
|
|
17
|
+
// polling operations fire with no token and come back 401 — which must NOT
|
|
18
|
+
// trigger the auto-reauthorize below, or its top-level redirect to /authorize
|
|
19
|
+
// would race (and beat) the logout navigation and silently sign the user back
|
|
20
|
+
// in from the still-live issuer session. A real token means we're authed, so
|
|
21
|
+
// the expiry-driven reauthorize is armed again.
|
|
22
|
+
signingOut = t === "";
|
|
23
|
+
}
|
|
24
|
+
// The collab WebSocket reads the current token on each (re)connect and sends it
|
|
25
|
+
// via the WebSocket subprotocol (never the URL).
|
|
26
|
+
export function getAuthToken(): string {
|
|
27
|
+
return token;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// config.apiUrl is the server host (injected) or "" for same-origin.
|
|
31
|
+
const httpLink = new HttpLink({ uri: `${config.apiUrl}/graphql` });
|
|
32
|
+
const authLink = setContext((_, { headers }) => ({
|
|
33
|
+
headers: {
|
|
34
|
+
...headers,
|
|
35
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
36
|
+
// The local server requires a custom header on anything that can change
|
|
37
|
+
// state. That is its CSRF barrier: a cross-origin simple POST cannot set a
|
|
38
|
+
// custom header without a preflight, which the server's origin check then
|
|
39
|
+
// refuses. Harmless to send to a hosted API, but scoped to local mode so it
|
|
40
|
+
// never appears on a request that has no reason to carry it.
|
|
41
|
+
...(config.local ? { "X-GitCms-Local": "1" } : {}),
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
// Subscriptions ride a WebSocket (queries/mutations stay on HTTP). The token
|
|
46
|
+
// can't ride an Authorization header on a WS upgrade, so it goes in the
|
|
47
|
+
// graphql-ws connection_init payload — read fresh on each (re)connect, exactly
|
|
48
|
+
// as the collab socket does, so a reconnect after a token refresh reauthenticates.
|
|
49
|
+
function graphqlWsURL(): string {
|
|
50
|
+
const base = config.apiUrl;
|
|
51
|
+
if (base) return base.replace(/^http/i, "ws").replace(/\/$/, "") + "/graphql";
|
|
52
|
+
if (typeof window === "undefined") return "";
|
|
53
|
+
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
54
|
+
return `${proto}://${window.location.host}/graphql`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const wsLink =
|
|
58
|
+
typeof window === "undefined"
|
|
59
|
+
? null
|
|
60
|
+
: new GraphQLWsLink(
|
|
61
|
+
createClient({
|
|
62
|
+
url: graphqlWsURL(),
|
|
63
|
+
connectionParams: () => (token ? { authorization: `Bearer ${token}` } : {}),
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Route subscription operations to the WS link, everything else to HTTP.
|
|
68
|
+
const requestLink = wsLink
|
|
69
|
+
? split(
|
|
70
|
+
({ query }) => {
|
|
71
|
+
const def = getMainDefinition(query);
|
|
72
|
+
return def.kind === "OperationDefinition" && def.operation === "subscription";
|
|
73
|
+
},
|
|
74
|
+
wsLink,
|
|
75
|
+
authLink.concat(httpLink),
|
|
76
|
+
)
|
|
77
|
+
: authLink.concat(httpLink);
|
|
78
|
+
|
|
79
|
+
// When the access token has expired, the server answers 401 (network) or an
|
|
80
|
+
// UNAUTHENTICATED GraphQL error. Re-authorize via a top-level redirect to the
|
|
81
|
+
// issuer, which re-mints a token from the first-party session cookie there. The
|
|
82
|
+
// guard prevents a storm of in-flight operations from each firing a redirect.
|
|
83
|
+
let reauthorizing = false;
|
|
84
|
+
// True from the moment the token is cleared for sign-out until a real token is
|
|
85
|
+
// set again. It suppresses the expiry-driven reauthorize so a 401 during logout
|
|
86
|
+
// teardown can't bounce the user back through /authorize and re-establish the
|
|
87
|
+
// session the logout is trying to end.
|
|
88
|
+
let signingOut = false;
|
|
89
|
+
function reauthorize() {
|
|
90
|
+
if (reauthorizing || signingOut) return;
|
|
91
|
+
reauthorizing = true;
|
|
92
|
+
void beginAuthorize();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const errorLink = onError(({ graphQLErrors, networkError }) => {
|
|
96
|
+
const unauthenticated =
|
|
97
|
+
graphQLErrors?.some((e) => e.extensions?.code === "UNAUTHENTICATED") ||
|
|
98
|
+
(networkError as { statusCode?: number } | null)?.statusCode === 401;
|
|
99
|
+
if (unauthenticated) reauthorize();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export const client = new ApolloClient({
|
|
103
|
+
link: errorLink.concat(requestLink),
|
|
104
|
+
cache: new InMemoryCache({
|
|
105
|
+
typePolicies: {
|
|
106
|
+
Query: {
|
|
107
|
+
fields: {
|
|
108
|
+
// Server-side paginated entry list: fetchMore requests successive
|
|
109
|
+
// offset windows, which this policy concatenates into one contiguous
|
|
110
|
+
// list per (repository, branch, collection, search, filters). offset/limit
|
|
111
|
+
// are NOT key args, so pages of the same search merge into one cached
|
|
112
|
+
// list; search/filters ARE key args, so each distinct search/filter set
|
|
113
|
+
// is its own list (they don't bleed together). See CmsView's fetchMore.
|
|
114
|
+
documents: offsetLimitPagination(["repositoryId", "branchId", "collection", "search", "filters"]),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
119
|
+
});
|
package/app/src/auth.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// PKCE authorization-code flow for the content-editing SPA.
|
|
2
|
+
//
|
|
3
|
+
// The editor can be served from any customer domain while the token issuer lives
|
|
4
|
+
// on a separate origin, so it cannot hold a refresh token securely (a cross-site
|
|
5
|
+
// cookie would be third-party and blocked; anything in JS storage is readable by
|
|
6
|
+
// injected scripts). Instead it holds only a short-lived access token in memory
|
|
7
|
+
// and re-mints it by bouncing the browser through the issuer's /authorize
|
|
8
|
+
// endpoint — a top-level navigation where the issuer session cookie is
|
|
9
|
+
// first-party. PKCE ensures a code intercepted in the redirect URL is useless
|
|
10
|
+
// without the verifier we keep here.
|
|
11
|
+
import { config } from "./config";
|
|
12
|
+
|
|
13
|
+
const VERIFIER_KEY = "cms.pkce.verifier";
|
|
14
|
+
const STATE_KEY = "cms.pkce.state";
|
|
15
|
+
// Set when the user explicitly signs out; makes the sign-out sticky for the tab.
|
|
16
|
+
// While present, nothing may silently re-establish a session — not the PKCE
|
|
17
|
+
// bounce, not the dev-server broker, not a 401-driven retry. It is the durable
|
|
18
|
+
// version of the transient ?loggedout marker: a top-level /logout round-trip (or
|
|
19
|
+
// any reload, or a lost query param) can't wash it away, so a still-live issuer
|
|
20
|
+
// session can't sign the user straight back in. Cleared only when the user
|
|
21
|
+
// initiates a fresh sign-in. sessionStorage (not local): scoped to this tab, so
|
|
22
|
+
// opening the editor in a new tab still authorizes normally.
|
|
23
|
+
const SIGNED_OUT_KEY = "cms.signedOut";
|
|
24
|
+
|
|
25
|
+
export function isSignedOut(): boolean {
|
|
26
|
+
return sessionStorage.getItem(SIGNED_OUT_KEY) === "1";
|
|
27
|
+
}
|
|
28
|
+
// clearSignedOut lifts the suppression. Called at every explicit sign-in entry
|
|
29
|
+
// point (the GitHub bounce and the password/magic-link forms), so a deliberate
|
|
30
|
+
// sign-in always wins over a prior sign-out.
|
|
31
|
+
export function clearSignedOut(): void {
|
|
32
|
+
sessionStorage.removeItem(SIGNED_OUT_KEY);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const authBase = `${config.apiUrl}/api/v1/auth`;
|
|
36
|
+
|
|
37
|
+
// Every call that signs the user in has to be credentialed, and it is the whole
|
|
38
|
+
// reason a session survives a reload.
|
|
39
|
+
//
|
|
40
|
+
// The issuer answers a sign-in with two things: an access token in the body,
|
|
41
|
+
// which this SPA keeps in memory, and its session cookie as a Set-Cookie header.
|
|
42
|
+
// The cookie is what /authorize reads on the next load to re-mint silently. But
|
|
43
|
+
// the editor is served from a different origin than the issuer, and a
|
|
44
|
+
// cross-origin fetch with the default credentials mode ("same-origin") neither
|
|
45
|
+
// sends cookies nor *stores* the ones a response sets — the browser drops the
|
|
46
|
+
// header on the floor, with no error anywhere.
|
|
47
|
+
//
|
|
48
|
+
// So without this, signing in appears to work — an access token comes back and
|
|
49
|
+
// the editor loads — and then every refresh lands on the sign-in form, because
|
|
50
|
+
// the issuer session it would have authorized against was never written.
|
|
51
|
+
//
|
|
52
|
+
// The API is already set up for it: its CORS layer reflects the request origin
|
|
53
|
+
// and sets Access-Control-Allow-Credentials, precisely so a cross-origin editor
|
|
54
|
+
// can use the HttpOnly refresh cookie.
|
|
55
|
+
const credentialed: RequestInit = { credentials: "include" };
|
|
56
|
+
|
|
57
|
+
function base64url(bytes: Uint8Array): string {
|
|
58
|
+
let s = "";
|
|
59
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
60
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function randomString(byteLen = 32): string {
|
|
64
|
+
const buf = new Uint8Array(byteLen);
|
|
65
|
+
crypto.getRandomValues(buf);
|
|
66
|
+
return base64url(buf);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function s256(verifier: string): Promise<string> {
|
|
70
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
71
|
+
return base64url(new Uint8Array(digest));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// redirectUri is the current page without query/hash — where /authorize sends
|
|
75
|
+
// the code back. It must match a redirect target the server allowlists (a
|
|
76
|
+
// same-origin /app path, or a registered self-hosted editor origin).
|
|
77
|
+
function redirectUri(): string {
|
|
78
|
+
return window.location.origin + window.location.pathname;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// beginAuthorize starts (or restarts) the flow: it stashes a fresh verifier and
|
|
82
|
+
// state, then navigates the whole tab to the issuer. This never returns — the
|
|
83
|
+
// browser leaves the page and comes back to the callback.
|
|
84
|
+
export async function beginAuthorize(): Promise<void> {
|
|
85
|
+
// An explicit sign-out is sticky: never silently re-authorize against a
|
|
86
|
+
// still-live issuer session. This is the guard that actually stops the
|
|
87
|
+
// "logout immediately signs me back in" loop — it holds no matter what called
|
|
88
|
+
// us (Root's boot path or a 401-driven retry) and survives the /logout
|
|
89
|
+
// round-trip, unlike an in-memory flag.
|
|
90
|
+
if (isSignedOut()) return;
|
|
91
|
+
const verifier = randomString();
|
|
92
|
+
const state = randomString(16);
|
|
93
|
+
sessionStorage.setItem(VERIFIER_KEY, verifier);
|
|
94
|
+
sessionStorage.setItem(STATE_KEY, state);
|
|
95
|
+
|
|
96
|
+
const params = new URLSearchParams({
|
|
97
|
+
redirect_uri: redirectUri(),
|
|
98
|
+
code_challenge: await s256(verifier),
|
|
99
|
+
code_challenge_method: "S256",
|
|
100
|
+
state,
|
|
101
|
+
});
|
|
102
|
+
window.location.assign(`${authBase}/authorize?${params.toString()}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// beginLogout ends the session at the issuer and returns to the editor. Like
|
|
106
|
+
// beginAuthorize it is a top-level navigation (and never returns): the issuer's
|
|
107
|
+
// session cookie is SameSite=Lax, so only a first-party top-level request clears
|
|
108
|
+
// it — a cross-origin fetch to /logout would omit the cookie, leave the session
|
|
109
|
+
// alive, and let the next authorize silently sign the user back in.
|
|
110
|
+
export function beginLogout(): void {
|
|
111
|
+
// Record the intent before leaving, so it is already durable when the browser
|
|
112
|
+
// comes back from /logout (and even if a stray 401 tries to re-authorize
|
|
113
|
+
// during the teardown, or the ?loggedout marker is lost). Cleared only by an
|
|
114
|
+
// explicit sign-in.
|
|
115
|
+
sessionStorage.setItem(SIGNED_OUT_KEY, "1");
|
|
116
|
+
const params = new URLSearchParams({ redirect_uri: redirectUri() });
|
|
117
|
+
window.location.assign(`${authBase}/logout?${params.toString()}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// beginGitHub starts GitHub sign-in as a top-level navigation to the issuer,
|
|
121
|
+
// asking it to return the signed verification token to this editor page (its
|
|
122
|
+
// origin is on the same allowlist the PKCE flow uses). handleCallback then trades
|
|
123
|
+
// that token for an access token. Never returns — the tab leaves and comes back.
|
|
124
|
+
export function beginGitHub(): void {
|
|
125
|
+
// An explicit sign-in overrides a prior sign-out.
|
|
126
|
+
clearSignedOut();
|
|
127
|
+
const params = new URLSearchParams({ redirect_uri: redirectUri() });
|
|
128
|
+
window.location.assign(`${authBase}/github?${params.toString()}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// brokeredToken asks the local dev server for an access token. `gogitcms-editor dev`
|
|
132
|
+
// serves this page from a localhost origin the issuer will never allowlist for
|
|
133
|
+
// PKCE, so the CLI — which already signed in via the device grant — lends its
|
|
134
|
+
// session through a same-origin endpoint. Returns "" when there is no broker
|
|
135
|
+
// (every hosted build) or it has no session to lend, in which case the caller
|
|
136
|
+
// falls back to PKCE.
|
|
137
|
+
export async function brokeredToken(): Promise<string> {
|
|
138
|
+
if (!config.tokenEndpoint) return "";
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(config.tokenEndpoint, { headers: { Accept: "application/json" } });
|
|
141
|
+
if (!res.ok) return "";
|
|
142
|
+
const body = await res.json();
|
|
143
|
+
return typeof body.accessToken === "string" ? body.accessToken : "";
|
|
144
|
+
} catch {
|
|
145
|
+
return "";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type CallbackResult =
|
|
150
|
+
| { kind: "none" } // no auth params in the URL — a normal load
|
|
151
|
+
| { kind: "token"; accessToken: string }
|
|
152
|
+
| { kind: "error"; error: string }
|
|
153
|
+
| { kind: "loggedout" }; // returned from /logout — stay signed out
|
|
154
|
+
|
|
155
|
+
// handleCallback inspects the URL for an authorize redirect result. On ?code it
|
|
156
|
+
// verifies state and exchanges the code (with the stored verifier) for an access
|
|
157
|
+
// token. It always strips the auth params from the URL so a reload can't replay
|
|
158
|
+
// them. `login_required` surfaces as an error so the SPA can prompt sign-in.
|
|
159
|
+
export async function handleCallback(): Promise<CallbackResult> {
|
|
160
|
+
const url = new URL(window.location.href);
|
|
161
|
+
const code = url.searchParams.get("code");
|
|
162
|
+
const error = url.searchParams.get("error");
|
|
163
|
+
const state = url.searchParams.get("state");
|
|
164
|
+
const loggedOut = url.searchParams.get("loggedout");
|
|
165
|
+
|
|
166
|
+
// Return from /logout: the issuer session is cleared. Strip the marker and
|
|
167
|
+
// report it so the SPA lands on sign-in instead of silently re-authorizing.
|
|
168
|
+
if (loggedOut) {
|
|
169
|
+
stripAuthParams();
|
|
170
|
+
return { kind: "loggedout" };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Return from GitHub sign-in: the issuer handed back a signed verification
|
|
174
|
+
// token (?token). Trade it for an access token via /github/complete. (A GitHub
|
|
175
|
+
// failure comes back as ?error, handled by the PKCE path below.)
|
|
176
|
+
const ghToken = url.searchParams.get("token");
|
|
177
|
+
if (ghToken && !code) {
|
|
178
|
+
stripAuthParams();
|
|
179
|
+
const res = await fetch(`${authBase}/github/complete`, {
|
|
180
|
+
...credentialed,
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "Content-Type": "application/json" },
|
|
183
|
+
body: JSON.stringify({ token: ghToken }),
|
|
184
|
+
});
|
|
185
|
+
if (!res.ok) {
|
|
186
|
+
return { kind: "error", error: res.status === 409 ? "github_claim_required" : "github_failed" };
|
|
187
|
+
}
|
|
188
|
+
const body = await res.json();
|
|
189
|
+
return { kind: "token", accessToken: body.accessToken as string };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!code && !error) return { kind: "none" };
|
|
193
|
+
|
|
194
|
+
const expectedState = sessionStorage.getItem(STATE_KEY);
|
|
195
|
+
const verifier = sessionStorage.getItem(VERIFIER_KEY);
|
|
196
|
+
sessionStorage.removeItem(STATE_KEY);
|
|
197
|
+
sessionStorage.removeItem(VERIFIER_KEY);
|
|
198
|
+
stripAuthParams();
|
|
199
|
+
|
|
200
|
+
if (error) return { kind: "error", error };
|
|
201
|
+
if (!state || state !== expectedState || !verifier) {
|
|
202
|
+
return { kind: "error", error: "state_mismatch" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const res = await fetch(`${authBase}/token`, {
|
|
206
|
+
...credentialed,
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "Content-Type": "application/json" },
|
|
209
|
+
body: JSON.stringify({ code, codeVerifier: verifier }),
|
|
210
|
+
});
|
|
211
|
+
if (!res.ok) return { kind: "error", error: "exchange_failed" };
|
|
212
|
+
const body = await res.json();
|
|
213
|
+
return { kind: "token", accessToken: body.accessToken as string };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// requestMagicLink asks the issuer to email a passwordless sign-in code. Always
|
|
217
|
+
// resolves for a valid request (the endpoint returns 200 even for unknown
|
|
218
|
+
// addresses so it can't be used to probe which emails exist).
|
|
219
|
+
export async function requestMagicLink(email: string): Promise<void> {
|
|
220
|
+
const res = await fetch(`${authBase}/register/magic-link`, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: { "Content-Type": "application/json" },
|
|
223
|
+
body: JSON.stringify({ email }),
|
|
224
|
+
});
|
|
225
|
+
if (!res.ok) throw new Error("could not send sign-in link");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// redeemMagicLink exchanges the emailed code for an access token. The editor is
|
|
229
|
+
// cross-origin to the issuer and holds no refresh token, so it keeps only the
|
|
230
|
+
// returned access token in memory (like the PKCE flow); the emailed link opens
|
|
231
|
+
// the dashboard, so the code is entered into the editor by hand.
|
|
232
|
+
export async function redeemMagicLink(code: string): Promise<string> {
|
|
233
|
+
const res = await fetch(`${authBase}/magic-link/redeem`, {
|
|
234
|
+
...credentialed,
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: { "Content-Type": "application/json" },
|
|
237
|
+
body: JSON.stringify({ code }),
|
|
238
|
+
});
|
|
239
|
+
if (!res.ok) throw new Error("invalid or expired code");
|
|
240
|
+
const body = await res.json();
|
|
241
|
+
return body.accessToken as string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function stripAuthParams() {
|
|
245
|
+
const url = new URL(window.location.href);
|
|
246
|
+
for (const k of ["code", "state", "error", "loggedout", "token"]) url.searchParams.delete(k);
|
|
247
|
+
window.history.replaceState(null, "", url.pathname + url.search + url.hash);
|
|
248
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Runtime configuration for the SPA. Resolved from two sources, in order:
|
|
2
|
+
// 1. Build-time values injected via Vite `define` (from cms.config.js|ts).
|
|
3
|
+
// 2. Window globals injected by the Go server into the served HTML.
|
|
4
|
+
// Build-time wins so a purpose-built bundle can't be overridden at runtime.
|
|
5
|
+
|
|
6
|
+
import { resolveAnalyticsConfig, type AnalyticsConfig, type RawAnalyticsConfig } from "@gogitcms/analytics";
|
|
7
|
+
|
|
8
|
+
export type CmsConfig = {
|
|
9
|
+
apiUrl: string;
|
|
10
|
+
workspaceId?: string;
|
|
11
|
+
repositoryId?: string;
|
|
12
|
+
// Pins this deployment to a single project of the repository. A locked editor
|
|
13
|
+
// renders no project picker and refuses a URL naming a different project, so a
|
|
14
|
+
// self-hosted instance can be handed to a team that should only ever see one
|
|
15
|
+
// project's content.
|
|
16
|
+
//
|
|
17
|
+
// The value is the project's manifest NAME, not its id: names are portable
|
|
18
|
+
// across deployments, so the same cms.config.js works against staging and
|
|
19
|
+
// production, and it is the same token the URL segment carries.
|
|
20
|
+
project?: string;
|
|
21
|
+
// Same-origin path that hands this page an access token, set only by
|
|
22
|
+
// `gogitcms-editor dev`. A local dev origin can never be on the issuer's PKCE
|
|
23
|
+
// redirect allowlist, so the CLI lends the SPA the session it already holds.
|
|
24
|
+
// Absent in every hosted build, which keeps using PKCE.
|
|
25
|
+
tokenEndpoint?: string;
|
|
26
|
+
// Origin of the collaboration WebSocket, for deployments that run `collab` as
|
|
27
|
+
// its own process on its own port. Empty means it is served by the API origin,
|
|
28
|
+
// which is what the single-process server does.
|
|
29
|
+
collabUrl?: string;
|
|
30
|
+
// True when the API is the local filesystem server (`gogitcms-editor dev --local`).
|
|
31
|
+
//
|
|
32
|
+
// Local mode is a subset of the hosted API: one branch, no merges, no
|
|
33
|
+
// collaboration. The editor uses this to HIDE those surfaces rather than let
|
|
34
|
+
// someone click into an error — a feature the user never sees beats a failure
|
|
35
|
+
// they cannot act on.
|
|
36
|
+
local: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type RawConfig = {
|
|
40
|
+
API_URL?: string;
|
|
41
|
+
WORKSPACE_ID?: string;
|
|
42
|
+
REPOSITORY_ID?: string;
|
|
43
|
+
PROJECT?: string;
|
|
44
|
+
TOKEN_ENDPOINT?: string;
|
|
45
|
+
COLLAB_URL?: string;
|
|
46
|
+
MODE?: string;
|
|
47
|
+
} & RawAnalyticsConfig;
|
|
48
|
+
|
|
49
|
+
// Replaced at build time by Vite `define`; `typeof` guards the undeclared case
|
|
50
|
+
// (plain `vite` dev with no config) without throwing.
|
|
51
|
+
declare const __CMS_CONFIG_BUILD__: RawConfig | undefined;
|
|
52
|
+
|
|
53
|
+
const build: RawConfig =
|
|
54
|
+
typeof __CMS_CONFIG_BUILD__ !== "undefined" && __CMS_CONFIG_BUILD__ ? __CMS_CONFIG_BUILD__ : {};
|
|
55
|
+
|
|
56
|
+
const runtime: RawConfig =
|
|
57
|
+
(typeof window !== "undefined" && (window as any).__CMS_CONFIG__) || {};
|
|
58
|
+
|
|
59
|
+
const pick = (key: keyof RawConfig): string | undefined => build[key] || runtime[key] || undefined;
|
|
60
|
+
|
|
61
|
+
export const config: CmsConfig = {
|
|
62
|
+
apiUrl: pick("API_URL") ?? "",
|
|
63
|
+
workspaceId: pick("WORKSPACE_ID"),
|
|
64
|
+
project: pick("PROJECT"),
|
|
65
|
+
repositoryId: pick("REPOSITORY_ID"),
|
|
66
|
+
// Only honoured from the build-time side: a runtime global is injected by
|
|
67
|
+
// whoever serves the page, and a hosted deployment must not be able to point
|
|
68
|
+
// the editor's credential fetch at a path of its choosing.
|
|
69
|
+
tokenEndpoint: build.TOKEN_ENDPOINT || undefined,
|
|
70
|
+
collabUrl: pick("COLLAB_URL"),
|
|
71
|
+
// Build-time only, like tokenEndpoint: whoever serves the page must not be
|
|
72
|
+
// able to talk the editor into (or out of) local mode at runtime.
|
|
73
|
+
local: build.MODE === "local",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// PostHog, resolved through the same two-source chain. Build-time values come
|
|
77
|
+
// from cms.config.js (or POSTHOG_* env vars at build time, which the
|
|
78
|
+
// cms-frontend CLI folds into the same object); the runtime globals let a
|
|
79
|
+
// server-embedded bundle be configured by the deployment serving it.
|
|
80
|
+
//
|
|
81
|
+
// Empty POSTHOG_API_KEY — the default everywhere — leaves analytics off.
|
|
82
|
+
export const analyticsConfig: AnalyticsConfig = resolveAnalyticsConfig({
|
|
83
|
+
POSTHOG_API_KEY: pick("POSTHOG_API_KEY"),
|
|
84
|
+
POSTHOG_API_HOST: pick("POSTHOG_API_HOST"),
|
|
85
|
+
POSTHOG_UI_HOST: pick("POSTHOG_UI_HOST"),
|
|
86
|
+
POSTHOG_ENVIRONMENT: pick("POSTHOG_ENVIRONMENT"),
|
|
87
|
+
POSTHOG_SESSION_RECORDING: pick("POSTHOG_SESSION_RECORDING"),
|
|
88
|
+
});
|
package/app/src/main.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AppRegistry } from "react-native";
|
|
2
|
+
import { loadPlugins } from "@gogitcms/plugin-sdk";
|
|
3
|
+
import { plugins } from "virtual:cms-plugins";
|
|
4
|
+
import App from "./App";
|
|
5
|
+
import { pluginRegistry } from "./plugins";
|
|
6
|
+
|
|
7
|
+
// Kick off plugin loading before mount and render immediately: contributions
|
|
8
|
+
// appear as each plugin's chunk lands (the app re-renders from registry
|
|
9
|
+
// subscriptions). With no `plugins` in cms.config.js the list is empty and
|
|
10
|
+
// this is a no-op.
|
|
11
|
+
void loadPlugins(plugins, pluginRegistry);
|
|
12
|
+
|
|
13
|
+
// Canonical react-native-web mount: runApplication injects the styles that make
|
|
14
|
+
// the app root fill the viewport, which the RN `flex: 1` layouts depend on.
|
|
15
|
+
AppRegistry.registerComponent("CMS", () => App);
|
|
16
|
+
AppRegistry.runApplication("CMS", {
|
|
17
|
+
rootTag: document.getElementById("root"),
|
|
18
|
+
});
|
package/app/src/media.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useApolloClient, useQuery } from "@apollo/client";
|
|
3
|
+
import type { MediaApi, MediaAsset, MediaFacets, MediaResolution, MediaSetInfo } from "@gogitcms/design-system";
|
|
4
|
+
import {
|
|
5
|
+
MEDIA,
|
|
6
|
+
MEDIA_BY_ID,
|
|
7
|
+
MEDIA_COUNT,
|
|
8
|
+
MEDIA_FACETS,
|
|
9
|
+
MEDIA_SETS,
|
|
10
|
+
RESOLVE_MEDIA,
|
|
11
|
+
CREATE_MEDIA_UPLOAD,
|
|
12
|
+
FINALIZE_MEDIA_UPLOAD,
|
|
13
|
+
} from "./queries";
|
|
14
|
+
|
|
15
|
+
// The Apollo implementation of the design system's media seam. The DS owns the
|
|
16
|
+
// picker and preview UI and knows nothing about transport; this file is the only
|
|
17
|
+
// place media queries live on the web client.
|
|
18
|
+
|
|
19
|
+
// Reads bypass the cache deliberately. A Media carries a presigned `url` with a
|
|
20
|
+
// short lifetime, so a cached copy from an hour ago would hand the editor an
|
|
21
|
+
// expired URL and render a broken image.
|
|
22
|
+
const NO_CACHE = { fetchPolicy: "no-cache" as const };
|
|
23
|
+
|
|
24
|
+
export function useMediaApi(repositoryId?: string, branchId?: string, project?: string): MediaApi | undefined {
|
|
25
|
+
const client = useApolloClient();
|
|
26
|
+
const skip = !repositoryId || !branchId;
|
|
27
|
+
|
|
28
|
+
// Sets come from the branch's config and change only on re-import, so unlike
|
|
29
|
+
// the assets themselves they are worth caching.
|
|
30
|
+
const { data } = useQuery(MEDIA_SETS, {
|
|
31
|
+
// Media hangs off the config, which is per project, so the sets a picker
|
|
32
|
+
// browses are the selected project's — not every project's on the branch.
|
|
33
|
+
variables: { repositoryId, branchId, project: project ?? null },
|
|
34
|
+
skip,
|
|
35
|
+
});
|
|
36
|
+
const sets: MediaSetInfo[] = data?.mediaSets ?? [];
|
|
37
|
+
|
|
38
|
+
return useMemo(() => {
|
|
39
|
+
if (skip) return undefined;
|
|
40
|
+
// Every media operation is scoped to one project's config, so the project
|
|
41
|
+
// travels with the ids rather than being remembered per call site. On a
|
|
42
|
+
// branch carrying several projects the server cannot pick for us: omitting
|
|
43
|
+
// it is an ambiguity error, not a default.
|
|
44
|
+
const ids = { repositoryId, branchId, project: project ?? null };
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
sets,
|
|
48
|
+
|
|
49
|
+
resolve: async (paths, documentPath) => {
|
|
50
|
+
if (paths.length === 0) return [];
|
|
51
|
+
const res = await client.query({
|
|
52
|
+
query: RESOLVE_MEDIA,
|
|
53
|
+
variables: { ...ids, paths, documentPath: documentPath ?? null },
|
|
54
|
+
...NO_CACHE,
|
|
55
|
+
});
|
|
56
|
+
return (res.data?.resolveMedia ?? []) as MediaResolution[];
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
byId: async (id) => {
|
|
60
|
+
const res = await client.query({
|
|
61
|
+
query: MEDIA_BY_ID,
|
|
62
|
+
variables: { ...ids, id },
|
|
63
|
+
...NO_CACHE,
|
|
64
|
+
});
|
|
65
|
+
return (res.data?.mediaById ?? null) as MediaAsset | null;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
list: async ({ set, kinds, search, filters, limit, offset }) => {
|
|
69
|
+
const res = await client.query({
|
|
70
|
+
query: MEDIA,
|
|
71
|
+
variables: {
|
|
72
|
+
...ids,
|
|
73
|
+
set: set ?? null,
|
|
74
|
+
kinds: kinds ?? null,
|
|
75
|
+
search: search ?? null,
|
|
76
|
+
filters: filters ?? null,
|
|
77
|
+
limit: limit ?? null,
|
|
78
|
+
offset: offset ?? null,
|
|
79
|
+
},
|
|
80
|
+
...NO_CACHE,
|
|
81
|
+
});
|
|
82
|
+
return (res.data?.media ?? []) as MediaAsset[];
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
count: async ({ set, kinds, search, filters }) => {
|
|
86
|
+
const res = await client.query({
|
|
87
|
+
query: MEDIA_COUNT,
|
|
88
|
+
variables: {
|
|
89
|
+
...ids,
|
|
90
|
+
set: set ?? null,
|
|
91
|
+
kinds: kinds ?? null,
|
|
92
|
+
search: search ?? null,
|
|
93
|
+
filters: filters ?? null,
|
|
94
|
+
},
|
|
95
|
+
...NO_CACHE,
|
|
96
|
+
});
|
|
97
|
+
return (res.data?.mediaCount ?? 0) as number;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// Facets describe the set, not the current result, so unlike the listing
|
|
101
|
+
// they are safe to cache: they only change when media is re-imported.
|
|
102
|
+
facets: async (set) => {
|
|
103
|
+
const res = await client.query({
|
|
104
|
+
query: MEDIA_FACETS,
|
|
105
|
+
variables: { ...ids, set: set ?? null },
|
|
106
|
+
});
|
|
107
|
+
return res.data.mediaFacets as MediaFacets;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// Three steps: ask the server for a presigned PUT, send the bytes straight
|
|
111
|
+
// to the object store, then tell the server they landed. The file never
|
|
112
|
+
// passes through the API process.
|
|
113
|
+
upload: async ({ set, file, folder }) => {
|
|
114
|
+
const granted = await client.mutate({
|
|
115
|
+
mutation: CREATE_MEDIA_UPLOAD,
|
|
116
|
+
variables: {
|
|
117
|
+
...ids,
|
|
118
|
+
set,
|
|
119
|
+
filename: file.name,
|
|
120
|
+
contentType: file.type || "application/octet-stream",
|
|
121
|
+
size: file.size,
|
|
122
|
+
folder: folder ?? null,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const grant = granted.data?.createMediaUpload;
|
|
126
|
+
if (!grant) throw new Error("Upload was not granted.");
|
|
127
|
+
|
|
128
|
+
const put = await fetch(grant.url, {
|
|
129
|
+
method: "PUT",
|
|
130
|
+
// Must match the type the grant was issued for. Presigning does not
|
|
131
|
+
// actually enforce this (S3 and MinIO both accept a different header),
|
|
132
|
+
// so the server re-checks the stored object on finalize and rejects a
|
|
133
|
+
// mismatch — sending the right one here keeps that check passing.
|
|
134
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
135
|
+
body: file,
|
|
136
|
+
});
|
|
137
|
+
if (!put.ok) {
|
|
138
|
+
throw new Error(`Upload failed (${put.status}). The file was not stored.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const finalized = await client.mutate({
|
|
142
|
+
mutation: FINALIZE_MEDIA_UPLOAD,
|
|
143
|
+
variables: { ...ids, uploadId: grant.uploadId },
|
|
144
|
+
});
|
|
145
|
+
const asset = finalized.data?.finalizeMediaUpload;
|
|
146
|
+
if (!asset) throw new Error("Upload could not be finalized.");
|
|
147
|
+
return asset as MediaAsset;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}, [client, repositoryId, branchId, project, skip, sets]);
|
|
151
|
+
}
|