From 43adb0a76bcf766c620b4ae4074fe28ebbd39936 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Tue, 8 Sep 2026 15:49:32 +0900 Subject: [PATCH 01/14] Add deleteSessionCookie() --- packages/web/src/session.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/web/src/session.ts b/packages/web/src/session.ts index 696fba2..0024f5a 100644 --- a/packages/web/src/session.ts +++ b/packages/web/src/session.ts @@ -16,7 +16,11 @@ // oxlint-disable-next-line import/no-unassigned-import -- Environment marker. import "server-only"; -import { getRequestProtocol, setCookie } from "@solidjs/start/http"; +import { + deleteCookie, + getRequestProtocol, + setCookie, +} from "@solidjs/start/http"; const SESSION_COOKIE = "session"; const ACCESS_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u; @@ -48,3 +52,7 @@ export function setSessionCookie( secure: getRequestProtocol() === "https", }); } + +export function deleteSessionCookie(): void { + deleteCookie(SESSION_COOKIE, { path: "/" }); +} From 898220a123960646925f2f2d8e81c6f2b5e2a217 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Tue, 8 Sep 2026 15:49:53 +0900 Subject: [PATCH 02/14] Turn off eslint/require-await and typescript/require-await --- packages/web/.oxlintrc.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/.oxlintrc.json b/packages/web/.oxlintrc.json index 39e48ca..95f0e84 100644 --- a/packages/web/.oxlintrc.json +++ b/packages/web/.oxlintrc.json @@ -7,6 +7,7 @@ "es2022": true }, "rules": { + "eslint/require-await": "off", "solid/components-return-once": "warn", "solid/event-handlers": "warn", "solid/imports": "warn", @@ -29,6 +30,7 @@ "typescript/explicit-function-return-type": "off", "typescript/strict-void-return": "off", "typescript/no-non-null-assertion": "off", + "typescript/require-await": "off", "unicorn/filename-case": "off", "unicorn/prefer-query-selector": "off", "promise/avoid-new": "off" From 967d0581cac51b41e69b275b258aed1cffbb1309 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Tue, 8 Sep 2026 15:50:07 +0900 Subject: [PATCH 03/14] Apply HeaderAccountButton --- packages/web/src/app.tsx | 11 +-- .../src/components/HeaderAccountButton.tsx | 79 +++++++++++++++++++ 2 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 packages/web/src/components/HeaderAccountButton.tsx diff --git a/packages/web/src/app.tsx b/packages/web/src/app.tsx index 0dd0bbf..0ce0c5f 100644 --- a/packages/web/src/app.tsx +++ b/packages/web/src/app.tsx @@ -17,12 +17,13 @@ import { MetaProvider, Title } from "@solidjs/meta"; import { A, Router } from "@solidjs/router"; import { FileRoutes } from "@solidjs/start/router"; -import { Suspense } from "solid-js"; import "./styles/drfed.css"; import "./styles/app.css"; +import { Suspense } from "solid-js"; import { RelayEnvironmentProvider } from "solid-relay"; +import { HeaderAccountButton } from "./components/HeaderAccountButton.tsx"; import { createRelayEnvironment } from "./RelayEnvironment.ts"; import styles from "./styles/app.module.css"; @@ -54,13 +55,7 @@ export default function App() { About - - Sign in - +
diff --git a/packages/web/src/components/HeaderAccountButton.tsx b/packages/web/src/components/HeaderAccountButton.tsx new file mode 100644 index 0000000..a1964e6 --- /dev/null +++ b/packages/web/src/components/HeaderAccountButton.tsx @@ -0,0 +1,79 @@ +// 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 { Button } from "@kobalte/core/button"; +import { A, action, useAction, useSubmission } from "@solidjs/router"; +import { graphql } from "relay-runtime"; +import { Show, Suspense } from "solid-js"; +import { createLazyLoadQuery } from "solid-relay"; + +import { deleteSessionCookie } from "~/session.ts"; + +import type { HeaderAccountButtonQuery } from "./__generated__/HeaderAccountButtonQuery.graphql.ts"; + +import styles from "~/styles/app.module.css"; + +const signOutAction = action(async () => { + "use server"; + deleteSessionCookie(); +}, "sign-out"); + +export function HeaderAccountButton() { + const signOut = useAction(signOutAction); + const submission = useSubmission(signOutAction); + + const query = createLazyLoadQuery( + graphql` + query HeaderAccountButtonQuery { + viewer { + name + } + } + `, + {}, + ); + + async function handleSignOut() { + await signOut(); + globalThis.location.replace("/"); + } + + return ( + + + {(data) => ( + + Sign in + + } + > + + + )} + + + ); +} From 853bdf821fcc03c129957625a2484cafcd25ccb8 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Tue, 8 Sep 2026 16:20:13 +0900 Subject: [PATCH 04/14] Remove argument from RevokeSession Mutation --- packages/graphql/src/auth.test.ts | 5 ++--- packages/graphql/src/auth/revoke.ts | 15 ++++++--------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index 330e6cf..6a194ac 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -92,8 +92,8 @@ const viewerQuery = ` `; const revokeSessionMutation = ` - mutation RevokeSession($session: UUID!) { - revokeSession(session: $session) { + mutation RevokeSession { + revokeSession { revoke } } @@ -363,7 +363,6 @@ describe("email authentication", () => { const revokeResponse = await post( { query: revokeSessionMutation, - variables: { session: session.id }, }, authorization, ); diff --git a/packages/graphql/src/auth/revoke.ts b/packages/graphql/src/auth/revoke.ts index bd9a4c8..d171f4d 100644 --- a/packages/graphql/src/auth/revoke.ts +++ b/packages/graphql/src/auth/revoke.ts @@ -26,16 +26,13 @@ builder.mutationFields((t) => ({ revokeSession: t.field({ type: LogoutSuccessRef, description: "Revokes a session. Return always `revoke: true`.", - args: { - session: t.arg({ - type: "UUID", - required: true, - description: "The session ID to revoke.", - }), + authScopes: { + authenticated: true, }, - async resolve(_query, { session }, ctx) { - if (ctx.session != null) { - await deleteSession(session, ctx); + async resolve(_query, _, ctx) { + const sessionId = ctx.session?.id; + if (sessionId) { + await deleteSession(sessionId, ctx); } // Return always true to prevent brute-force attack. return { revoke: true }; From d2ccebc4fa78b8aec71c744d20926a08a50831d4 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 9 Sep 2026 00:00:15 +0900 Subject: [PATCH 05/14] Revoke Session when Sign out --- .../src/components/HeaderAccountButton.tsx | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/HeaderAccountButton.tsx b/packages/web/src/components/HeaderAccountButton.tsx index a1964e6..e57c61a 100644 --- a/packages/web/src/components/HeaderAccountButton.tsx +++ b/packages/web/src/components/HeaderAccountButton.tsx @@ -16,19 +16,78 @@ import { Button } from "@kobalte/core/button"; import { A, action, useAction, useSubmission } from "@solidjs/router"; -import { graphql } from "relay-runtime"; +import { commitMutation, graphql } from "relay-runtime"; import { Show, Suspense } from "solid-js"; import { createLazyLoadQuery } from "solid-relay"; +import { createRelayEnvironment } from "~/RelayEnvironment.ts"; import { deleteSessionCookie } from "~/session.ts"; import type { HeaderAccountButtonQuery } from "./__generated__/HeaderAccountButtonQuery.graphql.ts"; +import type { RevokeSession } from "./__generated__/RevokeSession.graphql.ts"; import styles from "~/styles/app.module.css"; +const revokeSessionMutation = graphql` + mutation RevokeSession { + revokeSession { + revoke + } + } +`; + +interface revokeSessionResult { + message: string; + status: "error" | "success"; +} + const signOutAction = action(async () => { "use server"; + + const environment = createRelayEnvironment(); + deleteSessionCookie(); + + const result = await new Promise((resolve) => { + commitMutation(environment, { + mutation: revokeSessionMutation, + variables: {}, + onCompleted: (response, errors) => { + const graphQLErrors = errors ?? []; + + if (graphQLErrors.length > 0) { + resolve({ + message: graphQLErrors.map((error) => error.message).join("\n"), + status: "error", + }); + return; + } + + try { + deleteSessionCookie(); + } catch { + resolve({ + message: "Unable to delete session in cookie", + status: "error", + }); + return; + } + + resolve({ + message: "Signed Out", + status: "success", + }); + }, + onError: (error) => { + resolve({ + message: error.message, + status: "error", + }); + }, + }); + }); + + return result; }, "sign-out"); export function HeaderAccountButton() { From 9181a4501fa34a2fd6cd6d8d9a68e942a86b9c0e Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 9 Sep 2026 00:08:44 +0900 Subject: [PATCH 06/14] Fix deleteSession Cookie logic --- packages/web/src/components/HeaderAccountButton.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/HeaderAccountButton.tsx b/packages/web/src/components/HeaderAccountButton.tsx index e57c61a..4418539 100644 --- a/packages/web/src/components/HeaderAccountButton.tsx +++ b/packages/web/src/components/HeaderAccountButton.tsx @@ -46,8 +46,6 @@ const signOutAction = action(async () => { const environment = createRelayEnvironment(); - deleteSessionCookie(); - const result = await new Promise((resolve) => { commitMutation(environment, { mutation: revokeSessionMutation, @@ -93,7 +91,6 @@ const signOutAction = action(async () => { export function HeaderAccountButton() { const signOut = useAction(signOutAction); const submission = useSubmission(signOutAction); - const query = createLazyLoadQuery( graphql` query HeaderAccountButtonQuery { @@ -106,8 +103,10 @@ export function HeaderAccountButton() { ); async function handleSignOut() { - await signOut(); - globalThis.location.replace("/"); + const result = await signOut(); + if (result.status === "success") { + globalThis.location.replace("/"); + } } return ( From 844e077e23497050ec1f98e86edf8499abcf724e Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 9 Sep 2026 00:23:46 +0900 Subject: [PATCH 07/14] Add Error Indicator --- .../src/components/HeaderAccountButton.tsx | 70 ++++++++++++------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/packages/web/src/components/HeaderAccountButton.tsx b/packages/web/src/components/HeaderAccountButton.tsx index 4418539..7ead144 100644 --- a/packages/web/src/components/HeaderAccountButton.tsx +++ b/packages/web/src/components/HeaderAccountButton.tsx @@ -17,7 +17,7 @@ import { Button } from "@kobalte/core/button"; import { A, action, useAction, useSubmission } from "@solidjs/router"; import { commitMutation, graphql } from "relay-runtime"; -import { Show, Suspense } from "solid-js"; +import { ErrorBoundary, Show, Suspense, createSignal } from "solid-js"; import { createLazyLoadQuery } from "solid-relay"; import { createRelayEnvironment } from "~/RelayEnvironment.ts"; @@ -91,6 +91,7 @@ const signOutAction = action(async () => { export function HeaderAccountButton() { const signOut = useAction(signOutAction); const submission = useSubmission(signOutAction); + const [result, setResult] = createSignal(); const query = createLazyLoadQuery( graphql` query HeaderAccountButtonQuery { @@ -103,35 +104,52 @@ export function HeaderAccountButton() { ); async function handleSignOut() { - const result = await signOut(); - if (result.status === "success") { - globalThis.location.replace("/"); + try { + const signOutResult = await signOut(); + setResult(signOutResult); + + if (signOutResult.status === "success") { + globalThis.location.replace("/"); + } + } catch (error) { + setResult({ + message: error instanceof Error ? error.message : "Unable to sign out.", + status: "error", + }); } } return ( - - - {(data) => ( - - Sign in - - } - > -
}> + + {(data) => ( + + Sign in + + } > - Sign out - - - )} - - + + + )} + + + ); } From 96d248b570522d7ca546d704386d185cad899eeb Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 9 Sep 2026 00:56:19 +0900 Subject: [PATCH 08/14] Remove unecccessary fallback --- packages/web/src/components/HeaderAccountButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/HeaderAccountButton.tsx b/packages/web/src/components/HeaderAccountButton.tsx index 7ead144..3c6954d 100644 --- a/packages/web/src/components/HeaderAccountButton.tsx +++ b/packages/web/src/components/HeaderAccountButton.tsx @@ -121,7 +121,7 @@ export function HeaderAccountButton() { return ( <>}> - Signing Out..}> + }> {(data) => ( Date: Wed, 9 Sep 2026 18:01:40 +0900 Subject: [PATCH 09/14] Add new test scenario to auth.test.ts --- packages/graphql/src/auth.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index 6a194ac..a3e335e 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -381,4 +381,28 @@ describe("email authentication", () => { }); }); }); + + it("not sign in, and reovkes the session", async () => { + await withTestHarness(async ({ db, mailer: _, post }) => { + await db.insert(schema.accounts).values({ + id: accountId, + email, + name: "Sign Out Test", + }); + + const revokeResponse = await post({ + query: revokeSessionMutation, + }); + equal(revokeResponse.status, okStatus); + + const responseData = await revokeResponse.json(); + + equal(responseData.data, null); + + const error = responseData.errors[0]; + + equal(error?.message, "Not authorized to resolve Mutation.revokeSession"); + equal(error?.path[0], "revokeSession"); + }); + }); }); From 5bfbbb497bd575714cbdf757264fde26de5623f1 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 9 Sep 2026 18:11:02 +0900 Subject: [PATCH 10/14] Organize HeaderAccountButton --- .../src/components/HeaderAccountButton.tsx | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/web/src/components/HeaderAccountButton.tsx b/packages/web/src/components/HeaderAccountButton.tsx index 3c6954d..c371b1c 100644 --- a/packages/web/src/components/HeaderAccountButton.tsx +++ b/packages/web/src/components/HeaderAccountButton.tsx @@ -17,7 +17,7 @@ import { Button } from "@kobalte/core/button"; import { A, action, useAction, useSubmission } from "@solidjs/router"; import { commitMutation, graphql } from "relay-runtime"; -import { ErrorBoundary, Show, Suspense, createSignal } from "solid-js"; +import { ErrorBoundary, Show, Suspense } from "solid-js"; import { createLazyLoadQuery } from "solid-relay"; import { createRelayEnvironment } from "~/RelayEnvironment.ts"; @@ -50,7 +50,7 @@ const signOutAction = action(async () => { commitMutation(environment, { mutation: revokeSessionMutation, variables: {}, - onCompleted: (response, errors) => { + onCompleted: (_response, errors) => { const graphQLErrors = errors ?? []; if (graphQLErrors.length > 0) { @@ -65,7 +65,7 @@ const signOutAction = action(async () => { deleteSessionCookie(); } catch { resolve({ - message: "Unable to delete session in cookie", + message: "Unable to delete cookie", status: "error", }); return; @@ -91,7 +91,6 @@ const signOutAction = action(async () => { export function HeaderAccountButton() { const signOut = useAction(signOutAction); const submission = useSubmission(signOutAction); - const [result, setResult] = createSignal(); const query = createLazyLoadQuery( graphql` query HeaderAccountButtonQuery { @@ -104,18 +103,10 @@ export function HeaderAccountButton() { ); async function handleSignOut() { - try { - const signOutResult = await signOut(); - setResult(signOutResult); + const signOutResult = await signOut(); - if (signOutResult.status === "success") { - globalThis.location.replace("/"); - } - } catch (error) { - setResult({ - message: error instanceof Error ? error.message : "Unable to sign out.", - status: "error", - }); + if (signOutResult.status === "success") { + globalThis.location.replace("/"); } } @@ -127,7 +118,11 @@ export function HeaderAccountButton() { + Sign in } @@ -137,14 +132,16 @@ export function HeaderAccountButton() { class={styles.headerAction} disabled={submission.pending} title={ - result()?.status === "error" ? result()?.message : undefined + submission.result?.status === "error" + ? submission.result.message + : "Sign Out" } aria-live="polite" onClick={() => void handleSignOut()} > - {result()?.status === "error" - ? "Sign out failed — retry" - : "Sign out"} + {submission.result?.status === "error" + ? submission.result.message + : "Sign Out"} )} From 922ab3d473ca981a406472ec2df8d57458a652a6 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 9 Sep 2026 18:19:19 +0900 Subject: [PATCH 11/14] Simplify deleteSession Logic --- packages/graphql/src/auth/revoke.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/graphql/src/auth/revoke.ts b/packages/graphql/src/auth/revoke.ts index d171f4d..27e2a59 100644 --- a/packages/graphql/src/auth/revoke.ts +++ b/packages/graphql/src/auth/revoke.ts @@ -17,8 +17,7 @@ // oxlint-disable no-magic-numbers import { sessions } from "@drfed/models/schema"; -import type { Uuid } from "@drfed/models/uuid"; -import { and, eq } from "drizzle-orm/sql/expressions"; +import { eq } from "drizzle-orm/sql/expressions"; import builder, { type UserContext } from "../builder.ts"; @@ -32,7 +31,7 @@ builder.mutationFields((t) => ({ async resolve(_query, _, ctx) { const sessionId = ctx.session?.id; if (sessionId) { - await deleteSession(sessionId, ctx); + await deleteSession(ctx); } // Return always true to prevent brute-force attack. return { revoke: true }; @@ -56,9 +55,5 @@ const LogoutSuccessRef = builder }), }); -const deleteSession = (id: Uuid, ctx: UserContext) => - ctx.db - .delete(sessions) - .where( - and(eq(sessions.id, id), eq(sessions.accountId, ctx.session!.accountId)), - ); +const deleteSession = (ctx: UserContext) => + ctx.db.delete(sessions).where(eq(sessions.id, ctx.session!.id)); From d9b0ed72402453827d73b36504e978c8c05b5b36 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 9 Sep 2026 22:10:57 +0900 Subject: [PATCH 12/14] Add comment on ErrorBoundary and Suspense --- packages/web/src/components/HeaderAccountButton.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/src/components/HeaderAccountButton.tsx b/packages/web/src/components/HeaderAccountButton.tsx index c371b1c..5637600 100644 --- a/packages/web/src/components/HeaderAccountButton.tsx +++ b/packages/web/src/components/HeaderAccountButton.tsx @@ -111,7 +111,9 @@ export function HeaderAccountButton() { } return ( + /* A query failure does not mean the viewer is signed out. Instead, it means sever error or network error, making access to other function in this situation will make another error. */ <>}> + {/* Avoid auth-state flicker while the viewer query is pending. */} }> {(data) => ( From 119745a8ffb851714852b4a6161c8aa82ddcb605 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sat, 12 Sep 2026 21:15:32 +0900 Subject: [PATCH 13/14] Fix typo in auth.test.ts --- packages/graphql/src/auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index a3e335e..ecda9f1 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -298,7 +298,7 @@ describe("email authentication", () => { }); }); - it("logs in, authenticates the viewer, and revokes the session", async () => { + it("logs in, authenticates the viewer, and revoke the session", async () => { await withTestHarness(async ({ db, mailer, post }) => { await db.insert(schema.accounts).values({ id: accountId, From 228ea7792dc7a9b1df17dc527f910fdb8bfaf4c5 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sat, 12 Sep 2026 21:16:10 +0900 Subject: [PATCH 14/14] Remove unsused value in auth.test.ts --- packages/graphql/src/auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index ecda9f1..adc2b66 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -383,7 +383,7 @@ describe("email authentication", () => { }); it("not sign in, and reovkes the session", async () => { - await withTestHarness(async ({ db, mailer: _, post }) => { + await withTestHarness(async ({ db, post }) => { await db.insert(schema.accounts).values({ id: accountId, email,