diff --git a/packages/web/.oxlintrc.json b/packages/web/.oxlintrc.json index 147817a..39e48ca 100644 --- a/packages/web/.oxlintrc.json +++ b/packages/web/.oxlintrc.json @@ -30,6 +30,7 @@ "typescript/strict-void-return": "off", "typescript/no-non-null-assertion": "off", "unicorn/filename-case": "off", - "unicorn/prefer-query-selector": "off" + "unicorn/prefer-query-selector": "off", + "promise/avoid-new": "off" } } diff --git a/packages/web/package.json b/packages/web/package.json index 7a2b6af..68d1fe5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -56,6 +56,7 @@ "vite": "^7.0.0" }, "devDependencies": { + "@faker-js/faker": "^10.5.0", "@types/relay-runtime": "^20.1.1", "eslint-plugin-solid": "^0.14.5", "relay-compiler": "^21.0.1", diff --git a/packages/web/src/RelayEnviroment.ts b/packages/web/src/RelayEnvironment.ts similarity index 100% rename from packages/web/src/RelayEnviroment.ts rename to packages/web/src/RelayEnvironment.ts diff --git a/packages/web/src/app.css b/packages/web/src/app.css index 98da414..b646702 100644 --- a/packages/web/src/app.css +++ b/packages/web/src/app.css @@ -121,8 +121,7 @@ input { border-color: var(--accent-soft); } -.app-header a:focus-visible, -.button:focus-visible { +.app-header a:focus-visible { outline: 3px solid var(--accent-soft); outline-offset: 0.2rem; } @@ -134,11 +133,11 @@ input { padding: clamp(2rem, 5vw, 4rem) var(--gutter); } -.app-content > main:not(.auth-page) { +.app-content > main:not(.form-page) { width: 100%; } -.app-content > main:not(.auth-page) :is(h1, h2, p) { +.app-content > main:not(.form-page) :is(h1, h2, p) { margin-top: 0; } @@ -158,7 +157,7 @@ input { line-height: var(--leading-body); } -.auth-page { +.form-page { align-items: center; display: flex; justify-content: center; @@ -172,7 +171,7 @@ input { box-shadow: var(--shadow-sm); } -.auth-panel { +.form-panel { max-width: 28rem; padding: clamp(1.5rem, 4vw, 2rem); width: 100%; @@ -194,16 +193,35 @@ input { margin: 0; } -.auth-panel form { +.form-panel form { display: grid; gap: 1rem; } .field { display: grid; + gap: 0.5rem; +} + +.field-heading { + align-items: baseline; + display: flex; font-size: 0.9rem; font-weight: 650; - gap: 0.75rem; + justify-content: space-between; +} + +.field-status { + color: var(--ink-faint); + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.field-status.required { + color: var(--accent-strong); } .field input { @@ -228,6 +246,20 @@ input { outline: none; } +.field input:read-only { + border-style: dashed; + color: var(--ink-soft); + cursor: default; + font-family: var(--font-mono); +} + +.field-hint { + color: var(--ink-faint); + font-size: 0.78rem; + line-height: 1.45; + margin: 0; +} + .button { align-items: center; border: 0; @@ -240,6 +272,11 @@ input { padding: 0.75rem 1.1rem; } +.button:focus-visible { + outline: 3px solid var(--accent-soft); + outline-offset: 0.2rem; +} + .button.primary { background: var(--accent); color: var(--on-accent); @@ -270,10 +307,6 @@ input { border-left: 3px solid var(--danger); } -.notice.success { - border-left: 3px solid var(--accent); -} - @media (max-width: 600px) { .app-header-inner { padding: 0 1rem; @@ -301,7 +334,7 @@ input { padding: 1.5rem 1rem 3rem; } - .auth-page { + .form-page { align-items: flex-start; min-height: auto; } diff --git a/packages/web/src/app.tsx b/packages/web/src/app.tsx index f6373c9..fc5ee90 100644 --- a/packages/web/src/app.tsx +++ b/packages/web/src/app.tsx @@ -23,7 +23,7 @@ import "./drfed.css"; import "./app.css"; import { RelayEnvironmentProvider } from "solid-relay"; -import { createRelayEnvironment } from "./RelayEnviroment"; +import { createRelayEnvironment } from "./RelayEnvironment"; export default function App() { const environment = createRelayEnvironment(); diff --git a/packages/web/src/routes/confirm/[slug].tsx b/packages/web/src/routes/confirm/[slug].tsx deleted file mode 100644 index 64d4325..0000000 --- a/packages/web/src/routes/confirm/[slug].tsx +++ /dev/null @@ -1,108 +0,0 @@ -// DrFed: A web-based platform for developing and debugging ActivityPub apps -// Copyright (C) 2026 DrFed team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { useParams, useSearchParams } from "@solidjs/router"; -import { graphql } from "relay-runtime"; -import { Show, createSignal, onMount } from "solid-js"; -import { createMutation } from "solid-relay"; - -import type { CompleteLoginChallenge } from "./__generated__/CompleteLoginChallenge.graphql"; - -const signCompleteMutation = graphql` - mutation CompleteLoginChallenge($token: UUID!, $code: String!) { - completeLoginChallenge(token: $token, code: $code) { - accessToken - expires - } - } -`; - -export default function ConfirmPage() { - const params = useParams<{ slug: string }>(); - const [searchParams] = useSearchParams<{ code?: string }>(); - const [complete] = - createMutation(signCompleteMutation); - const [result, setResult] = createSignal<{ - message: string; - status: "error" | "success"; - }>(); - - function showSessionError() { - setResult({ - message: "Unable to save a session", - status: "error", - }); - } - - onMount(() => { - const { code } = searchParams; - const { slug: token } = params; - - if (token === "" || code == undefined || code === "") { - return; - } - - async function saveSession(accessToken: string, expires: string) { - try { - const response = await fetch("/session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - accessToken, - expires, - }), - }); - - if (!response.ok) { - showSessionError(); - return; - } - // Redirect to the home page - globalThis.location.assign("/"); - } catch { - showSessionError(); - } - } - - complete({ - variables: { token, code }, - onCompleted(data) { - const session = data.completeLoginChallenge; - if ( - session?.accessToken == undefined || - typeof session.expires !== "string" - ) { - setResult({ - message: "The sign-in link is invalid or expired.", - status: "error", - }); - return; - } - - void saveSession(session.accessToken, session.expires); - }, - onError: (error) => { - setResult({ message: error.message, status: "error" }); - }, - }); - }); - - return ( - - {(value) => {value().message}} - - ); -} diff --git a/packages/web/src/routes/confirm/[token].tsx b/packages/web/src/routes/confirm/[token].tsx new file mode 100644 index 0000000..36b53f5 --- /dev/null +++ b/packages/web/src/routes/confirm/[token].tsx @@ -0,0 +1,156 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { action, useAction, useParams, useSearchParams } from "@solidjs/router"; +import { commitMutation, graphql } from "relay-runtime"; +import { Show, createSignal, onMount } from "solid-js"; + +import { createRelayEnvironment } from "~/RelayEnvironment"; + +import type { CompleteLoginChallenge } from "./__generated__/CompleteLoginChallenge.graphql.ts"; + +const completeLoginChallengeMutation = graphql` + mutation CompleteLoginChallenge($token: UUID!, $code: String!) { + completeLoginChallenge(token: $token, code: $code) { + accessToken + expires + } + } +`; + +type CompleteLogInResult = + | { + message: string; + status: "error"; + } + | { + accessToken: string; + expires: string; + message: string; + status: "success"; + }; + +const completeLoginChallengeAction = action( + async ({ token, code }: { token: string; code: string }) => { + "use server"; + + const environment = createRelayEnvironment(); + + const result = await new Promise((resolve) => { + commitMutation(environment, { + mutation: completeLoginChallengeMutation, + variables: { token, code }, + onCompleted: (response, errors) => { + const errorMessage = errors?.map((e) => e.message).join("\n"); + + if (errorMessage !== undefined) { + resolve({ + message: errorMessage, + status: "error", + }); + return; + } + + const session = response.completeLoginChallenge; + if ( + session?.accessToken == undefined || + typeof session.expires !== "string" + ) { + resolve({ + message: "The sign-in link is invalid or expired.", + status: "error", + }); + return; + } + + resolve({ + accessToken: session.accessToken, + expires: session.expires, + message: "Signing in…", + status: "success", + }); + }, + onError: (error) => { + resolve({ + message: error.message, + status: "error", + }); + }, + }); + }); + + return result; + }, + "complete-login-challenge", +); + +export default function ConfirmPage() { + const params = useParams<{ token: string }>(); + const [searchParams] = useSearchParams<{ code?: string }>(); + const completeLoginChallenge = useAction(completeLoginChallengeAction); + const [result, setResult] = createSignal(); + + onMount(() => { + async function complete() { + try { + const completeResult = await completeLoginChallenge({ + token: params.token, + code: searchParams.code ?? "", + }); + setResult(completeResult); + + if (completeResult.status === "error") { + return; + } + + const response = await fetch("/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accessToken: completeResult.accessToken, + expires: completeResult.expires, + }), + }); + + if (!response.ok) { + setResult({ + message: "Unable to save a session.", + status: "error", + }); + return; + } + + globalThis.location.assign("/"); + } catch (error) { + setResult({ + message: + error instanceof Error + ? error.message + : "Unable to complete sign-in.", + status: "error", + }); + } + } + + void complete(); + }); + + return ( + + {(value) => {value().message}} + + ); +} diff --git a/packages/web/src/routes/sign-in.css b/packages/web/src/routes/sign-in.css new file mode 100644 index 0000000..e9e9be1 --- /dev/null +++ b/packages/web/src/routes/sign-in.css @@ -0,0 +1,21 @@ +/* +DrFed: A web-based platform for developing and debugging ActivityPub apps +Copyright (C) 2026 DrFed team + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +.auth-panel .notice.success { + border-left: 3px solid var(--accent); +} diff --git a/packages/web/src/routes/sign-in.tsx b/packages/web/src/routes/sign-in.tsx index 760f3ec..2e49dec 100644 --- a/packages/web/src/routes/sign-in.tsx +++ b/packages/web/src/routes/sign-in.tsx @@ -15,11 +15,14 @@ // along with this program. If not, see . import { Title } from "@solidjs/meta"; -import { graphql } from "relay-runtime"; -import { type JSX, Show, createSignal } from "solid-js"; -import { createMutation } from "solid-relay"; +import { action, useSubmission } from "@solidjs/router"; +import { commitMutation, graphql } from "relay-runtime"; +import { Show } from "solid-js"; +import { getRequestEvent } from "solid-js/web"; -import type { SignInMutation } from "./__generated__/SignInMutation.graphql"; +import { createRelayEnvironment } from "~/RelayEnvironment"; + +import "./sign-in.css"; const signInMutation = graphql` mutation SignInMutation($email: Email!, $verifyUrl: URITemplate) { @@ -29,64 +32,91 @@ const signInMutation = graphql` } `; -export default function SignInPage() { - const [signIn, isPending] = createMutation(signInMutation); - const [result, setResult] = createSignal<{ - message: string; - status: "error" | "success"; - }>(); - const buttonLabel = () => { - if (isPending()) { - return "Sending link…"; - } - if (result()?.status === "success") { - return "Resend sign-in link"; - } - return "Send sign-in link"; - }; +interface SignInResult { + message: string; + status: "error" | "success"; +} - const handleSubmit: JSX.EventHandler = (e) => { - e.preventDefault(); - const email = new FormData(e.currentTarget).get("email"); - if (typeof email !== "string" || email === "") { - return; - } +const signInAction = action(async (formData: FormData) => { + "use server"; - setResult(); - signIn({ - variables: { - email, - verifyUrl: `${globalThis.location.origin}/confirm/{token}?code={code}`, - }, + const email = formData.get("email"); + if (typeof email !== "string" || email === "") { + return { + message: "Enter a valid email address.", + status: "error", + } satisfies SignInResult; + } + + const request = getRequestEvent()?.request; + if (request === undefined) { + return { + message: "Unable to determine the application URL.", + status: "error", + } satisfies SignInResult; + } + + const environment = createRelayEnvironment(); + const verifyUrl = new URL("/confirm/{token}?code={code}", request.url).href; + + const result = await new Promise((resolve) => { + commitMutation(environment, { + mutation: signInMutation, + variables: { email, verifyUrl }, onCompleted: (_response, errors) => { - const [error] = errors ?? []; + const errorMessage = errors?.map((e) => e.message).join("\n"); - setResult({ + resolve({ message: - error?.message ?? + errorMessage ?? "Check your inbox for a secure sign-in link. You can close this page.", - status: error === undefined ? "success" : "error", + status: errorMessage === undefined ? "success" : "error", }); }, onError: (error) => { - setResult({ message: error.message, status: "error" }); + resolve({ + message: error.message, + status: "error", + }); }, }); + }); + + return result; +}, "sign-in"); + +export default function SignInPage() { + const signInSubmission = useSubmission(signInAction); + + const buttonLabel = () => { + if (signInSubmission.pending === true) { + return "Sending link…"; + } + if (signInSubmission.result?.status === "success") { + return "Resend sign-in link"; + } + return "Send sign-in link"; }; return ( -
+
Sign in — DrFed -
+

Sign in

Enter your email address to receive a secure sign-in link.

-
+ -
- + {(formResult) => (

. + +import { faker } from "@faker-js/faker"; +import { Title } from "@solidjs/meta"; +import { action, redirect, useSubmission } from "@solidjs/router"; +import { commitMutation, graphql } from "relay-runtime"; +import { Show } from "solid-js"; +import { getRequestEvent } from "solid-js/web"; + +import { createRelayEnvironment } from "~/RelayEnvironment"; + +import type { CreateInstanceMutation } from "./__generated__/CreateInstanceMutation.graphql"; + +const createInstanceMutation = graphql` + mutation CreateInstanceMutation( + $slug: String! # $name: String + ) { + createInstance(slug: $slug) { + ... on Instance { + slug + } + } + } +`; + +type CreateInstanceResult = + | { + payload: { + slug: string; + }; + status: "success"; + } + | { + message: string; + status: "error"; + }; + +const createInstanceAction = action(async (formData: FormData) => { + "use server"; + + const slug = formData.get("slug"); + if (typeof slug !== "string" || slug === "") { + return { + message: "Enter a valid slug.", + status: "error", + } satisfies CreateInstanceResult; + } + + const request = getRequestEvent()?.request; + if (request === undefined) { + return { + message: "Unable to determine the application URL.", + status: "error", + } satisfies CreateInstanceResult; + } + + const environment = createRelayEnvironment(); + + const result = await new Promise((resolve) => { + commitMutation(environment, { + mutation: createInstanceMutation, + variables: { slug }, + onCompleted: (response, errors) => { + const errorMessage = errors?.map((e) => e.message).join("\n"); + + if (typeof errorMessage == "string") { + resolve({ + message: errorMessage, + status: "error", + }); + } else if (response.createInstance.slug === undefined) { + resolve({ + message: "Empty Slug Returned", + status: "error", + }); + } else { + resolve({ + payload: { + slug: response.createInstance.slug, + }, + status: "success", + }); + } + }, + onError: (error) => { + resolve({ + message: error.message, + status: "error", + }); + }, + }); + }); + + if (result.status === "error") { + return result; + } + + return redirect(`/workspace/`); +}, "create-instance"); + +export default function CreateInstancePage() { + const createInstanceSubmission = useSubmission(createInstanceAction); + + const buttonLabel = () => { + if (createInstanceSubmission.pending === true) { + return "Creating instance…"; + } + return "Create instance"; + }; + + return ( +

+ Create an instance — DrFed + +
+
+

Create an instance

+

Name your new ActivityPub testing environment.

+
+ +
+ + + +
+ + + + +
+
+ ); +} diff --git a/packages/web/src/routes/workspace/index.tsx b/packages/web/src/routes/workspace/index.tsx new file mode 100644 index 0000000..5f34bfc --- /dev/null +++ b/packages/web/src/routes/workspace/index.tsx @@ -0,0 +1,25 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +export default function WorkspacePage() { + return ( +
+
+

Nothing Yet but it is a workspace for you.

+
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd412d4..9a2fab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: specifier: ^7.0.0 version: 7.3.6(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0) devDependencies: + '@faker-js/faker': + specifier: ^10.5.0 + version: 10.5.0 '@types/relay-runtime': specifier: ^20.1.1 version: 20.1.1 @@ -810,6 +813,10 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@faker-js/faker@10.5.0': + resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==} + engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} + '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} @@ -948,12 +955,6 @@ packages: engines: {node: '>=18'} hasBin: true - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -1641,9 +1642,6 @@ packages: resolution: {integrity: sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw==} engines: {node: '>=12'} - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -4743,6 +4741,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@faker-js/faker@10.5.0': {} + '@fastify/busboy@3.2.0': {} '@fedify/uri-template@2.3.1': {} @@ -4899,11 +4899,11 @@ snapshots: - encoding - supports-color - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 + '@tybys/wasm-util': 0.10.3 optional: true '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': @@ -4988,7 +4988,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@oxc-parser/binding-win32-arm64-msvc@0.134.0': @@ -5473,11 +5473,6 @@ snapshots: - supports-color - vite - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - optional: true - '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1