From 66b1830b441822c5b57e095a3d88e47619d3d4c8 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 23 Jul 2026 16:44:31 +0900 Subject: [PATCH 01/13] Disable oxc/no-async-await Co-authored-by: Jiwon Kwon --- .oxlintrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.oxlintrc.json b/.oxlintrc.json index 721797a..bb2d393 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -26,6 +26,7 @@ "eslint/curly": ["error", "multi-line"], "node/no-top-level-await": "off", "oxc/no-optional-chaining": "off", + "oxc/no-async-await": "off", "eslint/eqeqeq": ["off", "smart"], "eslint/func-style": ["off"], "eslint/id-length": ["warn", { "exceptionPatterns": ["^_", "^[Tertv]$"] }], From f8a684a046f5dc46ca6ffdf368ed152a6b3f81f6 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 23 Jul 2026 16:44:38 +0900 Subject: [PATCH 02/13] Create sign-in.tsx Co-authored-by: Jiwon Kwon --- packages/web/src/routes/sign-in.tsx | 72 +++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 packages/web/src/routes/sign-in.tsx diff --git a/packages/web/src/routes/sign-in.tsx b/packages/web/src/routes/sign-in.tsx new file mode 100644 index 0000000..562c92b --- /dev/null +++ b/packages/web/src/routes/sign-in.tsx @@ -0,0 +1,72 @@ +// 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, useSubmission } from "@solidjs/router"; +import { Show } from "solid-js"; + +const signin = action(async (formData: FormData) => { + "use server"; + + const email = formData.get("email"); + if (typeof email !== "string") { + return { ok: false, message: "이메일이 올바르지 않습니다." }; + } + + await fetch("http://0.0.0.0:8888/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: ` + mutation Login($email: Email!, $verifyUrl: URITemplate!) { loginByEmail(email: $email, verifyUrl: $verifyUrl) { token } } + `, + variables: { + email, + verifyUrl: "http://localhost:5173/confirm/{token}?code={code}", + }, + }), + }); + + return { ok: true, message: "인증 메일을 확인해 주세요." }; +}, "signin"); + +export default function SignInPage() { + const submission = useSubmission(signin); + + return ( + <> +
+ + +
+ + +

로그인 요청 완료

+

{submission.result?.message}

