Skip to content

Commit

Permalink
[generic issuance] pipeline CRUD app with minimal UI (#1451)
Browse files Browse the repository at this point in the history
Closes #1428 

TODO
- [x] Request types
- [x] Move requests to passport-interface
- [x] Integrating with pipeline DB
- [x] Integrating with user DB
- [x] DB testing
- [ ] proposed refreshPipeline() in-memory command
#1456
- [ ] end-to-end API tests
#1457

What's in here:
- CRUD endpoints for pipeline definitions
- MVP UI that allows interacting with this CRUD UI via JSON (see video
below)
- Basic 'permission control' for the CRUD actions, e.g., you must own a
pipeline to delete it, but you can read or write to a pipeline if you
are either an owner or editor
- Pipeline Definition schema validation on the backend

What's not in here:
- The best state management / client-side fetching system
- Any admin user logic
- Comprehensive tests (in progress)


https://github.com/proofcarryingdata/zupass/assets/36896271/c3277ca4-caca-4249-868f-cc167c80f896
  • Loading branch information
rrrliu authored Feb 1, 2024
1 parent 1183024 commit 656e735
Show file tree
Hide file tree
Showing 30 changed files with 1,104 additions and 252 deletions.
5 changes: 4 additions & 1 deletion apps/generic-issuance-client/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# This token is used to authenticate Stytch requests for user authentication.
# If you are on the team, message @rrrliu to get one.
STYTCH_PUBLIC_TOKEN=
STYTCH_PUBLIC_TOKEN=

PASSPORT_CLIENT_URL="http://localhost:3000"
PASSPORT_SERVER_URL="http://localhost:3002"
8 changes: 7 additions & 1 deletion apps/generic-issuance-client/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ const genericIssuanceClientAppOpts: BuildOptions = {
define: {
"process.env.NODE_ENV": `'${process.env.NODE_ENV}'`,
"process.env.STYTCH_PUBLIC_TOKEN": `'${process.env.STYTCH_PUBLIC_TOKEN}'`,
"process.env.GENERIC_ISSUANCE_CLIENT_URL": `'${process.env.GENERIC_ISSUANCE_CLIENT_URL}'`
"process.env.GENERIC_ISSUANCE_CLIENT_URL": `'${process.env.GENERIC_ISSUANCE_CLIENT_URL}'`,
"process.env.PASSPORT_SERVER_URL": JSON.stringify(
process.env.PASSPORT_SERVER_URL || "http://localhost:3002"
),
"process.env.PASSPORT_CLIENT_URL": JSON.stringify(
process.env.PASSPORT_CLIENT_URL || "http://localhost:3000"
)
},
entryPoints: ["src/main.tsx"],
plugins: [
Expand Down
2 changes: 2 additions & 0 deletions apps/generic-issuance-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
"clean": "rm -rf node_modules public/js tsconfig.tsbuildinfo"
},
"dependencies": {
"@pcd/passport-interface": "0.10.0",
"@stytch/react": "^15.0.0",
"@stytch/vanilla-js": "^4.4.2",
"dotenv": "^16.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.9.0",
"styled-components": "^5.3.6"
},
Expand Down
16 changes: 2 additions & 14 deletions apps/generic-issuance-client/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,4 @@
export const PCD_GITHUB_URL = "https://github.com/proofcarryingdata/pcd";

export const IS_PROD = process.env.NODE_ENV === "production";
export const IS_STAGING = process.env.NODE_ENV === "staging";

export const ZUPASS_URL = IS_PROD
? "https://zupass.org/"
: IS_STAGING
? "https://staging.zupass.org/"
: "http://localhost:3000/";

export const ZUPASS_SERVER_URL = IS_PROD
? "https://api.zupass.org/"
: IS_STAGING
? "https://api-staging.zupass.org/"
: "http://localhost:3002/";
export const ZUPASS_URL = process.env.PASSPORT_CLIENT_URL;
export const ZUPASS_SERVER_URL = process.env.PASSPORT_SERVER_URL;
4 changes: 3 additions & 1 deletion apps/generic-issuance-client/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { createHashRouter, RouterProvider } from "react-router-dom";
import { GlobalStyle } from "./components/GlobalStyle";
import Dashboard from "./pages/Dashboard";
import Home from "./pages/Home";
import Pipeline from "./pages/Pipeline";

const stytch = new StytchUIClient(process.env.STYTCH_PUBLIC_TOKEN);

const router = createHashRouter([
{ path: "/", element: <Home /> },
{ path: "/dashboard", element: <Dashboard /> }
{ path: "/dashboard", element: <Dashboard /> },
{ path: "/pipelines/:id", element: <Pipeline /> }
]);

createRoot(document.getElementById("root") as HTMLElement).render(
Expand Down
118 changes: 101 additions & 17 deletions apps/generic-issuance-client/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,75 @@
import {
PipelineDefinition,
requestGenericIssuanceGetAllUserPipelines,
requestGenericIssuanceUpsertPipeline
} from "@pcd/passport-interface";
import { useStytch, useStytchUser } from "@stytch/react";
import { ReactNode, useEffect, useState } from "react";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { ZUPASS_SERVER_URL } from "../constants";

const SAMPLE_CREATE_PIPELINE_TEXT = JSON.stringify(
{
type: "Lemonade",
editorUserIds: [],
options: {
lemonadeApiKey: "your-lemonade-api-key",
events: []
}
},
null,
2
);

export default function Dashboard(): ReactNode {
const stytchClient = useStytch();
const { user } = useStytchUser();
const [isLoggingOut, setLoggingOut] = useState(false);
const [userPingMessage, setUserPingMessage] = useState("");
// TODO: After MVP, replace with RTK hooks or a more robust state management.
const [pipelines, setPipelines] = useState<PipelineDefinition[]>([]);
const [isLoading, setLoading] = useState(true);
const [isCreatingPipeline, setCreatingPipeline] = useState(false);
const [newPipelineRaw, setNewPipelineRaw] = useState(
SAMPLE_CREATE_PIPELINE_TEXT
);
const [error, setError] = useState("");

useEffect(() => {
setUserPingMessage("Pinging server...");
fetch(new URL("generic-issuance/api/user/ping", ZUPASS_SERVER_URL).href, {
credentials: "include"
})
.then((res) => res.json())
.then((message) => setUserPingMessage(`JWT valid, received ${message}.`))
.catch((e) => setUserPingMessage(`Error: ${e}`));
const fetchAllPipelines = useCallback(async () => {
setLoading(true);
const res =
await requestGenericIssuanceGetAllUserPipelines(ZUPASS_SERVER_URL);
if (res.success) {
setPipelines(res.value);
} else {
// TODO: Better errors
alert(`An error occurred while fetching user pipelines: ${res.error}`);
}
setLoading(false);
}, []);

useEffect(() => {
fetchAllPipelines();
}, [fetchAllPipelines]);

const createPipeline = async (): Promise<void> => {
if (!newPipelineRaw) return;
const res = await requestGenericIssuanceUpsertPipeline(
ZUPASS_SERVER_URL,
JSON.parse(newPipelineRaw)
);
await fetchAllPipelines();
if (res.success) {
setCreatingPipeline(false);
} else {
// TODO: Better errors
alert(`An error occurred while creating pipeline: ${res.error}`);
}
};

if (isLoading) {
return <div>Loading...</div>;
}

if (!user) {
window.location.href = "/";
}
Expand All @@ -36,20 +87,53 @@ export default function Dashboard(): ReactNode {
<p>
Congrats - you are now logged in as <b>{user.emails?.[0]?.email}.</b>
</p>
{userPingMessage && <p>{userPingMessage}</p>}
<button
onClick={async (): Promise<void> => {
setLoggingOut(true);
try {
await stytchClient.session.revoke();
} catch (e) {
setError(e);
setLoggingOut(false);
if (confirm("Are you sure you want to log out?")) {
setLoggingOut(true);
try {
await stytchClient.session.revoke();
} catch (e) {
setError(e);
setLoggingOut(false);
}
}
}}
>
Log out
</button>

<h2>My Pipelines</h2>
{!pipelines.length && <p>No pipelines right now - go create some!</p>}
{!!pipelines.length && (
<ol>
{pipelines.map((p) => (
<Link to={`/pipelines/${p.id}`}>
<li key={p.id}>
id: {p.id}, type: {p.type}
</li>
</Link>
))}
</ol>
)}
<p>
<button onClick={(): void => setCreatingPipeline((curr) => !curr)}>
{isCreatingPipeline ? "Minimize 🔼" : "Create new pipeline 🔽"}
</button>
{isCreatingPipeline && (
<div>
<textarea
rows={10}
cols={50}
value={newPipelineRaw}
onChange={(e): void => setNewPipelineRaw(e.target.value)}
/>
<div>
<button onClick={createPipeline}>Create new pipeline</button>
</div>
</div>
)}
</p>
</div>
);
}
126 changes: 126 additions & 0 deletions apps/generic-issuance-client/src/pages/Pipeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
PipelineDefinition,
requestGenericIssuanceDeletePipeline,
requestGenericIssuanceGetPipeline,
requestGenericIssuanceUpsertPipeline
} from "@pcd/passport-interface";
import { useStytchUser } from "@stytch/react";
import { ReactNode, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { ZUPASS_SERVER_URL } from "../constants";

function format(obj: object): string {
return JSON.stringify(obj, null, 2);
}

export default function Pipeline(): ReactNode {
const params = useParams();
const { user } = useStytchUser();
const { id } = params;
// TODO: After MVP, replace with RTK hooks or a more robust state management.
const [savedPipeline, setSavedPipeline] = useState<PipelineDefinition>();
const [textareaValue, setTextareaValue] = useState("");
const [queryLoading, setQueryLoading] = useState(true);
const [saveLoading, setSaveLoading] = useState(false);
const [error, setError] = useState("");

async function savePipeline(): Promise<void> {
setSaveLoading(true);
const res = await requestGenericIssuanceUpsertPipeline(
ZUPASS_SERVER_URL,
JSON.parse(textareaValue)
);
if (res.success) {
setSavedPipeline(res.value);
setTextareaValue(format(res.value));
setError("");
} else {
setError(`An error occured while saving: ${res.error}`);
}
setSaveLoading(false);
}

async function deletePipeline(): Promise<void> {
if (confirm("Are you sure you would like to delete this pipeline?")) {
const res = await requestGenericIssuanceDeletePipeline(
ZUPASS_SERVER_URL,
id
);
if (res.success) {
window.location.href = "/#/dashboard";
} else {
setError(`An error occured while deleting: ${res.error}`);
}
}
}

useEffect(() => {
async function fetchPipeline(): Promise<void> {
const res = await requestGenericIssuanceGetPipeline(
ZUPASS_SERVER_URL,
id
);
if (res.success) {
setSavedPipeline(res.value);
setTextareaValue(format(res.value));
setError("");
} else {
setError(
`This pipeline "${id}" is invalid or you do not have access to this pipeline.`
);
setSavedPipeline(undefined);
}
setQueryLoading(false);
}
fetchPipeline();
}, [id]);

if (!user) {
window.location.href = "/";
}

if (queryLoading) {
return <div>Loading...</div>;
}

const hasEdits = format(savedPipeline) !== textareaValue;

return (
<div>
{savedPipeline && (
<>
<p>
<textarea
cols={50}
rows={30}
value={textareaValue}
onChange={(e): void => setTextareaValue(e.target.value)}
/>
</p>
<p>
{hasEdits && (
<button disabled={saveLoading} onClick={savePipeline}>
{saveLoading ? "Saving..." : "Save changes"}
</button>
)}
{!hasEdits && <button disabled>All changes saved ✅</button>}
</p>
<p>
<button onClick={deletePipeline}>Delete pipeline</button>
</p>
</>
)}
{error && (
<p>
<strong>Error: </strong>
{error}
</p>
)}
<p>
<Link to="/dashboard">
<button>Return to all pipelines</button>
</Link>
</p>
</div>
);
}
19 changes: 16 additions & 3 deletions apps/generic-issuance-client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,25 @@
"resolveJsonModule": true,
"downlevelIteration": true,
"jsx": "react-jsx",
"lib": ["ES2015", "DOM"],
"lib": [
"ES2015",
"DOM"
],
"esModuleInterop": true,
// To allow for mocha and jest to work together:
// https://stackoverflow.com/a/65568463
"skipLibCheck": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
],
"references": [
{
"path": "../../packages/lib/passport-interface"
}
]
}
2 changes: 2 additions & 0 deletions apps/passport-server/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import process from "node:process";
import * as path from "path";
import urljoin from "url-join";
import { MockPipelineAtomDB } from "../test/generic-issuance/MockPipelineAtomDB";
import { MockPipelineUserDB } from "../test/generic-issuance/MockPipelineUserDB";
import { getDevconnectPretixAPI } from "./apis/devconnect/devconnectPretixAPI";
import { IEmailAPI, sendgridSendEmail } from "./apis/emailAPI";
import { getHoneycombAPI } from "./apis/honeycombAPI";
Expand Down Expand Up @@ -51,6 +52,7 @@ export async function startApplication(
publicResourcesDir: path.join(process.cwd(), "public"),
gitCommitHash: await getCommitHash(),
// TODO: remove these once we have settled on a db schema for these
pipelineUserDB: new MockPipelineUserDB(),
pipelineAtomDB: new MockPipelineAtomDB()
};

Expand Down
Loading

0 comments on commit 656e735

Please sign in to comment.