settings: save s3 settings to %s3-store

This commit is contained in:
James Acklin 2022-10-20 19:51:00 -04:00
parent 279a6a3644
commit 0ed2432037
7 changed files with 297 additions and 243 deletions

22
ui/package-lock.json generated
View File

@ -42,6 +42,7 @@
"react-dnd-touch-backend": "^15.1.1",
"react-dom": "^17.0.2",
"react-error-boundary": "^3.1.3",
"react-hook-form": "^7.38.0",
"react-router-dom": "^5.2.0",
"slugify": "^1.6.0",
"urbit-ob": "^5.0.1",
@ -6285,6 +6286,21 @@
"react": ">=16.13.1"
}
},
"node_modules/react-hook-form": {
"version": "7.38.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.38.0.tgz",
"integrity": "sha512-gxWW1kMeru9xR1GoR+Iw4hA+JBOM3SHfr4DWCUKY0xc7Vv1MLsF109oHtBeWl9shcyPFx67KHru44DheN0XY5A==",
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"license": "MIT"
@ -11775,6 +11791,12 @@
"@babel/runtime": "^7.12.5"
}
},
"react-hook-form": {
"version": "7.38.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.38.0.tgz",
"integrity": "sha512-gxWW1kMeru9xR1GoR+Iw4hA+JBOM3SHfr4DWCUKY0xc7Vv1MLsF109oHtBeWl9shcyPFx67KHru44DheN0XY5A==",
"requires": {}
},
"react-is": {
"version": "16.13.1"
},

View File

@ -49,6 +49,7 @@
"react-dnd-touch-backend": "^15.1.1",
"react-dom": "^17.0.2",
"react-error-boundary": "^3.1.3",
"react-hook-form": "^7.38.0",
"react-router-dom": "^5.2.0",
"slugify": "^1.6.0",
"urbit-ob": "^5.0.1",

View File