+
+ +
+
+
+ + ); +} From 1a51847ebf6e0357779c1cba6998d196b32800fd Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 23 Jul 2026 16:45:42 +0900 Subject: [PATCH 03/13] FIXME: Add allowlist Co-authored-by: Jiwon Kwon --- packages/graphql/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 115ec9a..87320c9 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -92,7 +92,9 @@ function mockTransport() { const fillOptions = (opt: YogaServerOptions): Required => ({ mailer: opt?.mailer ?? mockTransport(), emailFrom: opt?.emailFrom ?? "noreply@drfed.org", - origins: opt?.origins ?? new Set(["https://drfed.org"]), + // FIXME: Properly parametrize the following allowlist: + origins: + opt?.origins ?? new Set(["https://drfed.org", "http://localhost:5173"]), }); const getAccessToken = (headers: Headers) => From ab45e493fe8a0c8f974036d3456a6869b81ac3a2 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 23 Jul 2026 17:48:53 +0900 Subject: [PATCH 04/13] Turn off eslint/max-lines-per-function rule --- .oxlintrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.oxlintrc.json b/.oxlintrc.json index bb2d393..9126f90 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -29,6 +29,7 @@ "oxc/no-async-await": "off", "eslint/eqeqeq": ["off", "smart"], "eslint/func-style": ["off"], + "eslint/max-lines-per-function": "off", "eslint/id-length": ["warn", { "exceptionPatterns": ["^_", "^[Tertv]$"] }], "eslint/init-declarations": "off", "eslint/max-params": ["warn", { "max": 4 }], From 118ee83790c739a3d5394e9d7f11269b0159e451 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sat, 25 Jul 2026 14:04:49 +0900 Subject: [PATCH 05/13] Solve CORS Problem --- packages/graphql/src/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 87320c9..9e991b4 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -62,6 +62,10 @@ export function createYogaServer( ): YogaServerInstance { const options = fillOptions(_options); return createYoga({ + cors: { + origin: [...options.origins], + credentials: true, + }, async context(ctx) { const anonymous = { db, request: ctx.request, ...options }; const accessToken = getAccessToken(ctx.request.headers); @@ -94,7 +98,12 @@ const fillOptions = (opt: YogaServerOptions): Required => ({ emailFrom: opt?.emailFrom ?? "noreply@drfed.org", // FIXME: Properly parametrize the following allowlist: origins: - opt?.origins ?? new Set(["https://drfed.org", "http://localhost:5173"]), + opt?.origins ?? + new Set([ + "https://drfed.org", + "http://localhost:5173", + "http://0.0.0.0:5173", + ]), }); const getAccessToken = (headers: Headers) => From d05b1528d1fac6cb7f0d1025008181ce883559bc Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 23 Jul 2026 17:49:53 +0900 Subject: [PATCH 06/13] Add Sign-in Page --- packages/web/src/routes/sign-in.tsx | 62 ++++++++++++++++------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/packages/web/src/routes/sign-in.tsx b/packages/web/src/routes/sign-in.tsx index 562c92b..a5c82f6 100644 --- a/packages/web/src/routes/sign-in.tsx +++ b/packages/web/src/routes/sign-in.tsx @@ -14,42 +14,50 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { action, useSubmission } from "@solidjs/router"; -import { Show } from "solid-js"; +import { graphql } from "relay-runtime"; +import { type JSX, Show, createSignal } from "solid-js"; +import { createMutation } from "solid-relay"; -const signin = action(async (formData: FormData) => { - "use server"; +import type { SignInMutation } from "./__generated__/SignInMutation.graphql"; - const email = formData.get("email"); - if (typeof email !== "string") { - return { ok: false, message: "이메일이 올바르지 않습니다." }; +const signInMutation = graphql` + mutation SignInMutation($email: Email!, $verifyUrl: URITemplate) { + loginByEmail(email: $email, verifyUrl: $verifyUrl) { + token + } } +`; - await fetch("http://0.0.0.0:8888/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: ` - mutation Login($email: Email!, $verifyUrl: URITemplate!) { loginByEmail(email: $email, verifyUrl: $verifyUrl) { token } } - `, +export default function SignInPage() { + const [signIn] = createMutation(signInMutation); + const [message, setMessage] = createSignal(""); + + const handleSubmit: JSX.EventHandler = (e) => { + e.preventDefault(); + const email = new FormData(e.currentTarget).get("email"); + if (typeof email !== "string" || email === "") { + return; + } + + signIn({ variables: { email, - verifyUrl: "http://localhost:5173/confirm/{token}?code={code}", + verifyUrl: `${globalThis.location.origin}/confirm/{token}?code={code}`, }, - }), - }); - - return { ok: true, message: "인증 메일을 확인해 주세요." }; -}, "signin"); + onCompleted: (_response, errors) => { + const [error] = errors ?? []; -export default function SignInPage() { - const submission = useSubmission(signin); + setMessage(error?.message ?? "인증 메일을 확인해 주세요."); + }, + onError: (error) => { + setMessage(error.message); + }, + }); + }; return ( <> -
+
- +

로그인 요청 완료

-

{submission.result?.message}

+

{message()}

From 973ffdd44ea07643f6ea42b20449ca173e6817f0 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 23 Jul 2026 17:50:02 +0900 Subject: [PATCH 07/13] Add confirm/slug page --- packages/web/src/routes/confirm/[slug].tsx | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 packages/web/src/routes/confirm/[slug].tsx diff --git a/packages/web/src/routes/confirm/[slug].tsx b/packages/web/src/routes/confirm/[slug].tsx new file mode 100644 index 0000000..7fc61f6 --- /dev/null +++ b/packages/web/src/routes/confirm/[slug].tsx @@ -0,0 +1,71 @@ +// 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 { setCookie } from "@solidjs/start/http"; +import { graphql } from "relay-runtime"; +import { Show, onMount } from "solid-js"; +import { createMutation } from "solid-relay"; + +import type { CompleteLoginChallenge } from "./__generated__/CompleteLoginChallenge.graphql"; + +const CompleteLoginChallenge = graphql` + mutation CompleteLoginChallenge($token: UUID!, $code: String!) { + completeLoginChallenge(token: $token, code: $code) { + accessToken + } + } +`; + +export default function ConfirmPage() { + const params = useParams<{ slug: string }>(); + const [searchParams] = useSearchParams<{ code: string }>(); + const [completeLogin, isPending] = createMutation( + CompleteLoginChallenge, + ); + + onMount(() => { + const { slug: token } = params; + const { code } = searchParams; + + if ( + typeof token !== "string" || + token === "" || + typeof code !== "string" || + code === "" + ) { + return; + } + + completeLogin({ + variables: { token, code }, + onCompleted(data) { + const accessToken = data.completeLoginChallenge?.accessToken; + + if (accessToken == undefined) { + return; + } + setCookie("accessToken", accessToken, { path: "/" }); + }, + }); + }); + + return ( + 확인 중입니다...}> +
완료완료
+
+ ); +} From 17ef0b2fc00f4247aea7bf5371300d72756fd2d5 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sat, 25 Jul 2026 14:14:19 +0900 Subject: [PATCH 08/13] Add drfed.css --- packages/web/src/app.tsx | 1 + packages/web/src/drfed.css | 107 +++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 packages/web/src/drfed.css diff --git a/packages/web/src/app.tsx b/packages/web/src/app.tsx index f14a08c..9a70e5d 100644 --- a/packages/web/src/app.tsx +++ b/packages/web/src/app.tsx @@ -19,6 +19,7 @@ import { Router } from "@solidjs/router"; import { FileRoutes } from "@solidjs/start/router"; import { Suspense } from "solid-js"; +import "./drfed.css"; import "./app.css"; import { RelayEnvironmentProvider } from "solid-relay"; diff --git a/packages/web/src/drfed.css b/packages/web/src/drfed.css new file mode 100644 index 0000000..b306ab6 --- /dev/null +++ b/packages/web/src/drfed.css @@ -0,0 +1,107 @@ +/* +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 . +*/ + +:root { + color-scheme: light; + + /* Typography */ + --font-serif: + "Fraunces Variable", Fraunces, Georgia, "Times New Roman", serif; + --font-sans: + "Hanken Grotesk Variable", "Hanken Grotesk", system-ui, -apple-system, + "Segoe UI", sans-serif; + --font-mono: + "IBM Plex Mono", ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, + monospace; + --text-body: clamp(1rem, 0.96rem + 0.2vw, 1.0625rem); + --text-lede: clamp(1.15rem, 1.05rem + 0.5vw, 1.45rem); + --text-heading-1: clamp(2.7rem, 1.9rem + 3.9vw, 4.75rem); + --text-heading-2: clamp(1.9rem, 1.5rem + 1.9vw, 2.85rem); + --text-heading-3: 1.3rem; + --text-label: 0.78rem; + --leading-body: 1.65; + --leading-heading: 1.08; + --tracking-heading: -0.018em; + --tracking-label: 0.13em; + + /* Light palette */ + --bg: #fdfcfb; + --bg-tint: #f4f2f0; + --surface: #ffffff; + --surface-2: #f5f3f1; + --card: #ffffff; + --ink: #26211f; + --ink-soft: #6a615c; + --ink-faint: #998f89; + --accent: #e45b7d; + --accent-strong: #c23a5c; + --accent-soft: #f4a9b8; + --accent-tint: #f7e3e7; + --on-accent: #ffffff; + --line: #e9e5e1; + --line-strong: #dad4cf; + --danger: #cf3f43; + + /* Elevation */ + --shadow-sm: 0 1px 2px rgb(38 33 31 / 5%), 0 2px 8px rgb(38 33 31 / 4%); + --shadow-md: 0 4px 14px rgb(38 33 31 / 6%), 0 12px 36px rgb(38 33 31 / 7%); + + /* Layout */ + --container: 70rem; + --container-narrow: 48rem; + --gutter: clamp(1.25rem, 5vw, 3rem); + --section-space: clamp(4rem, 3rem + 6vw, 8rem); + --header-h: 4.25rem; + + /* Shape */ + --radius-sm: 0.5rem; + --radius: 0.875rem; + --radius-lg: 1.5rem; + --radius-pill: 999px; + + /* Motion */ + --duration-fast: 0.18s; + --duration-normal: 0.25s; + --ease-standard: ease; + --ease-reveal: cubic-bezier(0.2, 0.7, 0.2, 1); +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + + --bg: #181613; + --bg-tint: #1e1b17; + --surface: #201d19; + --surface-2: #272420; + --card: #201d19; + --ink: #f0ece8; + --ink-soft: #bab1aa; + --ink-faint: #887f78; + --accent: #ef6a88; + --accent-strong: #f78da4; + --accent-soft: #b34965; + --accent-tint: #322a27; + --line: #332f2a; + --line-strong: #453f39; + --danger: #ef726b; + + --shadow-sm: 0 1px 2px rgb(0 0 0 / 30%); + --shadow-md: 0 6px 24px rgb(0 0 0 / 40%); + } +} From 60159f0c42f71982360736744dc0d3f00b4016aa Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sat, 25 Jul 2026 16:01:42 +0900 Subject: [PATCH 09/13] =?UTF-8?q?Update=20Design=20of=20Sign-In=20Page=20U?= =?UTF-8?q?sed=20Skil:=20https://github.com/anthropics/claude-code/blob/ma?= =?UTF-8?q?in/plugins/frontend-design/skills/frontend-design/SKILL.md=20Us?= =?UTF-8?q?er=20Prompt:=20```=20$frontend-design=20=EC=9A=B0=EB=A6=AC=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=EB=8A=94=20https://drfed.org/=20?= =?UTF-8?q?=EC=99=80=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=96=B8=EC=96=B4?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=B5=EC=9C=A0=ED=95=B4.=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=20drfed.css=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EB=8B=A4=20=EB=86=93?= =?UTF-8?q?=EC=95=98=EC=96=B4.=20sign-in.tsx=EB=A5=BC,=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=EC=9D=84=20=EC=9E=85=EB=A0=A5=EB=B0=9B?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=8F=BC=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=EC=84=9C=20=EC=9E=91=EB=8F=99=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=94=94=EC=9E=90=EC=9D=B8=20=ED=95=B4=EC=A4=98.?= =?UTF-8?q?=20UI=20=EC=96=B8=EC=96=B4=EB=8A=94=20=EC=98=81=EC=96=B4?= =?UTF-8?q?=EA=B0=80=20=EB=90=98=EC=96=B4=EC=95=BC=20=ED=95=B4.=20$fronten?= =?UTF-8?q?d-design=20=EB=84=88=EB=AC=B4=20=EB=9E=9C=EB=94=A9=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B0=99=EC=9E=96=EC=95=84.=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=97=90=20=EC=A4=91=EC=8B=AC=EC=9D=84=20=EB=91=90?= =?UTF-8?q?=EC=96=B4=EC=A4=98.=20=EA=B7=B8=EB=A6=AC=EA=B3=A0=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=EB=82=98=20=EC=A0=84=EC=B2=B4=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EA=B0=99=EC=9D=80=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9A=94=EC=86=8C=EB=8F=84=20=EC=A7=80=EA=B8=88=20=EB=8B=A4=20?= =?UTF-8?q?=ED=99=95=EB=A6=BD=ED=95=B4=EC=A4=98.=20=EC=A0=81=EC=A0=88?= =?UTF-8?q?=ED=95=9C=20=EC=97=AC=EB=B0=B1=EC=9D=B4=20=EC=97=86=EC=96=B4?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EA=B8=B0=EA=B0=80=20=EB=B6=88=ED=8E=B8?= =?UTF-8?q?=ED=95=B4=20=EC=88=98=EC=A0=95=ED=95=B4=EC=A4=98.=20Send=20sign?= =?UTF-8?q?-in=20link=EA=B0=80=20=EC=99=84=EB=A3=8C=EB=90=98=EB=A9=B4,=20R?= =?UTF-8?q?esend=EB=A1=9C=20=EB=A9=94=EC=84=B8=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EB=8D=98=EA=B0=80=20=ED=95=B4=EC=95=BC?= =?UTF-8?q?=ED=95=A0=20=EA=B2=83=20=EA=B0=99=EC=95=84.=20```?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Codex:gpt-5.6-Sol --- packages/web/src/app.css | 306 +++++++++++++++++++++++++--- packages/web/src/app.tsx | 35 +++- packages/web/src/routes/sign-in.tsx | 85 +++++--- 3 files changed, 372 insertions(+), 54 deletions(-) diff --git a/packages/web/src/app.css b/packages/web/src/app.css index 8187644..98da414 100644 --- a/packages/web/src/app.css +++ b/packages/web/src/app.css @@ -16,44 +16,300 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ +* { + box-sizing: border-box; +} + +html { + background: var(--bg); + color: var(--ink); + font-family: var(--font-sans); + font-size: var(--text-body); +} + body { - font-family: - Gordita, Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", - sans-serif; + margin: 0; +} + +button, +input { + font: inherit; +} + +.app-shell { + min-height: 100svh; +} + +.app-header { + background: color-mix(in srgb, var(--bg) 92%, transparent); + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 10; +} + +.app-header-inner { + align-items: center; + display: flex; + height: var(--header-h); + margin: 0 auto; + max-width: var(--container); + padding: 0 var(--gutter); +} + +.brand { + align-items: center; + color: var(--ink); + display: flex; + font-family: var(--font-serif); + font-size: 1.25rem; + font-weight: 700; + gap: 0.65rem; + letter-spacing: var(--tracking-heading); + text-decoration: none; +} + +.brand img { + border-radius: 50%; +} + +.app-nav { + align-self: stretch; + display: flex; + gap: 0.25rem; + margin-left: clamp(1.5rem, 5vw, 4rem); +} + +.app-nav a, +.header-action { + align-items: center; + color: var(--ink-soft); + display: flex; + font-size: 0.9rem; + font-weight: 600; + padding: 0 0.85rem; + position: relative; + text-decoration: none; } -a { - margin-right: 1rem; +.app-nav a:hover, +.header-action:hover, +.app-nav a.is-active, +.header-action.is-active { + color: var(--ink); } -main { - text-align: center; - padding: 1em; +.app-nav a.is-active::after { + background: var(--accent); + bottom: -1px; + content: ""; + height: 3px; + left: 0.85rem; + position: absolute; + right: 0.85rem; +} + +.header-action { + border: 1px solid var(--line-strong); + border-radius: var(--radius-pill); + margin-left: auto; + min-height: 2.4rem; +} + +.header-action.is-active { + background: var(--accent-tint); + border-color: var(--accent-soft); +} + +.app-header a:focus-visible, +.button:focus-visible { + outline: 3px solid var(--accent-soft); + outline-offset: 0.2rem; +} + +.app-content { margin: 0 auto; + max-width: var(--container); + min-height: calc(100svh - var(--header-h)); + padding: clamp(2rem, 5vw, 4rem) var(--gutter); +} + +.app-content > main:not(.auth-page) { + width: 100%; +} + +.app-content > main:not(.auth-page) :is(h1, h2, p) { + margin-top: 0; +} + +.app-content h1, +.app-content h2 { + font-family: var(--font-serif); + letter-spacing: var(--tracking-heading); +} + +.app-content h1 { + font-size: var(--text-heading-2); + line-height: var(--leading-heading); +} + +.app-content p { + color: var(--ink-soft); + line-height: var(--leading-body); +} + +.auth-page { + align-items: center; + display: flex; + justify-content: center; + min-height: calc(100svh - var(--header-h) - clamp(4rem, 10vw, 8rem)); +} + +.panel { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); +} + +.auth-panel { + max-width: 28rem; + padding: clamp(1.5rem, 4vw, 2rem); + width: 100%; +} + +.panel-header { + border-bottom: 1px solid var(--line); + margin: 0 calc(clamp(1.5rem, 4vw, 2rem) * -1) 1.5rem; + padding: 0 clamp(1.5rem, 4vw, 2rem) 1.5rem; +} + +.panel-header h1 { + font-size: 1.65rem; + margin: 0 0 0.5rem; +} + +.panel-header p { + font-size: 0.95rem; + margin: 0; +} + +.auth-panel form { + display: grid; + gap: 1rem; +} + +.field { + display: grid; + font-size: 0.9rem; + font-weight: 650; + gap: 0.75rem; +} + +.field input { + background: var(--surface-2); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + color: var(--ink); + min-width: 0; + padding: 0.9rem 1rem; + transition: + border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.field input::placeholder { + color: var(--ink-faint); +} + +.field input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-tint); + outline: none; +} + +.button { + align-items: center; + border: 0; + border-radius: var(--radius-sm); + cursor: pointer; + display: inline-flex; + font-weight: 700; + justify-content: center; + min-height: 2.9rem; + padding: 0.75rem 1.1rem; } -h1 { - color: #335d92; - text-transform: uppercase; - font-size: 4rem; - font-weight: 100; - line-height: 1.1; - margin: 4rem auto; - max-width: 14rem; +.button.primary { + background: var(--accent); + color: var(--on-accent); + transition: + background var(--duration-fast) var(--ease-standard), + transform var(--duration-fast) var(--ease-standard); } -p { - max-width: 14rem; - margin: 2rem auto; - line-height: 1.35; +.button.primary:hover:not(:disabled) { + background: var(--accent-strong); } -@media (min-width: 480px) { - h1 { - max-width: none; +.button:disabled { + cursor: wait; + opacity: 0.65; +} + +.notice { + background: var(--accent-tint); + border-radius: var(--radius-sm); + color: var(--ink); + line-height: 1.5; + margin: 1.25rem 0 0; + padding: 0.85rem 1rem; +} + +.notice.error { + border-left: 3px solid var(--danger); +} + +.notice.success { + border-left: 3px solid var(--accent); +} + +@media (max-width: 600px) { + .app-header-inner { + padding: 0 1rem; } - p { - max-width: none; + .brand span { + display: none; + } + + .app-nav { + margin-left: 0.75rem; + } + + .app-nav a, + .header-action { + padding-inline: 0.65rem; + } + + .app-nav a.is-active::after { + left: 0.65rem; + right: 0.65rem; + } + + .app-content { + padding: 1.5rem 1rem 3rem; + } + + .auth-page { + align-items: flex-start; + min-height: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .button.primary, + .field input { + transition: none; } } diff --git a/packages/web/src/app.tsx b/packages/web/src/app.tsx index 9a70e5d..f6373c9 100644 --- a/packages/web/src/app.tsx +++ b/packages/web/src/app.tsx @@ -15,7 +15,7 @@ // along with this program. If not, see . import { MetaProvider, Title } from "@solidjs/meta"; -import { Router } from "@solidjs/router"; +import { A, Router } from "@solidjs/router"; import { FileRoutes } from "@solidjs/start/router"; import { Suspense } from "solid-js"; @@ -33,10 +33,35 @@ export default function App() { ( - SolidStart - Basic - Index - About - {props.children} + DrFed +
+
+ +
+
+ {props.children} +
+
)} > diff --git a/packages/web/src/routes/sign-in.tsx b/packages/web/src/routes/sign-in.tsx index a5c82f6..760f3ec 100644 --- a/packages/web/src/routes/sign-in.tsx +++ b/packages/web/src/routes/sign-in.tsx @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // 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"; @@ -29,8 +30,20 @@ const signInMutation = graphql` `; export default function SignInPage() { - const [signIn] = createMutation(signInMutation); - const [message, setMessage] = createSignal(""); + 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"; + }; const handleSubmit: JSX.EventHandler = (e) => { e.preventDefault(); @@ -39,6 +52,7 @@ export default function SignInPage() { return; } + setResult(); signIn({ variables: { email, @@ -47,34 +61,57 @@ export default function SignInPage() { onCompleted: (_response, errors) => { const [error] = errors ?? []; - setMessage(error?.message ?? "인증 메일을 확인해 주세요."); + setResult({ + message: + error?.message ?? + "Check your inbox for a secure sign-in link. You can close this page.", + status: error === undefined ? "success" : "error", + }); }, onError: (error) => { - setMessage(error.message); + setResult({ message: error.message, status: "error" }); }, }); }; return ( - <> -
- - -
- - -

