@haruhimemoe/next-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-09-28
10
+
11
+ ### Added
12
+
13
+ - `@haruhimemoe/next-kit/auth-react`: the account components, styled with `@haruhimemoe/ui` (a new optional peer, ^0.5.0). `SignInWithOsu`, `SignOutButton`, `AccountMenu` (ui's HeaderMenu with the app's links) and `DeleteAccountForm` (type the username, then one DELETE), with `createAuthComponents(authClient, kit)` to bind the app's client and account kit. Moved from packs, pools and bb, which each had a copy.
14
+ - `osuAvatarSrc` and `OSU_AVATAR_HOSTS`: an osu! avatar URL only from a.ppy.sh or osu.ppy.sh. Moved from pools and bb.
15
+ - `signInErrorMessage`: reads better-auth's flat or nested error message, as packs did.
16
+
17
+ ### Changed
18
+
19
+ - `auth-react` now also loads `@haruhimemoe/ui` at runtime.
20
+
9
21
  ## [0.1.0] - 2026-09-28
10
22
 
11
23
  ### Added
@@ -17,5 +29,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
17
29
  - `@haruhimemoe/next-kit/auth-react`: `createSignedInMarker`, `createAccountStore`, `useAccount`, `createAccount`, `RestoreSignedIn` and `osuSignIn`.
18
30
  - `@haruhimemoe/next-kit/testing`: `startMemoryMongo`, `setupTestDb`, `setupMsw` and the fake osu! app env.
19
31
 
