sanity-plugin-preview-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: !0 });
3
+ var sanity = require("sanity"), icons = require("@sanity/icons"), jsxRuntime = require("react/jsx-runtime"), ui = require("@sanity/ui"), react = require("react"), sanityPluginPreviewAuthValidate = require("sanity-plugin-preview-auth-validate");
4
+ function usePreviewAuth({
5
+ previewOrigin,
6
+ previewAuthApi
7
+ }) {
8
+ const client = sanity.useClient(sanity.DEFAULT_STUDIO_CLIENT_OPTIONS), currentUser = sanity.useCurrentUser(), [isAuthenticating, setIsAuthenticating] = react.useState(!1), [error, setError] = react.useState(null), redirectUrl = react.useMemo(() => typeof window > "u" ? null : new URLSearchParams(window.location.search).get("redirect"), []), fullRedirectUrl = react.useMemo(() => {
9
+ if (!redirectUrl || !previewOrigin)
10
+ return null;
11
+ try {
12
+ return new URL(redirectUrl, previewOrigin).toString();
13
+ } catch {
14
+ return `${previewOrigin}${redirectUrl}`;
15
+ }
16
+ }, [redirectUrl, previewOrigin]), handleAuthenticate = react.useCallback(async () => {
17
+ if (!previewOrigin) {
18
+ setError("No preview URL configured for this workspace.");
19
+ return;
20
+ }
21
+ setIsAuthenticating(!0), setError(null);
22
+ try {
23
+ const { secret } = await sanityPluginPreviewAuthValidate.createPreviewSecret({
24
+ client,
25
+ source: "sanity/preview-auth",
26
+ studioUrl: window.location.href,
27
+ userId: currentUser?.id
28
+ }), apiUrl = new URL(previewAuthApi, previewOrigin), redirectPath = redirectUrl?.startsWith("/preview") ? redirectUrl.replace(/^\/preview/, "") || "/" : redirectUrl || "/";
29
+ apiUrl.searchParams.set("sanity-preview-secret", secret), apiUrl.searchParams.set("sanity-preview-pathname", redirectPath), apiUrl.searchParams.set("response", "json");
30
+ const response = await fetch(apiUrl, {
31
+ method: "GET",
32
+ credentials: "include",
33
+ headers: { Accept: "application/json" }
34
+ }), payload = await response.json().catch(() => null);
35
+ if (!response.ok || payload?.ok === !1)
36
+ throw new Error(payload?.error || `Authentication failed (${response.status})`);
37
+ if (!payload?.redirectTo)
38
+ throw new Error("Missing redirect target from authentication response.");
39
+ window.location.href = new URL(payload.redirectTo, previewOrigin).toString();
40
+ } catch (err) {
41
+ setError(err instanceof Error ? err.message : "Failed to authenticate preview mode."), setIsAuthenticating(!1);
42
+ }
43
+ }, [client, currentUser?.id, previewAuthApi, previewOrigin, redirectUrl]);
44
+ return { redirectUrl, fullRedirectUrl, isAuthenticating, error, handleAuthenticate };
45
+ }
46
+ function PreviewAuthPage({ previewOrigin, previewAuthApi }) {
47
+ const { redirectUrl, fullRedirectUrl, isAuthenticating, error, handleAuthenticate } = usePreviewAuth({ previewOrigin, previewAuthApi });
48
+ return redirectUrl ? /* @__PURE__ */ jsxRuntime.jsx(ui.Card, { height: "fill", overflow: "auto", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Container, { width: 1, padding: 5, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Flex, { justify: "center", align: "center", style: { minHeight: "60vh" }, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Card, { padding: 5, radius: 3, shadow: 1, style: { maxWidth: 480, width: "100%" }, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Stack, { space: 5, children: [
49
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Flex, { justify: "center", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Card, { padding: 3, radius: "full", tone: "primary", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 4, children: /* @__PURE__ */ jsxRuntime.jsx(icons.EyeOpenIcon, {}) }) }) }),
50
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Stack, { space: 3, style: { textAlign: "center" }, children: [
51
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { size: 2, children: "Preview Mode Authentication" }),
52
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { muted: !0, size: 2, children: "Authenticate to access the preview environment. This generates a secure token valid for 3 months." })
53
+ ] }),
54
+ fullRedirectUrl && /* @__PURE__ */ jsxRuntime.jsx(ui.Card, { padding: 3, radius: 2, tone: "positive", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Stack, { space: 3, children: [
55
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 0, muted: !0, weight: "medium", children: "Redirecting to:" }),
56
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 1, style: { wordBreak: "break-all" }, children: fullRedirectUrl })
57
+ ] }) }),
58
+ error && /* @__PURE__ */ jsxRuntime.jsx(ui.Card, { padding: 3, radius: 2, tone: "critical", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 1, children: error }) }),
59
+ /* @__PURE__ */ jsxRuntime.jsx(
60
+ ui.Button,
61
+ {
62
+ fontSize: 2,
63
+ padding: 4,
64
+ tone: "primary",
65
+ loading: isAuthenticating,
66
+ text: isAuthenticating ? "Authenticating\u2026" : "Authenticate Preview Mode",
67
+ icon: icons.EyeOpenIcon,
68
+ onClick: handleAuthenticate,
69
+ disabled: isAuthenticating || !previewOrigin
70
+ }
71
+ )
72
+ ] }) }) }) }) }) : /* @__PURE__ */ jsxRuntime.jsx(ui.Card, { height: "fill", overflow: "auto", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Container, { width: 1, padding: 5, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Flex, { justify: "center", align: "center", style: { minHeight: "60vh" }, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Card, { padding: 5, radius: 3, shadow: 1, style: { maxWidth: 480, width: "100%" }, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Stack, { space: 4, style: { textAlign: "center" }, children: [
73
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { size: 4, children: "404" }),
74
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { muted: !0, size: 2, children: "Preview authentication requires a redirect URL." })
75
+ ] }) }) }) }) });
76
+ }
77
+ const previewAuthSecret = sanity.defineType({
78
+ name: sanityPluginPreviewAuthValidate.PREVIEW_AUTH_SECRET_TYPE,
79
+ type: "document",
80
+ title: "Preview Auth Secret",
81
+ fields: [
82
+ sanity.defineField({ name: "secret", type: "string", title: "Secret" }),
83
+ sanity.defineField({ name: "source", type: "string", title: "Source" }),
84
+ sanity.defineField({ name: "studioUrl", type: "string", title: "Studio URL" }),
85
+ sanity.defineField({ name: "userId", type: "string", title: "User ID" }),
86
+ sanity.defineField({ name: "expiresAt", type: "datetime", title: "Expires At" })
87
+ ]
88
+ }), TOOL_NAME = "preview-auth", previewAuthPlugin = sanity.definePlugin((options) => ({
89
+ name: TOOL_NAME,
90
+ schema: {
91
+ types: [previewAuthSecret]
92
+ },
93
+ tools: [
94
+ {
95
+ name: TOOL_NAME,
96
+ title: "Preview Auth",
97
+ icon: icons.EyeOpenIcon,
98
+ component: () => PreviewAuthPage(options)
99
+ }
100
+ ],
101
+ studio: {
102
+ components: {
103
+ toolMenu: (props) => {
104
+ const filteredTools = props.tools.filter((tool) => tool.name !== TOOL_NAME);
105
+ return props.renderDefault({ ...props, tools: filteredTools });
106
+ }
107
+ }
108
+ }
109
+ }));
110
+ function createAction(options) {
111
+ const { previewOrigin, slugField = "slug" } = options, OpenPreviewAction = (props) => {
112
+ const client = sanity.useClient(sanity.DEFAULT_STUDIO_CLIENT_OPTIONS), currentUser = sanity.useCurrentUser(), [isGenerating, setIsGenerating] = react.useState(!1), slug = (props.draft ?? props.published)?.[slugField], slugValue = typeof slug == "string" ? slug : slug?.current;
113
+ return slugValue ? {
114
+ label: isGenerating ? "Opening\u2026" : "Open Preview",
115
+ icon: icons.EyeOpenIcon,
116
+ disabled: isGenerating,
117
+ onHandle: async () => {
118
+ setIsGenerating(!0);
119
+ try {
120
+ const { secret } = await sanityPluginPreviewAuthValidate.createPreviewSecret({
121
+ client,
122
+ source: "sanity/open-preview-action",
123
+ studioUrl: window.location.href,
124
+ userId: currentUser?.id
125
+ }), previewPath = slugValue.startsWith("/") ? slugValue : `/${slugValue}`, url = new URL(`/preview${previewPath}`, previewOrigin);
126
+ url.searchParams.set("sanity-preview-secret", secret), window.open(url.toString(), "_blank", "noopener,noreferrer");
127
+ } finally {
128
+ setIsGenerating(!1), props.onComplete();
129
+ }
130
+ }
131
+ } : null;
132
+ };
133
+ return OpenPreviewAction.action = "openPreview", OpenPreviewAction;
134
+ }
135
+ function openPreviewAction(options) {
136
+ return createAction(options);
137
+ }
138
+ exports.openPreviewAction = openPreviewAction;
139
+ exports.previewAuthPlugin = previewAuthPlugin;
140
+ exports.previewAuthSecret = previewAuthSecret;
141
+ exports.usePreviewAuth = usePreviewAuth;
142
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","sources":["../src/hooks/usePreviewAuth.ts","../src/components/PreviewAuthPage.tsx","../src/schema/previewAuthSecret.ts","../src/plugin.ts","../src/actions/openPreviewAction.ts"],"sourcesContent":["import { useState, useCallback, useMemo } from 'react'\nimport { useClient, useCurrentUser, DEFAULT_STUDIO_CLIENT_OPTIONS } from 'sanity'\nimport { createPreviewSecret } from 'sanity-plugin-preview-auth-validate'\n\n/** @public */\nexport type UsePreviewAuthOptions = {\n\tpreviewOrigin: string\n\tpreviewAuthApi: string\n}\n\n/** @public */\nexport type UsePreviewAuthResult = {\n\tredirectUrl: string | null\n\tfullRedirectUrl: string | null\n\tisAuthenticating: boolean\n\terror: string | null\n\thandleAuthenticate: () => Promise<void>\n}\n\n/** @public */\nexport function usePreviewAuth({\n\tpreviewOrigin,\n\tpreviewAuthApi,\n}: UsePreviewAuthOptions): UsePreviewAuthResult {\n\tconst client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)\n const currentUser = useCurrentUser()\n \n\tconst [isAuthenticating, setIsAuthenticating] = useState(false)\n\tconst [error, setError] = useState<string | null>(null)\n\n\tconst redirectUrl = useMemo(() => {\n\t\tif (typeof window === 'undefined') {\n\t\t\treturn null\n\t\t}\n\n\t\treturn new URLSearchParams(window.location.search).get('redirect')\n\t}, [])\n\n\tconst fullRedirectUrl = useMemo(() => {\n\t\tif (!redirectUrl || !previewOrigin) {\n\t\t\treturn null\n\t\t}\n\n\t\ttry {\n\t\t\treturn new URL(redirectUrl, previewOrigin).toString()\n\t\t} catch {\n\t\t\treturn `${previewOrigin}${redirectUrl}`\n\t\t}\n\t}, [redirectUrl, previewOrigin])\n\n\tconst handleAuthenticate = useCallback(async () => {\n\t\tif (!previewOrigin) {\n\t\t\tsetError('No preview URL configured for this workspace.')\n\n\t\t\treturn\n\t\t}\n\n\t\tsetIsAuthenticating(true)\n\t\tsetError(null)\n\n\t\ttry {\n\t\t\tconst { secret } = await createPreviewSecret({\n\t\t\t\tclient,\n\t\t\t\tsource: 'sanity/preview-auth',\n\t\t\t\tstudioUrl: window.location.href,\n\t\t\t\tuserId: currentUser?.id,\n\t\t\t})\n\n\t\t\tconst apiUrl = new URL(previewAuthApi, previewOrigin)\n\t\t\tconst redirectPath = redirectUrl?.startsWith('/preview')\n\t\t\t\t? redirectUrl.replace(/^\\/preview/, '') || '/'\n\t\t\t\t: redirectUrl || '/'\n\n\t\t\tapiUrl.searchParams.set('sanity-preview-secret', secret)\n\t\t\tapiUrl.searchParams.set('sanity-preview-pathname', redirectPath)\n\t\t\tapiUrl.searchParams.set('response', 'json')\n\n\t\t\tconst response = await fetch(apiUrl, {\n\t\t\t\tmethod: 'GET',\n\t\t\t\tcredentials: 'include',\n\t\t\t\theaders: { Accept: 'application/json' },\n\t\t\t})\n\n\t\t\tconst payload = await response.json().catch(() => null)\n\n\t\t\tif (!response.ok || payload?.ok === false) {\n\t\t\t\tthrow new Error(payload?.error || `Authentication failed (${response.status})`)\n\t\t\t}\n\n\t\t\tif (!payload?.redirectTo) {\n\t\t\t\tthrow new Error('Missing redirect target from authentication response.')\n\t\t\t}\n\n\t\t\twindow.location.href = new URL(payload.redirectTo, previewOrigin).toString()\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to authenticate preview mode.')\n\t\t\tsetIsAuthenticating(false)\n\t\t}\n\t}, [client, currentUser?.id, previewAuthApi, previewOrigin, redirectUrl])\n\n\treturn { redirectUrl, fullRedirectUrl, isAuthenticating, error, handleAuthenticate }\n}\n","import React from 'react'\nimport { Button, Card, Container, Flex, Heading, Stack, Text } from '@sanity/ui'\nimport { EyeOpenIcon } from '@sanity/icons'\nimport { usePreviewAuth } from '../hooks/usePreviewAuth'\n\nexport type PreviewAuthPageProps = {\n\tpreviewOrigin: string\n\tpreviewAuthApi: string\n}\n\nexport function PreviewAuthPage({ previewOrigin, previewAuthApi }: PreviewAuthPageProps) {\n\tconst { redirectUrl, fullRedirectUrl, isAuthenticating, error, handleAuthenticate } =\n\t\tusePreviewAuth({ previewOrigin, previewAuthApi })\n\n\tif (!redirectUrl) {\n\t\treturn (\n\t\t\t<Card height='fill' overflow='auto'>\n\t\t\t\t<Container width={1} padding={5}>\n\t\t\t\t\t<Flex justify='center' align='center' style={{ minHeight: '60vh' }}>\n\t\t\t\t\t\t<Card padding={5} radius={3} shadow={1} style={{ maxWidth: 480, width: '100%' }}>\n\t\t\t\t\t\t\t<Stack space={4} style={{ textAlign: 'center' }}>\n\t\t\t\t\t\t\t\t<Heading size={4}>404</Heading>\n\t\t\t\t\t\t\t\t<Text muted size={2}>\n\t\t\t\t\t\t\t\t\tPreview authentication requires a redirect URL.\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t</Flex>\n\t\t\t\t</Container>\n\t\t\t</Card>\n\t\t)\n\t}\n\n\treturn (\n\t\t<Card height='fill' overflow='auto'>\n\t\t\t<Container width={1} padding={5}>\n\t\t\t\t<Flex justify='center' align='center' style={{ minHeight: '60vh' }}>\n\t\t\t\t\t<Card padding={5} radius={3} shadow={1} style={{ maxWidth: 480, width: '100%' }}>\n\t\t\t\t\t\t<Stack space={5}>\n\t\t\t\t\t\t\t<Flex justify='center'>\n\t\t\t\t\t\t\t\t<Card padding={3} radius='full' tone='primary'>\n\t\t\t\t\t\t\t\t\t<Text size={4}>\n\t\t\t\t\t\t\t\t\t\t<EyeOpenIcon />\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t</Flex>\n\n\t\t\t\t\t\t\t<Stack space={3} style={{ textAlign: 'center' }}>\n\t\t\t\t\t\t\t\t<Heading size={2}>Preview Mode Authentication</Heading>\n\t\t\t\t\t\t\t\t<Text muted size={2}>\n\t\t\t\t\t\t\t\t\tAuthenticate to access the preview environment. This generates a secure token\n\t\t\t\t\t\t\t\t\tvalid for 3 months.\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</Stack>\n\n\t\t\t\t\t\t\t{fullRedirectUrl && (\n\t\t\t\t\t\t\t\t<Card padding={3} radius={2} tone='positive'>\n\t\t\t\t\t\t\t\t\t<Stack space={3}>\n\t\t\t\t\t\t\t\t\t\t<Text size={0} muted weight='medium'>\n\t\t\t\t\t\t\t\t\t\t\tRedirecting to:\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t<Text size={1} style={{ wordBreak: 'break-all' }}>\n\t\t\t\t\t\t\t\t\t\t\t{fullRedirectUrl}\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{error && (\n\t\t\t\t\t\t\t\t<Card padding={3} radius={2} tone='critical'>\n\t\t\t\t\t\t\t\t\t<Text size={1}>{error}</Text>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tfontSize={2}\n\t\t\t\t\t\t\t\tpadding={4}\n\t\t\t\t\t\t\t\ttone='primary'\n\t\t\t\t\t\t\t\tloading={isAuthenticating}\n\t\t\t\t\t\t\t\ttext={isAuthenticating ? 'Authenticating…' : 'Authenticate Preview Mode'}\n\t\t\t\t\t\t\t\ticon={EyeOpenIcon}\n\t\t\t\t\t\t\t\tonClick={handleAuthenticate}\n\t\t\t\t\t\t\t\tdisabled={isAuthenticating || !previewOrigin}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</Stack>\n\t\t\t\t\t</Card>\n\t\t\t\t</Flex>\n\t\t\t</Container>\n\t\t</Card>\n\t)\n}\n","import { defineField, defineType } from 'sanity';\nimport { PREVIEW_AUTH_SECRET_TYPE } from 'sanity-plugin-preview-auth-validate';\n\n/**\n * System document type for long-lived preview auth secrets.\n * Add this to your Sanity schema types array, or use `previewAuthPlugin`\n * which registers it automatically.\n * @public\n */\nexport const previewAuthSecret = defineType({\n name: PREVIEW_AUTH_SECRET_TYPE,\n type: 'document',\n title: 'Preview Auth Secret',\n fields: [\n defineField({ name: 'secret', type: 'string', title: 'Secret' }),\n defineField({ name: 'source', type: 'string', title: 'Source' }),\n defineField({ name: 'studioUrl', type: 'string', title: 'Studio URL' }),\n defineField({ name: 'userId', type: 'string', title: 'User ID' }),\n defineField({ name: 'expiresAt', type: 'datetime', title: 'Expires At' })\n ]\n});\n","import { definePlugin } from 'sanity';\nimport { EyeOpenIcon } from '@sanity/icons';\nimport { PreviewAuthPage } from './components/PreviewAuthPage';\nimport { previewAuthSecret } from './schema/previewAuthSecret';\n\n/** @public */\nexport type PreviewAuthPluginOptions = {\n /** The origin of your preview site, e.g. https://preview.mysite.com */\n previewOrigin: string;\n /** The path to your draft-mode enable API, e.g. /api/draft-mode/enable */\n previewAuthApi: string;\n};\n\nconst TOOL_NAME = 'preview-auth';\n\n/**\n * Sanity Studio plugin for long-lived cross-origin preview authentication.\n *\n * Registers:\n * - A hidden `preview-auth` tool reachable via `/preview-auth?redirect=…`\n * - The `sanity.previewAuthSecret` schema type for long-lived secrets\n * @public\n */\nexport const previewAuthPlugin = definePlugin<PreviewAuthPluginOptions>((options) => ({\n name: TOOL_NAME,\n schema: {\n types: [previewAuthSecret]\n },\n tools: [\n {\n name: TOOL_NAME,\n title: 'Preview Auth',\n icon: EyeOpenIcon,\n component: () => PreviewAuthPage(options)\n }\n ],\n studio: {\n components: {\n toolMenu: (props) => {\n const filteredTools = props.tools.filter((tool) => tool.name !== TOOL_NAME);\n\n return props.renderDefault({ ...props, tools: filteredTools });\n }\n }\n }\n}));\n","import React, { useState } from 'react'\nimport { useClient, useCurrentUser, DEFAULT_STUDIO_CLIENT_OPTIONS } from 'sanity'\nimport type { DocumentActionComponent, DocumentActionProps } from 'sanity'\nimport { EyeOpenIcon } from '@sanity/icons'\nimport { createPreviewSecret } from 'sanity-plugin-preview-auth-validate'\n\n/** @public */\nexport type OpenPreviewActionOptions = {\n\tpreviewOrigin: string\n\t/** Slug field name on route documents (default: 'slug') */\n\tslugField?: string\n}\n\nfunction createAction(options: OpenPreviewActionOptions): DocumentActionComponent {\n\tconst { previewOrigin, slugField = 'slug' } = options\n\n\tconst OpenPreviewAction = (props: DocumentActionProps) => {\n\t\tconst client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)\n\t\tconst currentUser = useCurrentUser()\n\t\tconst [isGenerating, setIsGenerating] = useState(false)\n\n\t\tconst slug = (props.draft ?? props.published)?.[slugField] as\n\t\t\t| { current?: string }\n\t\t\t| string\n\t\t\t| undefined\n\n\t\tconst slugValue = typeof slug === 'string' ? slug : slug?.current\n\n\t\tif (!slugValue) {\n\t\t\treturn null\n\t\t}\n\n\t\treturn {\n\t\t\tlabel: isGenerating ? 'Opening…' : 'Open Preview',\n\t\t\ticon: EyeOpenIcon,\n\t\t\tdisabled: isGenerating,\n\t\t\tonHandle: async () => {\n\t\t\t\tsetIsGenerating(true)\n\n\t\t\t\ttry {\n\t\t\t\t\tconst { secret } = await createPreviewSecret({\n\t\t\t\t\t\tclient,\n\t\t\t\t\t\tsource: 'sanity/open-preview-action',\n\t\t\t\t\t\tstudioUrl: window.location.href,\n\t\t\t\t\t\tuserId: currentUser?.id,\n\t\t\t\t\t})\n\n\t\t\t\t\tconst previewPath = slugValue.startsWith('/') ? slugValue : `/${slugValue}`\n\t\t\t\t\tconst url = new URL(`/preview${previewPath}`, previewOrigin)\n\n\t\t\t\t\turl.searchParams.set('sanity-preview-secret', secret)\n\n\t\t\t\t\twindow.open(url.toString(), '_blank', 'noopener,noreferrer')\n\t\t\t\t} finally {\n\t\t\t\t\tsetIsGenerating(false)\n\t\t\t\t\tprops.onComplete()\n\t\t\t\t}\n\t\t\t},\n\t\t}\n\t}\n\n\tOpenPreviewAction.action = 'openPreview'\n\n\treturn OpenPreviewAction as DocumentActionComponent\n}\n\n/** @public */\nexport function openPreviewAction(options: OpenPreviewActionOptions): DocumentActionComponent {\n\treturn createAction(options)\n}\n"],"names":["useClient","DEFAULT_STUDIO_CLIENT_OPTIONS","useCurrentUser","useState","useMemo","useCallback","createPreviewSecret","jsx","Card","Container","Flex","jsxs","Stack","Text","EyeOpenIcon","Heading","Button","defineType","PREVIEW_AUTH_SECRET_TYPE","defineField","definePlugin"],"mappings":"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"}
@@ -0,0 +1,72 @@
1
+ import type { DocumentActionComponent } from "sanity";
2
+ import { DocumentDefinition } from "sanity";
3
+ import { Plugin as Plugin_2 } from "sanity";
4
+ import { PreviewConfig } from "sanity";
5
+
6
+ /** @public */
7
+ export declare function openPreviewAction(
8
+ options: OpenPreviewActionOptions,
9
+ ): DocumentActionComponent;
10
+
11
+ /** @public */
12
+ export declare type OpenPreviewActionOptions = {
13
+ previewOrigin: string;
14
+ /** Slug field name on route documents (default: 'slug') */
15
+ slugField?: string;
16
+ };
17
+
18
+ /**
19
+ * Sanity Studio plugin for long-lived cross-origin preview authentication.
20
+ *
21
+ * Registers:
22
+ * - A hidden `preview-auth` tool reachable via `/preview-auth?redirect=…`
23
+ * - The `sanity.previewAuthSecret` schema type for long-lived secrets
24
+ * @public
25
+ */
26
+ export declare const previewAuthPlugin: Plugin_2<PreviewAuthPluginOptions>;
27
+
28
+ /** @public */
29
+ export declare type PreviewAuthPluginOptions = {
30
+ /** The origin of your preview site, e.g. https://preview.mysite.com */
31
+ previewOrigin: string;
32
+ /** The path to your draft-mode enable API, e.g. /api/draft-mode/enable */
33
+ previewAuthApi: string;
34
+ };
35
+
36
+ /**
37
+ * System document type for long-lived preview auth secrets.
38
+ * Add this to your Sanity schema types array, or use `previewAuthPlugin`
39
+ * which registers it automatically.
40
+ * @public
41
+ */
42
+ export declare const previewAuthSecret: {
43
+ type: "document";
44
+ name: "sanity.previewAuthSecret";
45
+ } & Omit<DocumentDefinition, "preview"> & {
46
+ preview?:
47
+ | PreviewConfig<Record<string, string>, Record<never, any>>
48
+ | undefined;
49
+ };
50
+
51
+ /** @public */
52
+ export declare function usePreviewAuth({
53
+ previewOrigin,
54
+ previewAuthApi,
55
+ }: UsePreviewAuthOptions): UsePreviewAuthResult;
56
+
57
+ /** @public */
58
+ export declare type UsePreviewAuthOptions = {
59
+ previewOrigin: string;
60
+ previewAuthApi: string;
61
+ };
62
+
63
+ /** @public */
64
+ export declare type UsePreviewAuthResult = {
65
+ redirectUrl: string | null;
66
+ fullRedirectUrl: string | null;
67
+ isAuthenticating: boolean;
68
+ error: string | null;
69
+ handleAuthenticate: () => Promise<void>;
70
+ };
71
+
72
+ export {};
@@ -0,0 +1,72 @@
1
+ import type { DocumentActionComponent } from "sanity";
2
+ import { DocumentDefinition } from "sanity";
3
+ import { Plugin as Plugin_2 } from "sanity";
4
+ import { PreviewConfig } from "sanity";
5
+
6
+ /** @public */
7
+ export declare function openPreviewAction(
8
+ options: OpenPreviewActionOptions,
9
+ ): DocumentActionComponent;
10
+
11
+ /** @public */
12
+ export declare type OpenPreviewActionOptions = {
13
+ previewOrigin: string;
14
+ /** Slug field name on route documents (default: 'slug') */
15
+ slugField?: string;
16
+ };
17
+
18
+ /**
19
+ * Sanity Studio plugin for long-lived cross-origin preview authentication.
20
+ *
21
+ * Registers:
22
+ * - A hidden `preview-auth` tool reachable via `/preview-auth?redirect=…`
23
+ * - The `sanity.previewAuthSecret` schema type for long-lived secrets
24
+ * @public
25
+ */
26
+ export declare const previewAuthPlugin: Plugin_2<PreviewAuthPluginOptions>;
27
+
28
+ /** @public */
29
+ export declare type PreviewAuthPluginOptions = {
30
+ /** The origin of your preview site, e.g. https://preview.mysite.com */
31
+ previewOrigin: string;
32
+ /** The path to your draft-mode enable API, e.g. /api/draft-mode/enable */
33
+ previewAuthApi: string;
34
+ };
35
+
36
+ /**
37
+ * System document type for long-lived preview auth secrets.
38
+ * Add this to your Sanity schema types array, or use `previewAuthPlugin`
39
+ * which registers it automatically.
40
+ * @public
41
+ */
42
+ export declare const previewAuthSecret: {
43
+ type: "document";
44
+ name: "sanity.previewAuthSecret";
45
+ } & Omit<DocumentDefinition, "preview"> & {
46
+ preview?:
47
+ | PreviewConfig<Record<string, string>, Record<never, any>>
48
+ | undefined;
49
+ };
50
+
51
+ /** @public */
52
+ export declare function usePreviewAuth({
53
+ previewOrigin,
54
+ previewAuthApi,
55
+ }: UsePreviewAuthOptions): UsePreviewAuthResult;
56
+
57
+ /** @public */
58
+ export declare type UsePreviewAuthOptions = {
59
+ previewOrigin: string;
60
+ previewAuthApi: string;
61
+ };
62
+
63
+ /** @public */
64
+ export declare type UsePreviewAuthResult = {
65
+ redirectUrl: string | null;
66
+ fullRedirectUrl: string | null;
67
+ isAuthenticating: boolean;
68
+ error: string | null;
69
+ handleAuthenticate: () => Promise<void>;
70
+ };
71
+
72
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,147 @@
1
+ import { useClient, DEFAULT_STUDIO_CLIENT_OPTIONS, useCurrentUser, defineType, defineField, definePlugin } from "sanity";
2
+ import { EyeOpenIcon } from "@sanity/icons";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { Card, Container, Flex, Stack, Text, Heading, Button } from "@sanity/ui";
5
+ import { useState, useMemo, useCallback } from "react";
6
+ import { createPreviewSecret, PREVIEW_AUTH_SECRET_TYPE } from "sanity-plugin-preview-auth-validate";
7
+ function usePreviewAuth({
8
+ previewOrigin,
9
+ previewAuthApi
10
+ }) {
11
+ const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS), currentUser = useCurrentUser(), [isAuthenticating, setIsAuthenticating] = useState(!1), [error, setError] = useState(null), redirectUrl = useMemo(() => typeof window > "u" ? null : new URLSearchParams(window.location.search).get("redirect"), []), fullRedirectUrl = useMemo(() => {
12
+ if (!redirectUrl || !previewOrigin)
13
+ return null;
14
+ try {
15
+ return new URL(redirectUrl, previewOrigin).toString();
16
+ } catch {
17
+ return `${previewOrigin}${redirectUrl}`;
18
+ }
19
+ }, [redirectUrl, previewOrigin]), handleAuthenticate = useCallback(async () => {
20
+ if (!previewOrigin) {
21
+ setError("No preview URL configured for this workspace.");
22
+ return;
23
+ }
24
+ setIsAuthenticating(!0), setError(null);
25
+ try {
26
+ const { secret } = await createPreviewSecret({
27
+ client,
28
+ source: "sanity/preview-auth",
29
+ studioUrl: window.location.href,
30
+ userId: currentUser?.id
31
+ }), apiUrl = new URL(previewAuthApi, previewOrigin), redirectPath = redirectUrl?.startsWith("/preview") ? redirectUrl.replace(/^\/preview/, "") || "/" : redirectUrl || "/";
32
+ apiUrl.searchParams.set("sanity-preview-secret", secret), apiUrl.searchParams.set("sanity-preview-pathname", redirectPath), apiUrl.searchParams.set("response", "json");
33
+ const response = await fetch(apiUrl, {
34
+ method: "GET",
35
+ credentials: "include",
36
+ headers: { Accept: "application/json" }
37
+ }), payload = await response.json().catch(() => null);
38
+ if (!response.ok || payload?.ok === !1)
39
+ throw new Error(payload?.error || `Authentication failed (${response.status})`);
40
+ if (!payload?.redirectTo)
41
+ throw new Error("Missing redirect target from authentication response.");
42
+ window.location.href = new URL(payload.redirectTo, previewOrigin).toString();
43
+ } catch (err) {
44
+ setError(err instanceof Error ? err.message : "Failed to authenticate preview mode."), setIsAuthenticating(!1);
45
+ }
46
+ }, [client, currentUser?.id, previewAuthApi, previewOrigin, redirectUrl]);
47
+ return { redirectUrl, fullRedirectUrl, isAuthenticating, error, handleAuthenticate };
48
+ }
49
+ function PreviewAuthPage({ previewOrigin, previewAuthApi }) {
50
+ const { redirectUrl, fullRedirectUrl, isAuthenticating, error, handleAuthenticate } = usePreviewAuth({ previewOrigin, previewAuthApi });
51
+ return redirectUrl ? /* @__PURE__ */ jsx(Card, { height: "fill", overflow: "auto", children: /* @__PURE__ */ jsx(Container, { width: 1, padding: 5, children: /* @__PURE__ */ jsx(Flex, { justify: "center", align: "center", style: { minHeight: "60vh" }, children: /* @__PURE__ */ jsx(Card, { padding: 5, radius: 3, shadow: 1, style: { maxWidth: 480, width: "100%" }, children: /* @__PURE__ */ jsxs(Stack, { space: 5, children: [
52
+ /* @__PURE__ */ jsx(Flex, { justify: "center", children: /* @__PURE__ */ jsx(Card, { padding: 3, radius: "full", tone: "primary", children: /* @__PURE__ */ jsx(Text, { size: 4, children: /* @__PURE__ */ jsx(EyeOpenIcon, {}) }) }) }),
53
+ /* @__PURE__ */ jsxs(Stack, { space: 3, style: { textAlign: "center" }, children: [
54
+ /* @__PURE__ */ jsx(Heading, { size: 2, children: "Preview Mode Authentication" }),
55
+ /* @__PURE__ */ jsx(Text, { muted: !0, size: 2, children: "Authenticate to access the preview environment. This generates a secure token valid for 3 months." })
56
+ ] }),
57
+ fullRedirectUrl && /* @__PURE__ */ jsx(Card, { padding: 3, radius: 2, tone: "positive", children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
58
+ /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: "Redirecting to:" }),
59
+ /* @__PURE__ */ jsx(Text, { size: 1, style: { wordBreak: "break-all" }, children: fullRedirectUrl })
60
+ ] }) }),
61
+ error && /* @__PURE__ */ jsx(Card, { padding: 3, radius: 2, tone: "critical", children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) }),
62
+ /* @__PURE__ */ jsx(
63
+ Button,
64
+ {
65
+ fontSize: 2,
66
+ padding: 4,
67
+ tone: "primary",
68
+ loading: isAuthenticating,
69
+ text: isAuthenticating ? "Authenticating\u2026" : "Authenticate Preview Mode",
70
+ icon: EyeOpenIcon,
71
+ onClick: handleAuthenticate,
72
+ disabled: isAuthenticating || !previewOrigin
73
+ }
74
+ )
75
+ ] }) }) }) }) }) : /* @__PURE__ */ jsx(Card, { height: "fill", overflow: "auto", children: /* @__PURE__ */ jsx(Container, { width: 1, padding: 5, children: /* @__PURE__ */ jsx(Flex, { justify: "center", align: "center", style: { minHeight: "60vh" }, children: /* @__PURE__ */ jsx(Card, { padding: 5, radius: 3, shadow: 1, style: { maxWidth: 480, width: "100%" }, children: /* @__PURE__ */ jsxs(Stack, { space: 4, style: { textAlign: "center" }, children: [
76
+ /* @__PURE__ */ jsx(Heading, { size: 4, children: "404" }),
77
+ /* @__PURE__ */ jsx(Text, { muted: !0, size: 2, children: "Preview authentication requires a redirect URL." })
78
+ ] }) }) }) }) });
79
+ }
80
+ const previewAuthSecret = defineType({
81
+ name: PREVIEW_AUTH_SECRET_TYPE,
82
+ type: "document",
83
+ title: "Preview Auth Secret",
84
+ fields: [
85
+ defineField({ name: "secret", type: "string", title: "Secret" }),
86
+ defineField({ name: "source", type: "string", title: "Source" }),
87
+ defineField({ name: "studioUrl", type: "string", title: "Studio URL" }),
88
+ defineField({ name: "userId", type: "string", title: "User ID" }),
89
+ defineField({ name: "expiresAt", type: "datetime", title: "Expires At" })
90
+ ]
91
+ }), TOOL_NAME = "preview-auth", previewAuthPlugin = definePlugin((options) => ({
92
+ name: TOOL_NAME,
93
+ schema: {
94
+ types: [previewAuthSecret]
95
+ },
96
+ tools: [
97
+ {
98
+ name: TOOL_NAME,
99
+ title: "Preview Auth",
100
+ icon: EyeOpenIcon,
101
+ component: () => PreviewAuthPage(options)
102
+ }
103
+ ],
104
+ studio: {
105
+ components: {
106
+ toolMenu: (props) => {
107
+ const filteredTools = props.tools.filter((tool) => tool.name !== TOOL_NAME);
108
+ return props.renderDefault({ ...props, tools: filteredTools });
109
+ }
110
+ }
111
+ }
112
+ }));
113
+ function createAction(options) {
114
+ const { previewOrigin, slugField = "slug" } = options, OpenPreviewAction = (props) => {
115
+ const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS), currentUser = useCurrentUser(), [isGenerating, setIsGenerating] = useState(!1), slug = (props.draft ?? props.published)?.[slugField], slugValue = typeof slug == "string" ? slug : slug?.current;
116
+ return slugValue ? {
117
+ label: isGenerating ? "Opening\u2026" : "Open Preview",
118
+ icon: EyeOpenIcon,
119
+ disabled: isGenerating,
120
+ onHandle: async () => {
121
+ setIsGenerating(!0);
122
+ try {
123
+ const { secret } = await createPreviewSecret({
124
+ client,
125
+ source: "sanity/open-preview-action",
126
+ studioUrl: window.location.href,
127
+ userId: currentUser?.id
128
+ }), previewPath = slugValue.startsWith("/") ? slugValue : `/${slugValue}`, url = new URL(`/preview${previewPath}`, previewOrigin);
129
+ url.searchParams.set("sanity-preview-secret", secret), window.open(url.toString(), "_blank", "noopener,noreferrer");
130
+ } finally {
131
+ setIsGenerating(!1), props.onComplete();
132
+ }
133
+ }
134
+ } : null;
135
+ };
136
+ return OpenPreviewAction.action = "openPreview", OpenPreviewAction;
137
+ }
138
+ function openPreviewAction(options) {
139
+ return createAction(options);
140
+ }
141
+ export {
142
+ openPreviewAction,
143
+ previewAuthPlugin,
144
+ previewAuthSecret,
145
+ usePreviewAuth
146
+ };
147
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../src/hooks/usePreviewAuth.ts","../src/components/PreviewAuthPage.tsx","../src/schema/previewAuthSecret.ts","../src/plugin.ts","../src/actions/openPreviewAction.ts"],"sourcesContent":["import { useState, useCallback, useMemo } from 'react'\nimport { useClient, useCurrentUser, DEFAULT_STUDIO_CLIENT_OPTIONS } from 'sanity'\nimport { createPreviewSecret } from 'sanity-plugin-preview-auth-validate'\n\n/** @public */\nexport type UsePreviewAuthOptions = {\n\tpreviewOrigin: string\n\tpreviewAuthApi: string\n}\n\n/** @public */\nexport type UsePreviewAuthResult = {\n\tredirectUrl: string | null\n\tfullRedirectUrl: string | null\n\tisAuthenticating: boolean\n\terror: string | null\n\thandleAuthenticate: () => Promise<void>\n}\n\n/** @public */\nexport function usePreviewAuth({\n\tpreviewOrigin,\n\tpreviewAuthApi,\n}: UsePreviewAuthOptions): UsePreviewAuthResult {\n\tconst client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)\n const currentUser = useCurrentUser()\n \n\tconst [isAuthenticating, setIsAuthenticating] = useState(false)\n\tconst [error, setError] = useState<string | null>(null)\n\n\tconst redirectUrl = useMemo(() => {\n\t\tif (typeof window === 'undefined') {\n\t\t\treturn null\n\t\t}\n\n\t\treturn new URLSearchParams(window.location.search).get('redirect')\n\t}, [])\n\n\tconst fullRedirectUrl = useMemo(() => {\n\t\tif (!redirectUrl || !previewOrigin) {\n\t\t\treturn null\n\t\t}\n\n\t\ttry {\n\t\t\treturn new URL(redirectUrl, previewOrigin).toString()\n\t\t} catch {\n\t\t\treturn `${previewOrigin}${redirectUrl}`\n\t\t}\n\t}, [redirectUrl, previewOrigin])\n\n\tconst handleAuthenticate = useCallback(async () => {\n\t\tif (!previewOrigin) {\n\t\t\tsetError('No preview URL configured for this workspace.')\n\n\t\t\treturn\n\t\t}\n\n\t\tsetIsAuthenticating(true)\n\t\tsetError(null)\n\n\t\ttry {\n\t\t\tconst { secret } = await createPreviewSecret({\n\t\t\t\tclient,\n\t\t\t\tsource: 'sanity/preview-auth',\n\t\t\t\tstudioUrl: window.location.href,\n\t\t\t\tuserId: currentUser?.id,\n\t\t\t})\n\n\t\t\tconst apiUrl = new URL(previewAuthApi, previewOrigin)\n\t\t\tconst redirectPath = redirectUrl?.startsWith('/preview')\n\t\t\t\t? redirectUrl.replace(/^\\/preview/, '') || '/'\n\t\t\t\t: redirectUrl || '/'\n\n\t\t\tapiUrl.searchParams.set('sanity-preview-secret', secret)\n\t\t\tapiUrl.searchParams.set('sanity-preview-pathname', redirectPath)\n\t\t\tapiUrl.searchParams.set('response', 'json')\n\n\t\t\tconst response = await fetch(apiUrl, {\n\t\t\t\tmethod: 'GET',\n\t\t\t\tcredentials: 'include',\n\t\t\t\theaders: { Accept: 'application/json' },\n\t\t\t})\n\n\t\t\tconst payload = await response.json().catch(() => null)\n\n\t\t\tif (!response.ok || payload?.ok === false) {\n\t\t\t\tthrow new Error(payload?.error || `Authentication failed (${response.status})`)\n\t\t\t}\n\n\t\t\tif (!payload?.redirectTo) {\n\t\t\t\tthrow new Error('Missing redirect target from authentication response.')\n\t\t\t}\n\n\t\t\twindow.location.href = new URL(payload.redirectTo, previewOrigin).toString()\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : 'Failed to authenticate preview mode.')\n\t\t\tsetIsAuthenticating(false)\n\t\t}\n\t}, [client, currentUser?.id, previewAuthApi, previewOrigin, redirectUrl])\n\n\treturn { redirectUrl, fullRedirectUrl, isAuthenticating, error, handleAuthenticate }\n}\n","import React from 'react'\nimport { Button, Card, Container, Flex, Heading, Stack, Text } from '@sanity/ui'\nimport { EyeOpenIcon } from '@sanity/icons'\nimport { usePreviewAuth } from '../hooks/usePreviewAuth'\n\nexport type PreviewAuthPageProps = {\n\tpreviewOrigin: string\n\tpreviewAuthApi: string\n}\n\nexport function PreviewAuthPage({ previewOrigin, previewAuthApi }: PreviewAuthPageProps) {\n\tconst { redirectUrl, fullRedirectUrl, isAuthenticating, error, handleAuthenticate } =\n\t\tusePreviewAuth({ previewOrigin, previewAuthApi })\n\n\tif (!redirectUrl) {\n\t\treturn (\n\t\t\t<Card height='fill' overflow='auto'>\n\t\t\t\t<Container width={1} padding={5}>\n\t\t\t\t\t<Flex justify='center' align='center' style={{ minHeight: '60vh' }}>\n\t\t\t\t\t\t<Card padding={5} radius={3} shadow={1} style={{ maxWidth: 480, width: '100%' }}>\n\t\t\t\t\t\t\t<Stack space={4} style={{ textAlign: 'center' }}>\n\t\t\t\t\t\t\t\t<Heading size={4}>404</Heading>\n\t\t\t\t\t\t\t\t<Text muted size={2}>\n\t\t\t\t\t\t\t\t\tPreview authentication requires a redirect URL.\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t</Card>\n\t\t\t\t\t</Flex>\n\t\t\t\t</Container>\n\t\t\t</Card>\n\t\t)\n\t}\n\n\treturn (\n\t\t<Card height='fill' overflow='auto'>\n\t\t\t<Container width={1} padding={5}>\n\t\t\t\t<Flex justify='center' align='center' style={{ minHeight: '60vh' }}>\n\t\t\t\t\t<Card padding={5} radius={3} shadow={1} style={{ maxWidth: 480, width: '100%' }}>\n\t\t\t\t\t\t<Stack space={5}>\n\t\t\t\t\t\t\t<Flex justify='center'>\n\t\t\t\t\t\t\t\t<Card padding={3} radius='full' tone='primary'>\n\t\t\t\t\t\t\t\t\t<Text size={4}>\n\t\t\t\t\t\t\t\t\t\t<EyeOpenIcon />\n\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t</Flex>\n\n\t\t\t\t\t\t\t<Stack space={3} style={{ textAlign: 'center' }}>\n\t\t\t\t\t\t\t\t<Heading size={2}>Preview Mode Authentication</Heading>\n\t\t\t\t\t\t\t\t<Text muted size={2}>\n\t\t\t\t\t\t\t\t\tAuthenticate to access the preview environment. This generates a secure token\n\t\t\t\t\t\t\t\t\tvalid for 3 months.\n\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t</Stack>\n\n\t\t\t\t\t\t\t{fullRedirectUrl && (\n\t\t\t\t\t\t\t\t<Card padding={3} radius={2} tone='positive'>\n\t\t\t\t\t\t\t\t\t<Stack space={3}>\n\t\t\t\t\t\t\t\t\t\t<Text size={0} muted weight='medium'>\n\t\t\t\t\t\t\t\t\t\t\tRedirecting to:\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t\t<Text size={1} style={{ wordBreak: 'break-all' }}>\n\t\t\t\t\t\t\t\t\t\t\t{fullRedirectUrl}\n\t\t\t\t\t\t\t\t\t\t</Text>\n\t\t\t\t\t\t\t\t\t</Stack>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{error && (\n\t\t\t\t\t\t\t\t<Card padding={3} radius={2} tone='critical'>\n\t\t\t\t\t\t\t\t\t<Text size={1}>{error}</Text>\n\t\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tfontSize={2}\n\t\t\t\t\t\t\t\tpadding={4}\n\t\t\t\t\t\t\t\ttone='primary'\n\t\t\t\t\t\t\t\tloading={isAuthenticating}\n\t\t\t\t\t\t\t\ttext={isAuthenticating ? 'Authenticating…' : 'Authenticate Preview Mode'}\n\t\t\t\t\t\t\t\ticon={EyeOpenIcon}\n\t\t\t\t\t\t\t\tonClick={handleAuthenticate}\n\t\t\t\t\t\t\t\tdisabled={isAuthenticating || !previewOrigin}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</Stack>\n\t\t\t\t\t</Card>\n\t\t\t\t</Flex>\n\t\t\t</Container>\n\t\t</Card>\n\t)\n}\n","import { defineField, defineType } from 'sanity';\nimport { PREVIEW_AUTH_SECRET_TYPE } from 'sanity-plugin-preview-auth-validate';\n\n/**\n * System document type for long-lived preview auth secrets.\n * Add this to your Sanity schema types array, or use `previewAuthPlugin`\n * which registers it automatically.\n * @public\n */\nexport const previewAuthSecret = defineType({\n name: PREVIEW_AUTH_SECRET_TYPE,\n type: 'document',\n title: 'Preview Auth Secret',\n fields: [\n defineField({ name: 'secret', type: 'string', title: 'Secret' }),\n defineField({ name: 'source', type: 'string', title: 'Source' }),\n defineField({ name: 'studioUrl', type: 'string', title: 'Studio URL' }),\n defineField({ name: 'userId', type: 'string', title: 'User ID' }),\n defineField({ name: 'expiresAt', type: 'datetime', title: 'Expires At' })\n ]\n});\n","import { definePlugin } from 'sanity';\nimport { EyeOpenIcon } from '@sanity/icons';\nimport { PreviewAuthPage } from './components/PreviewAuthPage';\nimport { previewAuthSecret } from './schema/previewAuthSecret';\n\n/** @public */\nexport type PreviewAuthPluginOptions = {\n /** The origin of your preview site, e.g. https://preview.mysite.com */\n previewOrigin: string;\n /** The path to your draft-mode enable API, e.g. /api/draft-mode/enable */\n previewAuthApi: string;\n};\n\nconst TOOL_NAME = 'preview-auth';\n\n/**\n * Sanity Studio plugin for long-lived cross-origin preview authentication.\n *\n * Registers:\n * - A hidden `preview-auth` tool reachable via `/preview-auth?redirect=…`\n * - The `sanity.previewAuthSecret` schema type for long-lived secrets\n * @public\n */\nexport const previewAuthPlugin = definePlugin<PreviewAuthPluginOptions>((options) => ({\n name: TOOL_NAME,\n schema: {\n types: [previewAuthSecret]\n },\n tools: [\n {\n name: TOOL_NAME,\n title: 'Preview Auth',\n icon: EyeOpenIcon,\n component: () => PreviewAuthPage(options)\n }\n ],\n studio: {\n components: {\n toolMenu: (props) => {\n const filteredTools = props.tools.filter((tool) => tool.name !== TOOL_NAME);\n\n return props.renderDefault({ ...props, tools: filteredTools });\n }\n }\n }\n}));\n","import React, { useState } from 'react'\nimport { useClient, useCurrentUser, DEFAULT_STUDIO_CLIENT_OPTIONS } from 'sanity'\nimport type { DocumentActionComponent, DocumentActionProps } from 'sanity'\nimport { EyeOpenIcon } from '@sanity/icons'\nimport { createPreviewSecret } from 'sanity-plugin-preview-auth-validate'\n\n/** @public */\nexport type OpenPreviewActionOptions = {\n\tpreviewOrigin: string\n\t/** Slug field name on route documents (default: 'slug') */\n\tslugField?: string\n}\n\nfunction createAction(options: OpenPreviewActionOptions): DocumentActionComponent {\n\tconst { previewOrigin, slugField = 'slug' } = options\n\n\tconst OpenPreviewAction = (props: DocumentActionProps) => {\n\t\tconst client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)\n\t\tconst currentUser = useCurrentUser()\n\t\tconst [isGenerating, setIsGenerating] = useState(false)\n\n\t\tconst slug = (props.draft ?? props.published)?.[slugField] as\n\t\t\t| { current?: string }\n\t\t\t| string\n\t\t\t| undefined\n\n\t\tconst slugValue = typeof slug === 'string' ? slug : slug?.current\n\n\t\tif (!slugValue) {\n\t\t\treturn null\n\t\t}\n\n\t\treturn {\n\t\t\tlabel: isGenerating ? 'Opening…' : 'Open Preview',\n\t\t\ticon: EyeOpenIcon,\n\t\t\tdisabled: isGenerating,\n\t\t\tonHandle: async () => {\n\t\t\t\tsetIsGenerating(true)\n\n\t\t\t\ttry {\n\t\t\t\t\tconst { secret } = await createPreviewSecret({\n\t\t\t\t\t\tclient,\n\t\t\t\t\t\tsource: 'sanity/open-preview-action',\n\t\t\t\t\t\tstudioUrl: window.location.href,\n\t\t\t\t\t\tuserId: currentUser?.id,\n\t\t\t\t\t})\n\n\t\t\t\t\tconst previewPath = slugValue.startsWith('/') ? slugValue : `/${slugValue}`\n\t\t\t\t\tconst url = new URL(`/preview${previewPath}`, previewOrigin)\n\n\t\t\t\t\turl.searchParams.set('sanity-preview-secret', secret)\n\n\t\t\t\t\twindow.open(url.toString(), '_blank', 'noopener,noreferrer')\n\t\t\t\t} finally {\n\t\t\t\t\tsetIsGenerating(false)\n\t\t\t\t\tprops.onComplete()\n\t\t\t\t}\n\t\t\t},\n\t\t}\n\t}\n\n\tOpenPreviewAction.action = 'openPreview'\n\n\treturn OpenPreviewAction as DocumentActionComponent\n}\n\n/** @public */\nexport function openPreviewAction(options: OpenPreviewActionOptions): DocumentActionComponent {\n\treturn createAction(options)\n}\n"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "sanity-plugin-preview-auth",
3
+ "version": "0.1.0",
4
+ "description": "Sanity Studio plugin for long-lived cross-origin preview authentication",
5
+ "keywords": [
6
+ "sanity",
7
+ "sanity-plugin",
8
+ "preview",
9
+ "authentication",
10
+ "draft-mode"
11
+ ],
12
+ "homepage": "https://github.com/ameenaburayya/sanity-plugin-preview-auth",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/ameenaburayya/sanity-plugin-preview-auth.git",
16
+ "directory": "packages/plugin"
17
+ },
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "main": "./dist/index.cjs",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "source": "./src/index.ts",
26
+ "import": "./dist/index.js",
27
+ "require": "./dist/index.cjs",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "sideEffects": false,
33
+ "browserslist": "extends @sanity/browserslist-config",
34
+ "files": [
35
+ "dist",
36
+ "src"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "scripts": {
42
+ "build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean",
43
+ "watch": "pkg-utils watch --strict",
44
+ "link-watch": "plugin-kit link-watch",
45
+ "typecheck": "tsc --noEmit",
46
+ "prepublishOnly": "pnpm run build"
47
+ },
48
+ "sanityPlugin": {
49
+ "verifyPackage": {
50
+ "sanityV2Json": false,
51
+ "eslintImports": false
52
+ }
53
+ },
54
+ "dependencies": {
55
+ "sanity-plugin-preview-auth-validate": "workspace:*"
56
+ },
57
+ "peerDependencies": {
58
+ "@sanity/icons": ">=3.0.0",
59
+ "@sanity/ui": ">=2.0.0",
60
+ "react": ">=18.0.0",
61
+ "sanity": ">=3.0.0"
62
+ },
63
+ "devDependencies": {
64
+ "@sanity/browserslist-config": "^1.0.5",
65
+ "@sanity/icons": "^3.0.0",
66
+ "@sanity/pkg-utils": "^6.0.0",
67
+ "@sanity/plugin-kit": "^4.0.0",
68
+ "@sanity/ui": "^2.0.0",
69
+ "@types/react": "^18.0.0",
70
+ "react": "^18.0.0",
71
+ "sanity": "^5.0.0",
72
+ "typescript": "^5.0.0"
73
+ }
74
+ }
@@ -0,0 +1,70 @@
1
+ import React, { useState } from 'react'
2
+ import { useClient, useCurrentUser, DEFAULT_STUDIO_CLIENT_OPTIONS } from 'sanity'
3
+ import type { DocumentActionComponent, DocumentActionProps } from 'sanity'
4
+ import { EyeOpenIcon } from '@sanity/icons'
5
+ import { createPreviewSecret } from 'sanity-plugin-preview-auth-validate'
6
+
7
+ /** @public */
8
+ export type OpenPreviewActionOptions = {
9
+ previewOrigin: string
10
+ /** Slug field name on route documents (default: 'slug') */
11
+ slugField?: string
12
+ }
13
+
14
+ function createAction(options: OpenPreviewActionOptions): DocumentActionComponent {
15
+ const { previewOrigin, slugField = 'slug' } = options
16
+
17
+ const OpenPreviewAction = (props: DocumentActionProps) => {
18
+ const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)
19
+ const currentUser = useCurrentUser()
20
+ const [isGenerating, setIsGenerating] = useState(false)
21
+
22
+ const slug = (props.draft ?? props.published)?.[slugField] as
23
+ | { current?: string }
24
+ | string
25
+ | undefined
26
+
27
+ const slugValue = typeof slug === 'string' ? slug : slug?.current
28
+
29
+ if (!slugValue) {
30
+ return null
31
+ }
32
+
33
+ return {
34
+ label: isGenerating ? 'Opening…' : 'Open Preview',
35
+ icon: EyeOpenIcon,
36
+ disabled: isGenerating,
37
+ onHandle: async () => {
38
+ setIsGenerating(true)
39
+
40
+ try {
41
+ const { secret } = await createPreviewSecret({
42
+ client,
43
+ source: 'sanity/open-preview-action',
44
+ studioUrl: window.location.href,
45
+ userId: currentUser?.id,
46
+ })
47
+
48
+ const previewPath = slugValue.startsWith('/') ? slugValue : `/${slugValue}`
49
+ const url = new URL(`/preview${previewPath}`, previewOrigin)
50
+
51
+ url.searchParams.set('sanity-preview-secret', secret)
52
+
53
+ window.open(url.toString(), '_blank', 'noopener,noreferrer')
54
+ } finally {
55
+ setIsGenerating(false)
56
+ props.onComplete()
57
+ }
58
+ },
59
+ }
60
+ }
61
+
62
+ OpenPreviewAction.action = 'openPreview'
63
+
64
+ return OpenPreviewAction as DocumentActionComponent
65
+ }
66
+
67
+ /** @public */
68
+ export function openPreviewAction(options: OpenPreviewActionOptions): DocumentActionComponent {
69
+ return createAction(options)
70
+ }
@@ -0,0 +1,91 @@
1
+ import React from 'react'
2
+ import { Button, Card, Container, Flex, Heading, Stack, Text } from '@sanity/ui'
3
+ import { EyeOpenIcon } from '@sanity/icons'
4
+ import { usePreviewAuth } from '../hooks/usePreviewAuth'
5
+
6
+ export type PreviewAuthPageProps = {
7
+ previewOrigin: string
8
+ previewAuthApi: string
9
+ }
10
+
11
+ export function PreviewAuthPage({ previewOrigin, previewAuthApi }: PreviewAuthPageProps) {
12
+ const { redirectUrl, fullRedirectUrl, isAuthenticating, error, handleAuthenticate } =
13
+ usePreviewAuth({ previewOrigin, previewAuthApi })
14
+
15
+ if (!redirectUrl) {
16
+ return (
17
+ <Card height='fill' overflow='auto'>
18
+ <Container width={1} padding={5}>
19
+ <Flex justify='center' align='center' style={{ minHeight: '60vh' }}>
20
+ <Card padding={5} radius={3} shadow={1} style={{ maxWidth: 480, width: '100%' }}>
21
+ <Stack space={4} style={{ textAlign: 'center' }}>
22
+ <Heading size={4}>404</Heading>
23
+ <Text muted size={2}>
24
+ Preview authentication requires a redirect URL.
25
+ </Text>
26
+ </Stack>
27
+ </Card>
28
+ </Flex>
29
+ </Container>
30
+ </Card>
31
+ )
32
+ }
33
+
34
+ return (
35
+ <Card height='fill' overflow='auto'>
36
+ <Container width={1} padding={5}>
37
+ <Flex justify='center' align='center' style={{ minHeight: '60vh' }}>
38
+ <Card padding={5} radius={3} shadow={1} style={{ maxWidth: 480, width: '100%' }}>
39
+ <Stack space={5}>
40
+ <Flex justify='center'>
41
+ <Card padding={3} radius='full' tone='primary'>
42
+ <Text size={4}>
43
+ <EyeOpenIcon />
44
+ </Text>
45
+ </Card>
46
+ </Flex>
47
+
48
+ <Stack space={3} style={{ textAlign: 'center' }}>
49
+ <Heading size={2}>Preview Mode Authentication</Heading>
50
+ <Text muted size={2}>
51
+ Authenticate to access the preview environment. This generates a secure token
52
+ valid for 3 months.
53
+ </Text>
54
+ </Stack>
55
+
56
+ {fullRedirectUrl && (
57
+ <Card padding={3} radius={2} tone='positive'>
58
+ <Stack space={3}>
59
+ <Text size={0} muted weight='medium'>
60
+ Redirecting to:
61
+ </Text>
62
+ <Text size={1} style={{ wordBreak: 'break-all' }}>
63
+ {fullRedirectUrl}
64
+ </Text>
65
+ </Stack>
66
+ </Card>
67
+ )}
68
+
69
+ {error && (
70
+ <Card padding={3} radius={2} tone='critical'>
71
+ <Text size={1}>{error}</Text>
72
+ </Card>
73
+ )}
74
+
75
+ <Button
76
+ fontSize={2}
77
+ padding={4}
78
+ tone='primary'
79
+ loading={isAuthenticating}
80
+ text={isAuthenticating ? 'Authenticating…' : 'Authenticate Preview Mode'}
81
+ icon={EyeOpenIcon}
82
+ onClick={handleAuthenticate}
83
+ disabled={isAuthenticating || !previewOrigin}
84
+ />
85
+ </Stack>
86
+ </Card>
87
+ </Flex>
88
+ </Container>
89
+ </Card>
90
+ )
91
+ }
@@ -0,0 +1,102 @@
1
+ import { useState, useCallback, useMemo } from 'react'
2
+ import { useClient, useCurrentUser, DEFAULT_STUDIO_CLIENT_OPTIONS } from 'sanity'
3
+ import { createPreviewSecret } from 'sanity-plugin-preview-auth-validate'
4
+
5
+ /** @public */
6
+ export type UsePreviewAuthOptions = {
7
+ previewOrigin: string
8
+ previewAuthApi: string
9
+ }
10
+
11
+ /** @public */
12
+ export type UsePreviewAuthResult = {
13
+ redirectUrl: string | null
14
+ fullRedirectUrl: string | null
15
+ isAuthenticating: boolean
16
+ error: string | null
17
+ handleAuthenticate: () => Promise<void>
18
+ }
19
+
20
+ /** @public */
21
+ export function usePreviewAuth({
22
+ previewOrigin,
23
+ previewAuthApi,
24
+ }: UsePreviewAuthOptions): UsePreviewAuthResult {
25
+ const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)
26
+ const currentUser = useCurrentUser()
27
+
28
+ const [isAuthenticating, setIsAuthenticating] = useState(false)
29
+ const [error, setError] = useState<string | null>(null)
30
+
31
+ const redirectUrl = useMemo(() => {
32
+ if (typeof window === 'undefined') {
33
+ return null
34
+ }
35
+
36
+ return new URLSearchParams(window.location.search).get('redirect')
37
+ }, [])
38
+
39
+ const fullRedirectUrl = useMemo(() => {
40
+ if (!redirectUrl || !previewOrigin) {
41
+ return null
42
+ }
43
+
44
+ try {
45
+ return new URL(redirectUrl, previewOrigin).toString()
46
+ } catch {
47
+ return `${previewOrigin}${redirectUrl}`
48
+ }
49
+ }, [redirectUrl, previewOrigin])
50
+
51
+ const handleAuthenticate = useCallback(async () => {
52
+ if (!previewOrigin) {
53
+ setError('No preview URL configured for this workspace.')
54
+
55
+ return
56
+ }
57
+
58
+ setIsAuthenticating(true)
59
+ setError(null)
60
+
61
+ try {
62
+ const { secret } = await createPreviewSecret({
63
+ client,
64
+ source: 'sanity/preview-auth',
65
+ studioUrl: window.location.href,
66
+ userId: currentUser?.id,
67
+ })
68
+
69
+ const apiUrl = new URL(previewAuthApi, previewOrigin)
70
+ const redirectPath = redirectUrl?.startsWith('/preview')
71
+ ? redirectUrl.replace(/^\/preview/, '') || '/'
72
+ : redirectUrl || '/'
73
+
74
+ apiUrl.searchParams.set('sanity-preview-secret', secret)
75
+ apiUrl.searchParams.set('sanity-preview-pathname', redirectPath)
76
+ apiUrl.searchParams.set('response', 'json')
77
+
78
+ const response = await fetch(apiUrl, {
79
+ method: 'GET',
80
+ credentials: 'include',
81
+ headers: { Accept: 'application/json' },
82
+ })
83
+
84
+ const payload = await response.json().catch(() => null)
85
+
86
+ if (!response.ok || payload?.ok === false) {
87
+ throw new Error(payload?.error || `Authentication failed (${response.status})`)
88
+ }
89
+
90
+ if (!payload?.redirectTo) {
91
+ throw new Error('Missing redirect target from authentication response.')
92
+ }
93
+
94
+ window.location.href = new URL(payload.redirectTo, previewOrigin).toString()
95
+ } catch (err) {
96
+ setError(err instanceof Error ? err.message : 'Failed to authenticate preview mode.')
97
+ setIsAuthenticating(false)
98
+ }
99
+ }, [client, currentUser?.id, previewAuthApi, previewOrigin, redirectUrl])
100
+
101
+ return { redirectUrl, fullRedirectUrl, isAuthenticating, error, handleAuthenticate }
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { previewAuthPlugin } from './plugin';
2
+ export type { PreviewAuthPluginOptions } from './plugin';
3
+ export { previewAuthSecret } from './schema/previewAuthSecret';
4
+ export { usePreviewAuth } from './hooks/usePreviewAuth';
5
+ export type { UsePreviewAuthOptions, UsePreviewAuthResult } from './hooks/usePreviewAuth';
6
+ export { openPreviewAction } from './actions/openPreviewAction';
7
+ export type { OpenPreviewActionOptions } from './actions/openPreviewAction';
package/src/plugin.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { definePlugin } from 'sanity';
2
+ import { EyeOpenIcon } from '@sanity/icons';
3
+ import { PreviewAuthPage } from './components/PreviewAuthPage';
4
+ import { previewAuthSecret } from './schema/previewAuthSecret';
5
+
6
+ /** @public */
7
+ export type PreviewAuthPluginOptions = {
8
+ /** The origin of your preview site, e.g. https://preview.mysite.com */
9
+ previewOrigin: string;
10
+ /** The path to your draft-mode enable API, e.g. /api/draft-mode/enable */
11
+ previewAuthApi: string;
12
+ };
13
+
14
+ const TOOL_NAME = 'preview-auth';
15
+
16
+ /**
17
+ * Sanity Studio plugin for long-lived cross-origin preview authentication.
18
+ *
19
+ * Registers:
20
+ * - A hidden `preview-auth` tool reachable via `/preview-auth?redirect=…`
21
+ * - The `sanity.previewAuthSecret` schema type for long-lived secrets
22
+ * @public
23
+ */
24
+ export const previewAuthPlugin = definePlugin<PreviewAuthPluginOptions>((options) => ({
25
+ name: TOOL_NAME,
26
+ schema: {
27
+ types: [previewAuthSecret]
28
+ },
29
+ tools: [
30
+ {
31
+ name: TOOL_NAME,
32
+ title: 'Preview Auth',
33
+ icon: EyeOpenIcon,
34
+ component: () => PreviewAuthPage(options)
35
+ }
36
+ ],
37
+ studio: {
38
+ components: {
39
+ toolMenu: (props) => {
40
+ const filteredTools = props.tools.filter((tool) => tool.name !== TOOL_NAME);
41
+
42
+ return props.renderDefault({ ...props, tools: filteredTools });
43
+ }
44
+ }
45
+ }
46
+ }));
@@ -0,0 +1,21 @@
1
+ import { defineField, defineType } from 'sanity';
2
+ import { PREVIEW_AUTH_SECRET_TYPE } from 'sanity-plugin-preview-auth-validate';
3
+
4
+ /**
5
+ * System document type for long-lived preview auth secrets.
6
+ * Add this to your Sanity schema types array, or use `previewAuthPlugin`
7
+ * which registers it automatically.
8
+ * @public
9
+ */
10
+ export const previewAuthSecret = defineType({
11
+ name: PREVIEW_AUTH_SECRET_TYPE,
12
+ type: 'document',
13
+ title: 'Preview Auth Secret',
14
+ fields: [
15
+ defineField({ name: 'secret', type: 'string', title: 'Secret' }),
16
+ defineField({ name: 'source', type: 'string', title: 'Source' }),
17
+ defineField({ name: 'studioUrl', type: 'string', title: 'Studio URL' }),
18
+ defineField({ name: 'userId', type: 'string', title: 'User ID' }),
19
+ defineField({ name: 'expiresAt', type: 'datetime', title: 'Expires At' })
20
+ ]
21
+ });