로그인 요청 완료

-

{message()}

-
- -
-
-
- +
+ Sign in — DrFed + +
+
+

Sign in

+

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

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

+ {formResult().message} +

+ )} +
+
+
); } From 87c9fff280ef4896b62644cc7c85a8f12209da83 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 26 Jul 2026 02:05:20 +0900 Subject: [PATCH 10/13] Enable Temporal in @drfed/web --- packages/web/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index fe104ea..46a2616 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "lib": ["DOM", "DOM.Iterable"], + "lib": ["DOM", "DOM.Iterable", "ESNext"], "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", From 7629b0cdd62ce84290df95dfa78358d3d04c92c2 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 26 Jul 2026 19:14:03 +0900 Subject: [PATCH 11/13] Add session --- packages/web/src/routes/confirm/[slug].tsx | 79 ++++++++++++++++------ packages/web/src/routes/session.ts | 74 ++++++++++++++++++++ 2 files changed, 132 insertions(+), 21 deletions(-) create mode 100644 packages/web/src/routes/session.ts diff --git a/packages/web/src/routes/confirm/[slug].tsx b/packages/web/src/routes/confirm/[slug].tsx index 7fc61f6..64d4325 100644 --- a/packages/web/src/routes/confirm/[slug].tsx +++ b/packages/web/src/routes/confirm/[slug].tsx @@ -15,57 +15,94 @@ // along with this program. If not, see . import { useParams, useSearchParams } from "@solidjs/router"; -import { setCookie } from "@solidjs/start/http"; import { graphql } from "relay-runtime"; -import { Show, onMount } from "solid-js"; +import { Show, createSignal, onMount } from "solid-js"; import { createMutation } from "solid-relay"; import type { CompleteLoginChallenge } from "./__generated__/CompleteLoginChallenge.graphql"; -const 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 [completeLogin, isPending] = createMutation( - CompleteLoginChallenge, - ); + 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 { slug: token } = params; const { code } = searchParams; + const { slug: token } = params; - if ( - typeof token !== "string" || - token === "" || - typeof code !== "string" || - code === "" - ) { + if (token === "" || code == undefined || code === "") { return; } - completeLogin({ + 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 accessToken = data.completeLoginChallenge?.accessToken; - - if (accessToken == undefined) { + 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; } - setCookie("accessToken", accessToken, { path: "/" }); + + 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/session.ts b/packages/web/src/routes/session.ts new file mode 100644 index 0000000..5b0dad8 --- /dev/null +++ b/packages/web/src/routes/session.ts @@ -0,0 +1,74 @@ +// 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 type { APIEvent } from "@solidjs/start/server"; + +// Note: setCookie(nativeEvent, ...) from @solidjs/start/http is intentionally +// NOT used here. In @solidjs/start 2.0.0-alpha.2, that function produces a +// malformed Set-Cookie header in both "use server" RPCs and POST API route +// handlers — the cookie name becomes "[METHOD] URL" instead of the intended +// name. +export async function POST({ request }: APIEvent) { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(undefined, { status: 400 }); + } + if (typeof body !== "object" || body == undefined) { + return new Response(undefined, { status: 400 }); + } + + const accessToken = + "accessToken" in body && typeof body.accessToken === "string" + ? body.accessToken + : undefined; + const expiresValue = + "expires" in body && typeof body.expires === "string" + ? body.expires + : undefined; + + // FIXME: Accesstoken validation + if (accessToken == undefined || expiresValue == undefined) { + return new Response(undefined, { status: 400 }); + } + + let expires: Temporal.Instant; + + try { + expires = Temporal.Instant.from(expiresValue); + } catch { + return new Response(undefined, { status: 400 }); + } + + if (expires.epochNanoseconds <= Temporal.Now.instant().epochNanoseconds) { + return new Response(undefined, { status: 400 }); + } + + const cookie = [ + `session=${encodeURIComponent(accessToken)}`, + "HttpOnly", + "Path=/", + "SameSite=Lax", + `Expires=${new Date(expires.epochMilliseconds).toUTCString()}`, + ...(new URL(request.url).protocol === "https:" ? ["Secure"] : []), + ].join("; "); + + return new Response(undefined, { + status: 204, + headers: { "Set-Cookie": cookie }, + }); +} From 86a57be6105064c28fd1c8bba1708e1a18e18ae0 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 26 Jul 2026 21:47:50 +0900 Subject: [PATCH 12/13] Authenticate Relay requests with session Cookie Valiedate and read session cookie --- packages/web/src/RelayEnviroment.ts | 16 ++++++++--- packages/web/src/routes/index.tsx | 2 +- packages/web/src/routes/session.ts | 41 +++++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/web/src/RelayEnviroment.ts b/packages/web/src/RelayEnviroment.ts index 0f4c953..0f6bfb9 100644 --- a/packages/web/src/RelayEnviroment.ts +++ b/packages/web/src/RelayEnviroment.ts @@ -21,18 +21,28 @@ import { RecordSource, Store, } from "relay-runtime"; +import { getRequestEvent } from "solid-js/web"; + +import { readSessionCookie } from "./routes/session.ts"; // oxlint-disable no-async-await const fetchFn: FetchFunction = async (params, variables) => { + const event = getRequestEvent(); + const accessToken = readSessionCookie(event?.request); + const headers: Record = { + "Content-Type": "application/json", + }; + if (accessToken !== undefined) { + headers.Authorization = `Bearer ${accessToken}`; + } const response = await fetch(import.meta.env.VITE_DRFED_URL, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers, body: JSON.stringify({ query: params.text, variables, }), + credentials: "include" }); // oxlint-disable return-await no-unsafe-return diff --git a/packages/web/src/routes/index.tsx b/packages/web/src/routes/index.tsx index 3db2993..35f3258 100644 --- a/packages/web/src/routes/index.tsx +++ b/packages/web/src/routes/index.tsx @@ -37,7 +37,7 @@ export default function Home() {
Hello World

