@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.
Files changed (143) hide show
  1. package/app/index.html +38 -0
  2. package/app/src/App.tsx +2665 -0
  3. package/app/src/apollo.ts +119 -0
  4. package/app/src/auth.ts +248 -0
  5. package/app/src/config.ts +88 -0
  6. package/app/src/main.tsx +18 -0
  7. package/app/src/media.ts +151 -0
  8. package/app/src/navigation.tsx +321 -0
  9. package/app/src/plugins.ts +11 -0
  10. package/app/src/previewHost.tsx +263 -0
  11. package/app/src/previewToken.ts +68 -0
  12. package/app/src/queries.ts +591 -0
  13. package/app/src/virtual-cms-plugins.d.ts +6 -0
  14. package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
  15. package/app/vendor/analytics/src/config.ts +94 -0
  16. package/app/vendor/analytics/src/events.ts +56 -0
  17. package/app/vendor/analytics/src/index.ts +23 -0
  18. package/app/vendor/analytics/src/provider.tsx +196 -0
  19. package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
  20. package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  21. package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
  22. package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
  23. package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  24. package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  25. package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  26. package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  27. package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  28. package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  29. package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  30. package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  31. package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  32. package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  33. package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  34. package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  35. package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  36. package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  37. package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  38. package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  39. package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  40. package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  41. package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
  42. package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
  43. package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
  44. package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
  45. package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
  46. package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
  47. package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
  48. package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
  49. package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
  50. package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
  51. package/app/vendor/design-system/src/components/Button.tsx +131 -0
  52. package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
  53. package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
  54. package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
  55. package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
  56. package/app/vendor/design-system/src/components/Icon.tsx +28 -0
  57. package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
  58. package/app/vendor/design-system/src/components/Input.tsx +106 -0
  59. package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
  60. package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
  61. package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
  62. package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
  63. package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
  64. package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
  65. package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
  66. package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
  67. package/app/vendor/design-system/src/components/Segment.tsx +87 -0
  68. package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
  69. package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
  70. package/app/vendor/design-system/src/components/Text.tsx +85 -0
  71. package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
  72. package/app/vendor/design-system/src/components/layout.tsx +284 -0
  73. package/app/vendor/design-system/src/components/primitives.tsx +143 -0
  74. package/app/vendor/design-system/src/components/reorder.ts +40 -0
  75. package/app/vendor/design-system/src/fieldComponents.ts +63 -0
  76. package/app/vendor/design-system/src/icons.ts +102 -0
  77. package/app/vendor/design-system/src/index.ts +198 -0
  78. package/app/vendor/design-system/src/media.ts +229 -0
  79. package/app/vendor/design-system/src/theme.ts +116 -0
  80. package/app/vendor/design-system/src/web/Button.tsx +110 -0
  81. package/app/vendor/design-system/src/web/Icon.tsx +52 -0
  82. package/app/vendor/design-system/src/web/Input.tsx +39 -0
  83. package/app/vendor/design-system/src/web/index.ts +43 -0
  84. package/app/vendor/design-system/src/web/primitives.tsx +119 -0
  85. package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
  86. package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
  87. package/app/vendor/markdown-editor/src/field.tsx +67 -0
  88. package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
  89. package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
  90. package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
  91. package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
  92. package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
  93. package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
  94. package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
  95. package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
  96. package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
  97. package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
  98. package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
  99. package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
  100. package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
  101. package/app/vendor/markdown-editor/src/icons.tsx +120 -0
  102. package/app/vendor/markdown-editor/src/index.ts +23 -0
  103. package/app/vendor/markdown-editor/src/theme.ts +249 -0
  104. package/app/vendor/markdown-editor/src/types.ts +58 -0
  105. package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
  106. package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
  107. package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
  108. package/app/vendor/plugin-sdk/src/index.ts +33 -0
  109. package/app/vendor/plugin-sdk/src/loader.ts +45 -0
  110. package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
  111. package/app/vendor/plugin-sdk/src/react.tsx +155 -0
  112. package/app/vendor/plugin-sdk/src/registry.ts +337 -0
  113. package/app/vendor/plugin-sdk/src/types.ts +230 -0
  114. package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
  115. package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
  116. package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
  117. package/app/vendor/realtime/src/collab.ts +308 -0
  118. package/app/vendor/realtime/src/hooks.ts +57 -0
  119. package/app/vendor/realtime/src/index.ts +12 -0
  120. package/app/vendor/realtime/src/pure.ts +43 -0
  121. package/app/vite.config.mjs +59 -0
  122. package/bin/gogitcms-editor.mjs +168 -0
  123. package/npm-shrinkwrap.json +5696 -0
  124. package/package.json +74 -0
  125. package/src/commands/build.mjs +100 -0
  126. package/src/commands/dev.mjs +186 -0
  127. package/src/commands/init.mjs +144 -0
  128. package/src/commands/login.mjs +116 -0
  129. package/src/commands/status.mjs +68 -0
  130. package/src/lib/api.mjs +122 -0
  131. package/src/lib/appRoot.mjs +51 -0
  132. package/src/lib/baked.json +3 -0
  133. package/src/lib/config.mjs +108 -0
  134. package/src/lib/credentials.mjs +91 -0
  135. package/src/lib/defaults.mjs +26 -0
  136. package/src/lib/graphql.mjs +28 -0
  137. package/src/lib/localServer.mjs +206 -0
  138. package/src/lib/open.mjs +23 -0
  139. package/src/lib/plugins.mjs +164 -0
  140. package/src/lib/tokenBroker.mjs +85 -0
  141. package/src/tui/prompts.mjs +92 -0
  142. package/src/tui/screen.mjs +282 -0
  143. 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
+ });
@@ -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
+ });
@@ -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
+ });
@@ -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
+ }