@ -2,22 +2,21 @@
/* tslint:disable */
/**
* Mock Service Worker (0.39.2).
* Mock Service Worker (0.47.4).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929'
const bypassHeaderName = 'x-msw-bypass'
const INTEGRITY_CHECKSUM = 'b3066ef78c2f9090b4ce87e874965995'
const activeClientIds = new Set()
self.addEventListener('install', function () {
return self.skipWaiting()
self.skipWaiting()
})
self.addEventListener('activate', async function (event) {
return self.clients.claim()
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
@ -33,7 +32,9 @@ self.addEventListener('message', async function (event) {
return
}
const allClients = await self.clients.matchAll()
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
@ -83,161 +84,6 @@ self.addEventListener('message', async function (event) {
}
})
// Resolve the "main" client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll()
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: serializeHeaders(clonedResponse.headers),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
async function getResponse(event, client, requestId) {
const { request } = event
const requestClone = request.clone()
const getOriginalResponse = () => fetch(requestClone)
// Bypass mocking when the request client is not active.
if (!client) {
return getOriginalResponse()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return await getOriginalResponse()
}
// Bypass requests with the explicit bypass header
if (requestClone.headers.get(bypassHeaderName) === 'true') {
const cleanRequestHeaders = serializeHeaders(requestClone.headers)
// Remove the bypass header to comply with the CORS preflight check.
delete cleanRequestHeaders[bypassHeaderName]
const originalRequest = new Request(requestClone, {
headers: new Headers(cleanRequestHeaders),
})
return fetch(originalRequest)
}
// Send the request to the client-side MSW.
const reqHeaders = serializeHeaders(request.headers)
const body = await request.text()
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: reqHeaders,
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body,
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_SUCCESS': {
return delayPromise(
() => respondWithMock(clientMessage),
clientMessage.payload.delay,
)
}
case 'MOCK_NOT_FOUND': {
return getOriginalResponse()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.payload
const networkError = new Error(message)
networkError.name = name
// Rejecting a request Promise emulates a network error.
throw networkError
}
case 'INTERNAL_ERROR': {
const parsedBody = JSON.parse(clientMessage.payload.body)
console.error(
`\
[MSW] Uncaught exception in the request handler for "%s %s":
${parsedBody.location}
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
`,
request.method,
request.url,
)
return respondWithMock(clientMessage)
}
}
return getOriginalResponse()
}
self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
@ -265,9 +111,10 @@ self.addEventListener('fetch', function (event) {
return
}
const requestId = uuidv4()
// Generate unique request ID.
const requestId = Math.random().toString(16).slice(2)
return event.respondWith(
event.respondWith(
handleRequest(event, requestId).catch((error) => {
if (error.name === 'NetworkError') {
console.warn(
@ -290,14 +137,142 @@ self.addEventListener('fetch', function (event) {
)
})
function serializeHeaders(headers) {
const reqHeaders = {}
headers.forEach((value, name) => {
reqHeaders[name] = reqHeaders[name]
? [].concat(reqHeaders[name]).concat(value)
: value
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: Object.fromEntries(clonedResponse.headers.entries()),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return reqHeaders
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
const clonedRequest = request.clone()
function passthrough() {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const headers = Object.fromEntries(clonedRequest.headers.entries())
// Remove MSW-specific request headers so the bypassed requests
// comply with the server's CORS preflight check.
// Operate with the headers as an object because request "Headers"
// are immutable.
delete headers['x-msw-bypass']
return fetch(clonedRequest, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
if (request.headers.get('x-msw-bypass') === 'true') {
return passthrough()
}
// Notify the client that a request has been intercepted.
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.text(),
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'MOCK_NOT_FOUND': {
return passthrough()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.data
const networkError = new Error(message)
networkError.name = name
// Rejecting a "respondWith" promise emulates a network error.
throw networkError
}
}
return passthrough()
}
function sendToClient(client, message) {
@ -312,27 +287,17 @@ function sendToClient(client, message) {
resolve(event.data)
}
client.postMessage(JSON.stringify(message), [channel.port2])
client.postMessage(message, [channel.port2])
})
}
function delayPromise(cb, duration) {
function sleep(timeMs) {
return new Promise((resolve) => {
setTimeout(() => resolve(cb()), duration)
setTimeout(resolve, timeMs)
})
}
function respondWithMock(clientMessage) {
return new Response(clientMessage.payload.body, {
...clientMessage.payload,
headers: clientMessage.payload.headers,
})
}
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0
const v = c == 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
async function respondWithMock(response) {
await sleep(response.delay)
return new Response(response.body, response)
}

View File

@ -0,0 +1,20 @@
import React from 'react';
import { IconProps } from './icon';
export default function SlidersIcon({ className }: IconProps) {
return (
<svg
className={className}
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path
className="fill-current"
fillRule="evenodd"
clipRule="evenodd"
d="M7 3c.55228 0 1 .44772 1 1v6.126c1.72523.4441 3 2.0102 3 3.874s-1.27477 3.4299-3 3.874V20c0 .5523-.44772 1-1 1s-1-.4477-1-1v-2.126C4.27477 17.4299 3 15.8638 3 14s1.27477-3.4299 3-3.874V4c0-.55228.44772-1 1-1Zm11 4.12602V4c0-.55228-.4477-1-1-1s-1 .44772-1 1v3.12602C14.2748 7.57006 13 9.13616 13 11c0 1.8638 1.2748 3.4299 3 3.874V20c0 .5523.4477 1 1 1s1-.4477 1-1v-5.126c1.7252-.4441 3-2.0102 3-3.874 0-1.86384-1.2748-3.42994-3-3.87398ZM7 16c1.10457 0 2-.8954 2-2s-.89543-2-2-2-2 .8954-2 2 .89543 2 2 2Z"
/>
</svg>
);
}

View File

@ -1,14 +1,17 @@
import React, { useCallback, useState, FormEvent, useEffect } from 'react';
import api from '../state/api';
import {
addBucket,
setAccessKeyId,
setCurrentBucket,
setEndpoint,
setSecretAccessKey,
} from '@urbit/api';
import { useForm } from 'react-hook-form';
import cn from 'classnames';
import { useAsyncCall } from '../logic/useAsyncCall';
import { useStorageState } from '../state/storage';
import { Button } from '../components/Button';
import { Spinner } from '../components/Spinner';
interface CredentialsSubmit {
endpoint: string;
@ -18,85 +21,125 @@ interface CredentialsSubmit {
}
export const StoragePrefs = () => {
const { s3, ...storageState } = useStorageState();
const { s3, loaded, ...storageState } = useStorageState();
useEffect(() => {
useStorageState.getState().initialize(api);
}, []);
const {
register,
handleSubmit,
reset,
formState: { isSubmitting, isDirty, isValid, isSubmitSuccessful },
} = useForm<CredentialsSubmit>({
mode: 'onChange',
});
const { call: addS3Credentials, status } = useAsyncCall(
useCallback(async (data: CredentialsSubmit) => {
api.poke(setEndpoint(data.endpoint));
api.poke(setAccessKeyId(data.accessId));
api.poke(setSecretAccessKey(data.accessSecret));
api.poke(addBucket(data.bucket));
api.poke(setCurrentBucket(data.bucket));
}, [])
);
useEffect(() => {
useStorageState.getState().initialize(api);
}, []);
useEffect(() => {
loaded && reset();
}, [loaded, reset]);
return (
<div className="inner-section space-y-8">
<h2 className="h4">Remote Storage</h2>
<div className="flex flex-col space-y-3 leading-5">
<div className="mb-8 flex flex-col space-y-3 leading-5">
<p>
Configure your urbit to enable uploading your own images or other
files in Urbit applications.
</p>
<p>
Read more about setting up S3 storage in the{' '}
<a
className="font-bold"
rel="external"
target="_blank"
href="https://operators.urbit.org/manual/os/s3"
>
Read more about setting up S3 storage
</a>{' '}
on Urbit.org.
Urbit Operator's Manual
</a>
.
</p>
</div>
<div className="flex flex-col space-y-2">
<label className="font-semibold" htmlFor="endpoint">
Endpoint
</label>
<input
id="endpoint"
type="text"
value={s3.credentials?.endpoint}
className="input default-ring bg-gray-50"
/>
</div>
<div className="flex flex-col space-y-2">
<label className="font-semibold" htmlFor="key">
Access Key ID
</label>
<input
id="key"
type="text"
value={s3.credentials?.accessKeyId}
className="input default-ring bg-gray-50"
/>
</div>
<div className="flex flex-col space-y-2">
<label className="font-semibold" htmlFor="secretAccessKey">
Secret Access Key
</label>
<input
id="secretAccessKey"
type="text"
value={s3.credentials?.secretAccessKey}
className="input default-ring bg-gray-50"
/>
</div>
<div className="flex flex-col space-y-2">
<label className="font-semibold" htmlFor="bucket">
Bucket Name
</label>
<input
id="bucket"
type="text"
value={s3.configuration.currentBucket}
className="input default-ring bg-gray-50"
/>
</div>
<form onSubmit={handleSubmit(addS3Credentials)}>
<div className="mb-8 flex flex-col space-y-2">
<label className="font-semibold" htmlFor="endpoint">
Endpoint<span label="Required field">*</span>
</label>
<input
disabled={!loaded}
required
id="endpoint"
type="text"
defaultValue={s3.credentials?.endpoint}
{...register('endpoint', { required: true })}
className="input default-ring bg-gray-50"
/>
</div>
<div className="mb-8 flex flex-col space-y-2">
<label className="font-semibold" htmlFor="key">
Access Key ID<span label="Required field">*</span>
</label>
<input
disabled={!loaded}
required
id="key"
type="text"
defaultValue={s3.credentials?.accessKeyId}
{...register('accessId', { required: true })}
className="input default-ring bg-gray-50"
/>
</div>
<div className="mb-8 flex flex-col space-y-2">
<label className="font-semibold" htmlFor="secretAccessKey">
Secret Access Key<span label="Required field">*</span>
</label>
<input
disabled={!loaded}
required
id="secretAccessKey"
type="text"
defaultValue={s3.credentials?.secretAccessKey}
{...register('accessSecret', { required: true })}
className="input default-ring bg-gray-50"
/>
</div>
<div className="mb-8 flex flex-col space-y-2">
<label className="font-semibold" htmlFor="bucket">
Bucket Name<span label="Required field">*</span>
</label>
<input
disabled={!loaded}
required
id="bucket"
type="text"
defaultValue={s3.configuration.currentBucket}
{...register('bucket', { required: true })}
className="input default-ring bg-gray-50"
/>
</div>
<Button
type="submit"
disabled={!isDirty || !isValid}
className={cn(
!isDirty || !isValid || isSubmitSuccessful
? 'cursor-not-allowed bg-gray-200 text-gray-100'
: ''
)}
>
{isSubmitting ? <Spinner /> : 'Save'}
{isSubmitSuccessful && ' Successful'}
</Button>
</form>
</div>
);
};

View File

@ -29,6 +29,7 @@ import BellIcon from '../components/icons/BellIcon';
import BurstIcon from '../components/icons/BurstIcon';
import PencilIcon from '../components/icons/PencilIcon';
import ForwardSlashIcon from '../components/icons/ForwardSlashIcon';
import SlidersIcon from '../components/icons/SlidersIcon';
import Sig16Icon from '../components/icons/Sig16Icon';
import { useSystemUpdate } from '../logic/useSystemUpdate';
import { Bullet } from '../components/icons/Bullet';
@ -184,7 +185,7 @@ export const SystemPreferences = (
url={subUrl('storage')}
active={matchSub('storage')}
>
<Sig16Icon className="mr-3 h-6 w-6 rounded-md text-gray-600" />
<SlidersIcon className="mr-3 h-6 w-6 rounded-md text-gray-600" />
Remote Storage
</SystemPreferencesSection>
</ul>

View File

@ -76,7 +76,9 @@ export const useStorageState = createState<BaseStorageState>(
createSubscription('s3-store', '/all', (e) => {
const d = _.get(e, 's3-update', false);
if (d) {
console.log(d)
reduceStateN(get(), d, reduce);
set({ loaded: true });
}
})
]