Hello world!

- 로그인되지 않았습니다.

}> + You're not signed in.

}> {(viewer) => (

{viewer().name} diff --git a/packages/web/src/routes/session.ts b/packages/web/src/routes/session.ts index 5b0dad8..8e1c11a 100644 --- a/packages/web/src/routes/session.ts +++ b/packages/web/src/routes/session.ts @@ -16,6 +16,9 @@ import type { APIEvent } from "@solidjs/start/server"; +const SESSION_COOKIE = "session"; +const ACCESS_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u; + // Note: setCookie(nativeEvent, ...) from @solidjs/start/http is intentionally // NOT used here. In @solidjs/start 2.0.0-alpha.2, that function produces a // malformed Set-Cookie header in both "use server" RPCs and POST API route @@ -41,11 +44,14 @@ export async function POST({ request }: APIEvent) { ? body.expires : undefined; - // FIXME: Accesstoken validation if (accessToken == undefined || expiresValue == undefined) { return new Response(undefined, { status: 400 }); } + if (!ACCESS_TOKEN_PATTERN.test(accessToken)) { + return new Response(undefined, { status: 400 }); + } + let expires: Temporal.Instant; try { @@ -59,7 +65,7 @@ export async function POST({ request }: APIEvent) { } const cookie = [ - `session=${encodeURIComponent(accessToken)}`, + `${SESSION_COOKIE}=${encodeURIComponent(accessToken)}`, "HttpOnly", "Path=/", "SameSite=Lax", @@ -72,3 +78,34 @@ export async function POST({ request }: APIEvent) { headers: { "Set-Cookie": cookie }, }); } + +export function readSessionCookie( + request: Request | undefined, +): string | undefined { + const cookieHeader = request?.headers.get("cookie"); + + if (cookieHeader == undefined || cookieHeader == "") { + return undefined; + } + for (const part of cookieHeader.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) { + continue; + } + if (part.slice(0, eq).trim() !== SESSION_COOKIE) { + continue; + } + const raw = part.slice(eq + 1).trim(); + if (raw === "") { + return undefined; + } + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + return undefined; + } + return ACCESS_TOKEN_PATTERN.test(decoded) ? decoded : undefined; + } + return undefined; +} From 591fe173da758eb3ef8ec79cb4cf7f587d8b9717 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Tue, 28 Jul 2026 14:47:09 +0900 Subject: [PATCH 13/13] Change placeholder texts to DrFed Co-authored-by: Jiwon Kwon --- packages/web/src/RelayEnviroment.ts | 2 +- packages/web/src/routes/[...404].tsx | 8 ++++---- packages/web/src/routes/about.tsx | 8 +++++++- packages/web/src/routes/index.tsx | 18 ++++++------------ 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/web/src/RelayEnviroment.ts b/packages/web/src/RelayEnviroment.ts index 0f6bfb9..66e8f94 100644 --- a/packages/web/src/RelayEnviroment.ts +++ b/packages/web/src/RelayEnviroment.ts @@ -42,7 +42,7 @@ const fetchFn: FetchFunction = async (params, variables) => { query: params.text, variables, }), - credentials: "include" + credentials: "include", }); // oxlint-disable return-await no-unsafe-return diff --git a/packages/web/src/routes/[...404].tsx b/packages/web/src/routes/[...404].tsx index 25708e8..b5e0971 100644 --- a/packages/web/src/routes/[...404].tsx +++ b/packages/web/src/routes/[...404].tsx @@ -25,10 +25,10 @@ export default function NotFound() {

Page Not Found

Visit{" "} - - start.solidjs.com - {" "} - to learn how to build SolidStart apps. + + DrFed + + : A web-based platform for developing and debugging ActivityPub apps

); diff --git a/packages/web/src/routes/about.tsx b/packages/web/src/routes/about.tsx index 34dbe99..84ec70c 100644 --- a/packages/web/src/routes/about.tsx +++ b/packages/web/src/routes/about.tsx @@ -20,7 +20,13 @@ export default function About() { return (
About -

About

+

+ Visit{" "} + + DrFed + + : A web-based platform for developing and debugging ActivityPub apps +

); } diff --git a/packages/web/src/routes/index.tsx b/packages/web/src/routes/index.tsx index 35f3258..a08e0ee 100644 --- a/packages/web/src/routes/index.tsx +++ b/packages/web/src/routes/index.tsx @@ -35,23 +35,17 @@ export default function Home() { return (
- Hello World -

Hello world!

+ Home You're not signed in.

}> - {(viewer) => ( -

- {viewer().name} - {viewer().admin ? " (관리자)" : ""} -

- )} + {(viewer) =>

{viewer().name}

}

Visit{" "} - - start.solidjs.com - {" "} - to learn how to build SolidStart apps. + + DrFed + + : A web-based platform for developing and debugging ActivityPub apps

);