20
- [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.1.0...HEAD
32
+ [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.2.0...HEAD
33
+ [0.2.0]: https://github.com/haruhimemoe/next-kit/compare/v0.1.0...v0.2.0
21
34
  [0.1.0]: https://github.com/haruhimemoe/next-kit/releases/tag/v0.1.0
package/README.md CHANGED
@@ -8,7 +8,7 @@ The Next.js server plumbing the haruhime.moe tools share. [packs.haruhime.moe](h
8
8
  - **`/env`:** zod env parsing that runs on first use, with SKIP_ENV_VALIDATION for CI and a guard that refuses placeholder secrets on a production server.
9
9
  - **`/mongo`:** one MongoClient per process with Mongoose on the same client, index builds that never take the site down, and frozen collection names.
10
10
  - **`/auth`:** better-auth with osu! as the only way in, the indexes its collections need, and reading the caller.
11
- - **`/auth-react`:** the browser half: the signed-in marker cookie, the account store and `useAccount`, and `RestoreSignedIn`.
11
+ - **`/auth-react`:** the browser half: the signed-in marker cookie, the account store and `useAccount`, `RestoreSignedIn`, and the account components (Sign in with osu!, Sign out, the header's account menu, Delete my account) styled with `@haruhimemoe/ui`.
12
12
  - **`/testing`:** Vitest helpers: one in-memory MongoDB per run, an msw server that refuses unhandled requests, and a fake env.
13
13
 
14
14
  Every name, path, limit and message comes from the caller. There is no root entry point; import a subpath.
@@ -28,7 +28,7 @@ bun add @haruhimemoe/next-kit zod
28
28
  | `env` | nothing else |
29
29
  | `mongo` | `mongodb` ^7.6.0, `mongoose` ^9.10.2 |
30
30
  | `auth` | `better-auth` ^1.7.5, `mongodb`, `@haruhimemoe/osu` 0.2 or 0.3 |
31
- | `auth-react` | `react` ^19.3.0, `next` ^16.3.6 |
31
+ | `auth-react` | `react` ^19.3.0, `next` ^16.3.6, `@haruhimemoe/ui` ^0.5.0 (with its theme set up) |
32
32
  | `testing` | `vitest` ^5.0.1, `msw` ^2.15.0, `mongodb-memory-server` ^11.3.0 |
33
33
 
34
34
  ## Use
@@ -103,7 +103,15 @@ export const getAuth = () => {
103
103
  import { createAccount, createSignedInMarker } from "@haruhimemoe/next-kit/auth-react";
104
104
 
105
105
  export const marker = createSignedInMarker("pools-signed-in");
106
- export const { store, useAccount, markSignedOut, RestoreSignedIn } = createAccount(authClient, marker);
106
+ const kit = createAccount(authClient, marker);
107
+ export const { store, useAccount, markSignedOut, RestoreSignedIn } = kit;
108
+ export const { SignInWithOsu, SignOutButton, AccountMenu, DeleteAccountForm } =
109
+ createAuthComponents(authClient, kit);
110
+
111
+ // Anywhere, server pages included: only plain data is left to pass.
112
+ <SignInWithOsu next={next} />
113
+ <AccountMenu items={[{ href: "/new", label: "Make a pool" }, { href: "/account", label: "Account" }]} />
114
+ <DeleteAccountForm username={user.username} appName="pools" deletes="This deletes your account and every pool you own. It can't be undone." />
107
115
  ```
108
116
 
109
117
  Build the auth instance once (memoize `getAuth`), and use the same cookie name on both sides.
@@ -169,6 +177,12 @@ Build the auth instance once (memoize `getAuth`), and use the same cookie name o
169
177
  | `RestoreSignedIn({ store, hasMarker, next?, pending? })` | Asks for the session once when the marker is missing; with `next`, goes there after. |
170
178
  | `osuSignIn(next, signInPath?)` | The body for `authClient.signIn.social`: an error comes back to `/signin?next=<next>`. |
171
179
  | `safeNextPath`, `signInHref`, `OSU_PROVIDER_ID` | The same as in `server` and `auth`, safe in the browser. |
180
+ | `createAuthComponents(authClient, kit)` | Since 0.2.0. The four components below with the client's `signIn.social` and `signOut` and the kit's `useAccount` and `markSignedOut` bound (`AuthComponents`; props `BoundSignInProps`, `BoundSignOutProps`, `BoundAccountMenuProps`, `BoundDeleteAccountProps`: each component's own minus what's bound). Export them from a `"use client"` module. |
181
+ | `SignInWithOsu({ next, signIn, signInPath?, label?, pendingLabel?, failedMessage? })` | Since 0.2.0. The large "Sign in with osu!" button. Calls `signIn(osuSignIn(next))`; an error (better-auth's `{ message }` or `{ error: { message } }`, or a throw) shows in a `role="alert"` line and the button comes back. `signInErrorMessage(error, fallback?)` reads it. |
182
+ | `SignOutButton({ signOut, onSignedOut, redirectTo?, variant?, className?, label?, pendingLabel? })` | Since 0.2.0. Signs out, runs `onSignedOut` only once the session is gone, then `router.replace(redirectTo ?? "/")` and `router.refresh()`. |
183
+ | `AccountMenu({ account, items, signOut, onSignedOut, avatarSrc?, signInPath?, signInLabel?, signOutLabel?, signOutRedirect? })` | Since 0.2.0. A sized blank while loading, a "Sign in" link back to this page when signed out, else ui's `HeaderMenu` with the avatar and name, `items` and Sign out. The avatar is a plain `<img>` (your CSP's img-src must allow a.ppy.sh and osu.ppy.sh); `avatarSrc` defaults to `osuAvatarSrc`. |
184
+ | `DeleteAccountForm({ username, appName, deletes, onDeleted, endpoint?, homeHref?, homeLabel?, fetcher? })` | Since 0.2.0. ui's `TypeToConfirm` on the username, then `DELETE endpoint` (default `/api/account`) with `{ username }`. 204: `onDeleted`, "Your account is deleted." and home. Another 2xx: `onDeleted` and the answer's `notice` after that line, staying. A refusal shows `error.message` (else "Deleting failed (status)."); no answer, "Couldn't reach <appName>. Your account is still there." `deletes` is inline content in a `<p>`. |
185
+ | `osuAvatarSrc(url)`, `OSU_AVATAR_HOSTS` | Since 0.2.0. An osu! avatar URL on https when it's on a.ppy.sh or osu.ppy.sh (a bare path is osu.ppy.sh's), else null. |
172
186
 
173
187
  ### testing
174
188
 
@@ -197,7 +211,7 @@ For pools.haruhime.moe, `createMongo` runs `onConnect` (the privilege check, ind
197
211
 
198
212
  ## Compatibility
199
213
 
200
- ES modules for Node 22.12+ on the server. `auth-react` also runs in browsers; its hook and component files keep `"use client"`, and it loads only `react` and `next/navigation.js`. `server` loads `node:crypto` for machine auth.
214
+ ES modules for Node 22.12+ on the server. `auth-react` also runs in browsers; its hook and component files keep `"use client"`, and it loads only `react`, `next/navigation.js` and `@haruhimemoe/ui`. `server` loads `node:crypto` for machine auth.
201
215
 
202
216
  ## License
203
217
 
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @file src/auth-react/AccountMenu.tsx
3
+ * @desc The header's account area. Client-side, so static pages stay static and read no cookies:
4
+ * the account comes from the app's useAccount, which asks only when the signed-in marker is
5
+ * there. Nothing to press while loading; "Sign in" (back to this page) when signed out;
6
+ * signed in, ui's HeaderMenu: the avatar (a plain img, so the app's CSP img-src must allow
7
+ * osu!'s avatar hosts) and name, the app's links, and Sign out. Escape,
8
+ * a click outside or on a link, or focus leaving it closes it. Moved from pools and bb
9
+ * (src/components/layout/AccountMenu.tsx), which differed only in the links.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ import { type HeaderMenuItem } from "@haruhimemoe/ui";
15
+ import type { ReactNode } from "react";
16
+ import type { Account } from "./account-store.js";
17
+ /** AccountMenu's props. */
18
+ export type AccountMenuProps = {
19
+ /** The account (the app's useAccount()); createAuthComponents binds it. */
20
+ account: Account;
21
+ /** The menu's links, top to bottom; Sign out comes after them. */
22
+ items: readonly HeaderMenuItem[];
23
+ /** Ends the session; createAuthComponents binds it. */
24
+ signOut: () => Promise<unknown>;
25
+ /** Runs once the session is gone (markSignedOut); createAuthComponents binds it. */
26
+ onSignedOut: () => void;
27
+ /** Where the avatar is shown from, or null for none (default osuAvatarSrc). */
28
+ avatarSrc?: ((url: string | null) => string | null) | undefined;
29
+ /** The sign-in page (default /signin). */
30
+ signInPath?: string | undefined;
31
+ /** The sign-in link's text (default "Sign in"). */
32
+ signInLabel?: ReactNode;
33
+ /** The sign-out button's text (default "Sign out"). */
34
+ signOutLabel?: ReactNode;
35
+ /** Where sign-out goes (default "/"). */
36
+ signOutRedirect?: string | undefined;
37
+ };
38
+ /**
39
+ * @function AccountMenu
40
+ * @param props {AccountMenuProps} the account, the links, the sign-out wiring and the words
41
+ * @returns {ReactNode} a sized blank while loading, the sign-in link when signed out, else the
42
+ * avatar menu with the links and Sign out
43
+ */
44
+ export declare function AccountMenu({ account, items, signOut, onSignedOut, avatarSrc, signInPath, signInLabel, signOutLabel, signOutRedirect, }: AccountMenuProps): ReactNode;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file src/auth-react/AccountMenu.tsx
3
+ * @desc The header's account area. Client-side, so static pages stay static and read no cookies:
4
+ * the account comes from the app's useAccount, which asks only when the signed-in marker is
5
+ * there. Nothing to press while loading; "Sign in" (back to this page) when signed out;
6
+ * signed in, ui's HeaderMenu: the avatar (a plain img, so the app's CSP img-src must allow
7
+ * osu!'s avatar hosts) and name, the app's links, and Sign out. Escape,
8
+ * a click outside or on a link, or focus leaving it closes it. Moved from pools and bb
9
+ * (src/components/layout/AccountMenu.tsx), which differed only in the links.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ "use client";
15
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
+ import { HeaderMenu } from "@haruhimemoe/ui";
17
+ import { usePathname } from "next/navigation.js";
18
+ import { DEFAULT_SIGN_IN_PATH, signInHref } from "../server/safe-next.js";
19
+ import { osuAvatarSrc } from "./avatar.js";
20
+ import { SignOutButton } from "./SignOutButton.js";
21
+ /**
22
+ * @function AccountMenu
23
+ * @param props {AccountMenuProps} the account, the links, the sign-out wiring and the words
24
+ * @returns {ReactNode} a sized blank while loading, the sign-in link when signed out, else the
25
+ * avatar menu with the links and Sign out
26
+ */
27
+ export function AccountMenu({ account, items, signOut, onSignedOut, avatarSrc = osuAvatarSrc, signInPath = DEFAULT_SIGN_IN_PATH, signInLabel = "Sign in", signOutLabel, signOutRedirect, }) {
28
+ const pathname = usePathname();
29
+ if (account.status === "loading")
30
+ return _jsx("span", { "aria-hidden": "true", className: "block h-7 w-16" });
31
+ if (account.status === "signed-out") {
32
+ return (_jsx("a", { href: signInHref(pathname || "/", signInPath), className: "font-bold text-c3 text-sm transition-colors hover:text-c1", children: signInLabel }));
33
+ }
34
+ const avatar = avatarSrc(account.user.avatarUrl);
35
+ return (_jsx(HeaderMenu, { label: _jsxs(_Fragment, { children: [avatar ? (_jsx("img", { src: avatar, alt: "", width: 28, height: 28, className: "rounded-full" })) : null, _jsx("span", { children: account.user.username })] }), items: items, children: _jsx(SignOutButton, { signOut: signOut, onSignedOut: onSignedOut, redirectTo: signOutRedirect, label: signOutLabel, variant: "ghost", className: "h-auto justify-start rounded px-3 py-2" }) }));
36
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file src/auth-react/DeleteAccountForm.tsx
3
+ * @desc "Delete my account". The confirmation is built into the page (ui's TypeToConfirm, no
4
+ * confirm() dialog): the button stays off until the osu! username is typed exactly, then
5
+ * one DELETE to the app's endpoint carries it. 204 means gone: signed out, the page says
6
+ * so and goes home. Another 2xx is gone too but stays, to show the answer's `notice` (work
7
+ * still waiting elsewhere). A refusal says the server's message, and no answer says the app
8
+ * couldn't be reached; nothing was deleted. The form never comes back once it's gone.
9
+ * Moved from pools and bb (src/components/account/DeleteAccountForm.tsx).
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ import { type ReactNode } from "react";
15
+ /** DeleteAccountForm's props. */
16
+ export type DeleteAccountFormProps = {
17
+ /** The signed-in osu! username, typed to confirm and sent as `{ username }`. */
18
+ username: string;
19
+ /** The app's name, for "Couldn't reach <appName>. Your account is still there." */
20
+ appName: string;
21
+ /** What goes, above the field ("This deletes your account and every pool you own…"). */
22
+ deletes: ReactNode;
23
+ /** Runs once the account is gone (markSignedOut); createAuthComponents binds it. */
24
+ onDeleted: () => void;
25
+ /** The DELETE endpoint (default "/api/account"). */
26
+ endpoint?: string | undefined;
27
+ /** Where to go after (default "/"). */
28
+ homeHref?: string | undefined;
29
+ /** The link home's text once deleted (default "Go to the home page"). */
30
+ homeLabel?: ReactNode;
31
+ /** Test seam (default: fetch). */
32
+ fetcher?: typeof fetch | undefined;
33
+ };
34
+ /**
35
+ * @function DeleteAccountForm
36
+ * @param props {DeleteAccountFormProps} the username, the app's name, what goes, the endpoint
37
+ * and what to do after
38
+ * @returns {ReactNode} the typed-name confirmation, or what happened
39
+ */
40
+ export declare function DeleteAccountForm({ username, appName, deletes, onDeleted, endpoint, homeHref, homeLabel, fetcher, }: DeleteAccountFormProps): ReactNode;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @file src/auth-react/DeleteAccountForm.tsx
3
+ * @desc "Delete my account". The confirmation is built into the page (ui's TypeToConfirm, no
4
+ * confirm() dialog): the button stays off until the osu! username is typed exactly, then
5
+ * one DELETE to the app's endpoint carries it. 204 means gone: signed out, the page says
6
+ * so and goes home. Another 2xx is gone too but stays, to show the answer's `notice` (work
7
+ * still waiting elsewhere). A refusal says the server's message, and no answer says the app
8
+ * couldn't be reached; nothing was deleted. The form never comes back once it's gone.
9
+ * Moved from pools and bb (src/components/account/DeleteAccountForm.tsx).
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Mon Sep 28, 2026
12
+ * @modified Mon Sep 28, 2026
13
+ */
14
+ "use client";
15
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
+ import { buttonClasses, TypeToConfirm } from "@haruhimemoe/ui";
17
+ import { useRouter } from "next/navigation.js";
18
+ import { useId, useState } from "react";
19
+ const messageOf = async (response) => {
20
+ const fallback = `Deleting failed (${response.status}).`;
21
+ try {
22
+ const body = (await response.json());
23
+ return body.error?.message ?? fallback;
24
+ }
25
+ catch {
26
+ return fallback;
27
+ }
28
+ };
29
+ /**
30
+ * @function DeleteAccountForm
31
+ * @param props {DeleteAccountFormProps} the username, the app's name, what goes, the endpoint
32
+ * and what to do after
33
+ * @returns {ReactNode} the typed-name confirmation, or what happened
34
+ */
35
+ export function DeleteAccountForm({ username, appName, deletes, onDeleted, endpoint = "/api/account", homeHref = "/", homeLabel = "Go to the home page", fetcher = fetch, }) {
36
+ const router = useRouter();
37
+ const id = useId();
38
+ const [error, setError] = useState(null);
39
+ // Once it's gone: what more to say. The form doesn't come back, so no second delete.
40
+ const [done, setDone] = useState(null);
41
+ const remove = async () => {
42
+ setError(null);
43
+ try {
44
+ const response = await fetcher(endpoint, {
45
+ method: "DELETE",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({ username }),
48
+ });
49
+ if (!response.ok) {
50
+ setError(await messageOf(response));
51
+ return;
52
+ }
53
+ onDeleted();
54
+ if (response.status === 204) {
55
+ setDone("");
56
+ router.push(homeHref);
57
+ return;
58
+ }
59
+ const body = (await response.json().catch(() => ({})));
60
+ setDone(typeof body.notice === "string" ? body.notice : "");
61
+ }
62
+ catch {
63
+ setError(`Couldn't reach ${appName}. Your account is still there.`);
64
+ }
65
+ };
66
+ if (done !== null) {
67
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("p", { role: "status", className: "text-c2 text-sm", children: `Your account is deleted. ${done}`.trim() }), _jsx("a", { href: homeHref, className: buttonClasses({ variant: "secondary", className: "self-start" }), children: homeLabel })] }));
68
+ }
69
+ return (_jsx(TypeToConfirm, { id: id, expected: username, submitLabel: "Delete my account", pendingLabel: "Deleting\u2026", error: error, onConfirm: remove, children: _jsx("p", { className: "text-c2 text-sm", children: deletes }) }));
70
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @file src/auth-react/SignInWithOsu.tsx
3
+ * @desc "Sign in with osu!": starts the OAuth redirect through the app's better-auth client, then
4
+ * lands on `next`, or back on /signin?next=<next>&error=<code> when osu! says no. A call
5
+ * that can't start says so under the button (role="alert") and lets the visitor try again.
6
+ * Styled with @haruhimemoe/ui. Moved from packs and pools
7
+ * (src/components/auth/SignInWithOsu.tsx), where only the error reading differed.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Mon Sep 28, 2026
10
+ * @modified Mon Sep 28, 2026
11
+ */
12
+ import { type ReactNode } from "react";
13
+ import { type OsuSignIn } from "./sign-in.js";
14
+ /** The better-auth call that starts sign-in (`(body) => authClient.signIn.social(body)`). */
15
+ export type StartSignIn = (body: OsuSignIn) => Promise<unknown>;
16
+ /** SignInWithOsu's props. */
17
+ export type SignInWithOsuProps = {
18
+ /** Where to land after signing in (already checked with safeNextPath). */
19
+ next: string;
20
+ /** Starts sign-in; createAuthComponents binds the app's client. */
21
+ signIn: StartSignIn;
22
+ /** The sign-in page errors come back to (default /signin). */
23
+ signInPath?: string | undefined;
24
+ /** The button's text (default "Sign in with osu!"). */
25
+ label?: ReactNode;
26
+ /** The button's text while osu! opens (default "Opening osu!…"). */
27
+ pendingLabel?: ReactNode;
28
+ /** Said when sign-in can't start and the error has no message. */
29
+ failedMessage?: string | undefined;
30
+ };
31
+ /**
32
+ * @function signInErrorMessage
33
+ * @param error {unknown} better-auth's error (`{ message }`, or `{ error: { message } }`)
34
+ * @param fallback {string} said when it carries no message
35
+ * @returns {string} the message to show
36
+ */
37
+ export declare const signInErrorMessage: (error: unknown, fallback?: string) => string;
38
+ /**
39
+ * @function SignInWithOsu
40
+ * @param props {SignInWithOsuProps} `next`, the sign-in call, and the words
41
+ * @returns {ReactNode} the large "Sign in with osu!" button, with an alert when it can't start
42
+ */
43
+ export declare function SignInWithOsu({ next, signIn, signInPath, label, pendingLabel, failedMessage, }: SignInWithOsuProps): ReactNode;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @file src/auth-react/SignInWithOsu.tsx
3
+ * @desc "Sign in with osu!": starts the OAuth redirect through the app's better-auth client, then
4
+ * lands on `next`, or back on /signin?next=<next>&error=<code> when osu! says no. A call
5
+ * that can't start says so under the button (role="alert") and lets the visitor try again.
6
+ * Styled with @haruhimemoe/ui. Moved from packs and pools
7
+ * (src/components/auth/SignInWithOsu.tsx), where only the error reading differed.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Mon Sep 28, 2026
10
+ * @modified Mon Sep 28, 2026
11
+ */
12
+ "use client";
13
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
+ import { Button } from "@haruhimemoe/ui";
15
+ import { useState } from "react";
16
+ import { osuSignIn } from "./sign-in.js";
17
+ const FAILED = "Couldn't start osu! sign-in. Try again.";
18
+ /**
19
+ * @function signInErrorMessage
20
+ * @param error {unknown} better-auth's error (`{ message }`, or `{ error: { message } }`)
21
+ * @param fallback {string} said when it carries no message
22
+ * @returns {string} the message to show
23
+ */
24
+ export const signInErrorMessage = (error, fallback = FAILED) => {
25
+ if (typeof error !== "object" || error === null)
26
+ return fallback;
27
+ const { message, error: nested } = error;
28
+ if (typeof message === "string" && message)
29
+ return message;
30
+ if (typeof nested?.message === "string" && nested.message)
31
+ return nested.message;
32
+ return fallback;
33
+ };
34
+ /**
35
+ * @function SignInWithOsu
36
+ * @param props {SignInWithOsuProps} `next`, the sign-in call, and the words
37
+ * @returns {ReactNode} the large "Sign in with osu!" button, with an alert when it can't start
38
+ */
39
+ export function SignInWithOsu({ next, signIn, signInPath, label = "Sign in with osu!", pendingLabel = "Opening osu!…", failedMessage = FAILED, }) {
40
+ const [pending, setPending] = useState(false);
41
+ const [error, setError] = useState(null);
42
+ const onClick = async () => {
43
+ setPending(true);
44
+ setError(null);
45
+ try {
46
+ // better-auth adds &error=<code> to the error URL; /signin explains it and keeps `next`.
47
+ const result = (await signIn(osuSignIn(next, signInPath)));
48
+ if (!result?.error)
49
+ return;
50
+ setError(signInErrorMessage(result.error, failedMessage));
51
+ }
52
+ catch (cause) {
53
+ setError(signInErrorMessage(cause, failedMessage));
54
+ }
55
+ setPending(false);
56
+ };
57
+ return (_jsxs("div", { className: "flex flex-col items-center gap-2", children: [_jsx(Button, { size: "lg", onClick: onClick, disabled: pending, children: pending ? pendingLabel : label }), error ? (_jsx("p", { role: "alert", className: "font-bold text-rose-300 text-sm", children: error })) : null] }));
58
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @file src/auth-react/SignOutButton.tsx
3
+ * @desc Signs out, forgets the signed-in marker once the session is really gone (so the header
4
+ * flips without a reload), then goes home and refreshes server components. A failed
5
+ * sign-out still goes home, marker kept. Styled with @haruhimemoe/ui. Moved from packs and
6
+ * pools (src/components/auth/SignOutButton.tsx).
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ import { type ButtonVariant } from "@haruhimemoe/ui";
12
+ import { type ReactNode } from "react";
13
+ /** SignOutButton's props. */
14
+ export type SignOutButtonProps = {
15
+ /** Ends the session (`() => authClient.signOut()`); createAuthComponents binds it. */
16
+ signOut: () => Promise<unknown>;
17
+ /** Runs once the session is gone (the account kit's markSignedOut). */
18
+ onSignedOut: () => void;
19
+ /** Where to go after (default "/"). */
20
+ redirectTo?: string | undefined;
21
+ /** The button's look (default "secondary"). */
22
+ variant?: ButtonVariant | undefined;
23
+ /** Classes for the button. */
24
+ className?: string | undefined;
25
+ /** The button's text (default "Sign out"). */
26
+ label?: ReactNode;
27
+ /** The button's text while signing out (default "Signing out…"). */
28
+ pendingLabel?: ReactNode;
29
+ };
30
+ /**
31
+ * @function SignOutButton
32
+ * @param props {SignOutButtonProps} the sign-out call, what to do after, where to go, the look
33
+ * @returns {ReactNode} a button that signs out, tells the header, and goes to `redirectTo`
34
+ */
35
+ export declare function SignOutButton({ signOut, onSignedOut, redirectTo, variant, className, label, pendingLabel, }: SignOutButtonProps): ReactNode;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @file src/auth-react/SignOutButton.tsx
3
+ * @desc Signs out, forgets the signed-in marker once the session is really gone (so the header
4
+ * flips without a reload), then goes home and refreshes server components. A failed
5
+ * sign-out still goes home, marker kept. Styled with @haruhimemoe/ui. Moved from packs and
6
+ * pools (src/components/auth/SignOutButton.tsx).
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ "use client";
12
+ import { jsx as _jsx } from "react/jsx-runtime";
13
+ import { Button } from "@haruhimemoe/ui";
14
+ import { useRouter } from "next/navigation.js";
15
+ import { useState } from "react";
16
+ /**
17
+ * @function SignOutButton
18
+ * @param props {SignOutButtonProps} the sign-out call, what to do after, where to go, the look
19
+ * @returns {ReactNode} a button that signs out, tells the header, and goes to `redirectTo`
20
+ */
21
+ export function SignOutButton({ signOut, onSignedOut, redirectTo = "/", variant = "secondary", className, label = "Sign out", pendingLabel = "Signing out…", }) {
22
+ const router = useRouter();
23
+ const [pending, setPending] = useState(false);
24
+ const onClick = async () => {
25
+ setPending(true);
26
+ await signOut().then(onSignedOut, () => undefined);
27
+ router.replace(redirectTo);
28
+ router.refresh();
29
+ };
30
+ return (_jsx(Button, { variant: variant, className: className, onClick: onClick, disabled: pending, children: pending ? pendingLabel : label }));
31
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @file src/auth-react/auth-components.tsx
3
+ * @desc createAuthComponents: SignInWithOsu, SignOutButton, AccountMenu and DeleteAccountForm
4
+ * with the app's better-auth client and account kit already bound, so what's left is plain
5
+ * data a server page can pass. Export them from the app's own "use client" module, next to
6
+ * createAccount's.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ import type { ReactNode } from "react";
12
+ import { type AccountMenuProps } from "./AccountMenu.js";
13
+ import type { Account } from "./account-store.js";
14
+ import { type DeleteAccountFormProps } from "./DeleteAccountForm.js";
15
+ import { type SignInWithOsuProps, type StartSignIn } from "./SignInWithOsu.js";
16
+ import { type SignOutButtonProps } from "./SignOutButton.js";
17
+ /** The part of a better-auth client the components call. */
18
+ export type AuthUiClient = {
19
+ signIn: {
20
+ social: StartSignIn;
21
+ };
22
+ signOut: () => Promise<unknown>;
23
+ };
24
+ /** The part of createAccount's kit the components use. */
25
+ export type AuthUiAccount = {
26
+ useAccount: () => Account;
27
+ markSignedOut: () => void;
28
+ };
29
+ /** The bound components' props: each component's own, minus what's bound. */
30
+ export type BoundSignInProps = Omit<SignInWithOsuProps, "signIn">;
31
+ /** SignOutButton's props minus the bound sign-out. */
32
+ export type BoundSignOutProps = Omit<SignOutButtonProps, "signOut" | "onSignedOut">;
33
+ /** AccountMenu's props minus the bound account and sign-out. */
34
+ export type BoundAccountMenuProps = Omit<AccountMenuProps, "account" | "signOut" | "onSignedOut">;
35
+ /** DeleteAccountForm's props minus the bound onDeleted. */
36
+ export type BoundDeleteAccountProps = Omit<DeleteAccountFormProps, "onDeleted">;
37
+ /** What createAuthComponents returns. */
38
+ export type AuthComponents = {
39
+ SignInWithOsu: (props: BoundSignInProps) => ReactNode;
40
+ SignOutButton: (props: BoundSignOutProps) => ReactNode;
41
+ AccountMenu: (props: BoundAccountMenuProps) => ReactNode;
42
+ DeleteAccountForm: (props: BoundDeleteAccountProps) => ReactNode;
43
+ };
44
+ /**
45
+ * @function createAuthComponents
46
+ * @param client {AuthUiClient} the app's better-auth client (signIn.social and signOut)
47
+ * @param account {AuthUiAccount} createAccount's kit (useAccount and markSignedOut)
48
+ * @returns {AuthComponents} the four components with the client and account bound
49
+ */
50
+ export declare const createAuthComponents: (client: AuthUiClient, account: AuthUiAccount) => AuthComponents;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file src/auth-react/auth-components.tsx
3
+ * @desc createAuthComponents: SignInWithOsu, SignOutButton, AccountMenu and DeleteAccountForm
4
+ * with the app's better-auth client and account kit already bound, so what's left is plain
5
+ * data a server page can pass. Export them from the app's own "use client" module, next to
6
+ * createAccount's.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ "use client";
12
+ import { jsx as _jsx } from "react/jsx-runtime";
13
+ import { AccountMenu } from "./AccountMenu.js";
14
+ import { DeleteAccountForm } from "./DeleteAccountForm.js";
15
+ import { SignInWithOsu } from "./SignInWithOsu.js";
16
+ import { SignOutButton } from "./SignOutButton.js";
17
+ /**
18
+ * @function createAuthComponents
19
+ * @param client {AuthUiClient} the app's better-auth client (signIn.social and signOut)
20
+ * @param account {AuthUiAccount} createAccount's kit (useAccount and markSignedOut)
21
+ * @returns {AuthComponents} the four components with the client and account bound
22
+ */
23
+ export const createAuthComponents = (client, account) => {
24
+ const signIn = (body) => client.signIn.social(body);
25
+ const signOut = () => client.signOut();
26
+ const onSignedOut = () => account.markSignedOut();
27
+ return {
28
+ SignInWithOsu: (props) => _jsx(SignInWithOsu, { ...props, signIn: signIn }),
29
+ SignOutButton: (props) => (_jsx(SignOutButton, { ...props, signOut: signOut, onSignedOut: onSignedOut })),
30
+ AccountMenu: function BoundAccountMenu(props) {
31
+ const current = account.useAccount();
32
+ return (_jsx(AccountMenu, { ...props, account: current, signOut: signOut, onSignedOut: onSignedOut }));
33
+ },
34
+ DeleteAccountForm: (props) => _jsx(DeleteAccountForm, { ...props, onDeleted: onSignedOut }),
35
+ };
36
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @file src/auth-react/avatar.ts
3
+ * @desc Where an osu! avatar is shown from. osu!'s API sends a.ppy.sh URLs, and the guest avatar
4
+ * on osu.ppy.sh (which osu-web can send as a bare path). An app's CSP and next/image
5
+ * config allow those two hosts, so any other URL shows no avatar rather than a broken one.
6
+ * Pure and browser-safe. Moved from pools and bb (src/utils/avatar.ts, identical).
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ /** osu!'s avatar hosts. */
12
+ export declare const OSU_AVATAR_HOSTS: ReadonlySet<string>;
13
+ /**
14
+ * @function osuAvatarSrc
15
+ * @param url {string | null | undefined} the avatar URL osu! sent
16
+ * @returns {string | null} it on https when it's on a.ppy.sh or osu.ppy.sh (a bare path read as
17
+ * osu.ppy.sh's), else null
18
+ */
19
+ export declare const osuAvatarSrc: (url: string | null | undefined) => string | null;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @file src/auth-react/avatar.ts
3
+ * @desc Where an osu! avatar is shown from. osu!'s API sends a.ppy.sh URLs, and the guest avatar
4
+ * on osu.ppy.sh (which osu-web can send as a bare path). An app's CSP and next/image
5
+ * config allow those two hosts, so any other URL shows no avatar rather than a broken one.
6
+ * Pure and browser-safe. Moved from pools and bb (src/utils/avatar.ts, identical).
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ /** osu!'s avatar hosts. */
12
+ export const OSU_AVATAR_HOSTS = new Set(["a.ppy.sh", "osu.ppy.sh"]);
13
+ /** A bare path is osu-web's own. */
14
+ const OSU_WEB = "https://osu.ppy.sh";
15
+ /**
16
+ * @function osuAvatarSrc
17
+ * @param url {string | null | undefined} the avatar URL osu! sent
18
+ * @returns {string | null} it on https when it's on a.ppy.sh or osu.ppy.sh (a bare path read as
19
+ * osu.ppy.sh's), else null
20
+ */
21
+ export const osuAvatarSrc = (url) => {
22
+ if (!url)
23
+ return null;
24
+ let parsed;
25
+ try {
26
+ parsed = new URL(url, OSU_WEB);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
32
+ return null;
33
+ if (!OSU_AVATAR_HOSTS.has(parsed.hostname) || parsed.port !== "")
34
+ return null;
35
+ parsed.protocol = "https:";
36
+ return parsed.href;
37
+ };
@@ -2,7 +2,8 @@
2
2
  * @file src/auth-react/index.ts
3
3
  * @desc @haruhimemoe/next-kit/auth-react: the browser side of osu! sign-in. The signed-in marker
4
4
  * cookie, the account store and useAccount, RestoreSignedIn, what the sign-in button
5
- * sends, and where to go after sign-in. No "use client" here: the hook and component
5
+ * sends, where to go after sign-in, and the account components (sign in, sign out, the
6
+ * header's account menu, delete my account), styled with @haruhimemoe/ui. No "use client" here: the hook and component
6
7
  * files carry it, so a server page can still call safeNextPath from this entry point.
7
8
  * @author David @dvhsh (https://dvh.sh)
8
9
  * @created Mon Sep 28, 2026
@@ -10,8 +11,14 @@
10
11
  */
11
12
  export { OSU_PROVIDER_ID } from "../auth/osu-id.js";
12
13
  export { DEFAULT_SIGN_IN_PATH, type SafeNextOptions, safeNextPath, signInHref, } from "../server/safe-next.js";
14
+ export { AccountMenu, type AccountMenuProps, } from "./AccountMenu.js";
13
15
  export { type Account, type AccountDeps, type AccountStore, createAccountStore, LOADING, type SessionData, } from "./account-store.js";
16
+ export { type AuthComponents, type AuthUiAccount, type AuthUiClient, type BoundAccountMenuProps, type BoundDeleteAccountProps, type BoundSignInProps, type BoundSignOutProps, createAuthComponents, } from "./auth-components.js";
17
+ export { OSU_AVATAR_HOSTS, osuAvatarSrc } from "./avatar.js";
18
+ export { DeleteAccountForm, type DeleteAccountFormProps } from "./DeleteAccountForm.js";
14
19
  export { createSignedInMarker, markerMaxAge, type SignedInMarker } from "./marker.js";
15
20
  export { RestoreSignedIn, type RestoreSignedInProps } from "./RestoreSignedIn.js";
21
+ export { SignInWithOsu, type SignInWithOsuProps, type StartSignIn, signInErrorMessage, } from "./SignInWithOsu.js";
22
+ export { SignOutButton, type SignOutButtonProps } from "./SignOutButton.js";
16
23
  export { type OsuSignIn, osuSignIn } from "./sign-in.js";
17
24
  export { type AccountKit, type BoundRestoreProps, createAccount, type SessionClient, sessionFetcher, useAccount, } from "./use-account.js";
@@ -2,7 +2,8 @@
2
2
  * @file src/auth-react/index.ts
3
3
  * @desc @haruhimemoe/next-kit/auth-react: the browser side of osu! sign-in. The signed-in marker
4
4
  * cookie, the account store and useAccount, RestoreSignedIn, what the sign-in button
5
- * sends, and where to go after sign-in. No "use client" here: the hook and component
5
+ * sends, where to go after sign-in, and the account components (sign in, sign out, the
6
+ * header's account menu, delete my account), styled with @haruhimemoe/ui. No "use client" here: the hook and component
6
7
  * files carry it, so a server page can still call safeNextPath from this entry point.
7
8
  * @author David @dvhsh (https://dvh.sh)
8
9
  * @created Mon Sep 28, 2026
@@ -10,8 +11,14 @@
10
11
  */
11
12
  export { OSU_PROVIDER_ID } from "../auth/osu-id.js";
12
13
  export { DEFAULT_SIGN_IN_PATH, safeNextPath, signInHref, } from "../server/safe-next.js";
14
+ export { AccountMenu, } from "./AccountMenu.js";
13
15
  export { createAccountStore, LOADING, } from "./account-store.js";
16
+ export { createAuthComponents, } from "./auth-components.js";
17
+ export { OSU_AVATAR_HOSTS, osuAvatarSrc } from "./avatar.js";
18
+ export { DeleteAccountForm } from "./DeleteAccountForm.js";
14
19
  export { createSignedInMarker, markerMaxAge } from "./marker.js";
15
20
  export { RestoreSignedIn } from "./RestoreSignedIn.js";
21
+ export { SignInWithOsu, signInErrorMessage, } from "./SignInWithOsu.js";
22
+ export { SignOutButton } from "./SignOutButton.js";
16
23
  export { osuSignIn } from "./sign-in.js";
17
24
  export { createAccount, sessionFetcher, useAccount, } from "./use-account.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haruhimemoe/next-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The Next.js server plumbing packs.haruhime.moe and pools.haruhime.moe share: JSON route helpers, rate limits and budgets in MongoDB, bearer machine auth, zod env parsing, a connect-once MongoDB client with safe index builds, better-auth with osu! sign-in, and the signed-in marker and account store for the browser.",
5
5
  "keywords": [
6
6
  "nextjs",
@@ -76,6 +76,7 @@
76
76
  },
77
77
  "peerDependencies": {
78
78
  "@haruhimemoe/osu": "^0.2.0 || ^0.3.0",
79
+ "@haruhimemoe/ui": "^0.5.0",
79
80
  "better-auth": "^1.7.5",
80
81
  "mongodb": "^7.6.0",
81
82
  "mongodb-memory-server": "^11.3.0",
@@ -90,6 +91,9 @@
90
91
  "@haruhimemoe/osu": {
91
92
  "optional": true
92
93
  },
94
+ "@haruhimemoe/ui": {
95
+ "optional": true
96
+ },
93
97
  "better-auth": {
94
98
  "optional": true
95
99
  },
@@ -118,6 +122,7 @@
118
122
  "devDependencies": {
119
123
  "@biomejs/biome": "2.5.14",
120
124
  "@haruhimemoe/osu": "0.2.0",
125
+ "@haruhimemoe/ui": "0.5.0",
121
126
  "@testing-library/dom": "10.4.2",
122
127
  "@testing-library/react": "16.3.3",
123
128
  "@types/node": "26.6.2",