@gigamusic/admin 0.1.1 → 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/package.json +8 -8
- package/src/client.ts +0 -2
- package/src/handlers/links.ts +7 -15
- package/src/handlers/orders.ts +9 -9
- package/src/handlers/releases.ts +20 -19
- package/src/handlers/settings.ts +9 -12
- package/src/handlers/upload.ts +12 -13
- package/src/index.ts +0 -2
- package/src/lib/types.ts +8 -5
- package/src/server.ts +11 -16
- package/src/components/AdminLoginForm.tsx +0 -63
- package/src/handlers/auth.ts +0 -58
- package/src/lib/rate-limit.ts +0 -31
- package/src/lib/session.ts +0 -89
- package/src/pages/AdminLoginPage.tsx +0 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gigamusic/admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Admin UI (releases CRUD, tracks, settings, links, link-pages, orders) and admin API handler factories for the gigamusic platform. Fully replaceable by the consumer — mount the pages directly or override individual slots.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,13 +34,13 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"zod": "^3.24.1",
|
|
37
|
-
"@gigamusic/audio": "0.
|
|
38
|
-
"@gigamusic/
|
|
39
|
-
"@gigamusic/
|
|
40
|
-
"@gigamusic/
|
|
41
|
-
"@gigamusic/
|
|
42
|
-
"@gigamusic/
|
|
43
|
-
"@gigamusic/
|
|
37
|
+
"@gigamusic/audio": "0.2.0",
|
|
38
|
+
"@gigamusic/db": "0.2.0",
|
|
39
|
+
"@gigamusic/email": "0.2.0",
|
|
40
|
+
"@gigamusic/config": "0.2.0",
|
|
41
|
+
"@gigamusic/storage": "0.2.0",
|
|
42
|
+
"@gigamusic/core": "0.2.0",
|
|
43
|
+
"@gigamusic/ui": "0.2.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"next": ">=15",
|
package/src/client.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// server-only modules.
|
|
6
6
|
|
|
7
7
|
export { AdminHomePage } from "./pages/AdminHomePage";
|
|
8
|
-
export { AdminLoginPage } from "./pages/AdminLoginPage";
|
|
9
8
|
export { AdminReleasesPage } from "./pages/AdminReleasesPage";
|
|
10
9
|
export { AdminNewReleasePage } from "./pages/AdminNewReleasePage";
|
|
11
10
|
export { AdminEditReleasePage } from "./pages/AdminEditReleasePage";
|
|
@@ -14,5 +13,4 @@ export { AdminSettingsPage } from "./pages/AdminSettingsPage";
|
|
|
14
13
|
export { AdminOrdersPage } from "./pages/AdminOrdersPage";
|
|
15
14
|
export { ReleaseForm } from "./components/ReleaseForm";
|
|
16
15
|
export type { ReleaseFormProps } from "./components/ReleaseForm";
|
|
17
|
-
export { AdminLoginForm } from "./components/AdminLoginForm";
|
|
18
16
|
export { AdminNav } from "./components/AdminNav";
|
package/src/handlers/links.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
2
2
|
import type { LinkInput } from "@gigamusic/db";
|
|
3
|
-
import { badRequest, json, notFound
|
|
4
|
-
import { isAdminAuthenticated } from "../lib/session";
|
|
3
|
+
import { badRequest, json, notFound } from "../lib/responses";
|
|
5
4
|
|
|
6
5
|
interface LinkBody {
|
|
7
6
|
id?: unknown;
|
|
@@ -38,8 +37,13 @@ function toLinkInput(body: LinkBody): LinkInput | null {
|
|
|
38
37
|
* to reorder the whole list in one round-trip.
|
|
39
38
|
*
|
|
40
39
|
* DELETE expects `?id=...`.
|
|
40
|
+
*
|
|
41
|
+
* Auth: the package assumes the request has been gated by the consumer's
|
|
42
|
+
* `proxy.ts` / middleware. These handlers don't verify a session.
|
|
41
43
|
*/
|
|
42
|
-
export function createAdminLinksHandlers(
|
|
44
|
+
export function createAdminLinksHandlers(
|
|
45
|
+
deps: Pick<AdminDeps, "queries" | "logger">,
|
|
46
|
+
): {
|
|
43
47
|
GET: RouteHandler;
|
|
44
48
|
POST: RouteHandler;
|
|
45
49
|
PUT: RouteHandler;
|
|
@@ -47,15 +51,9 @@ export function createAdminLinksHandlers(deps: AdminDeps): {
|
|
|
47
51
|
} {
|
|
48
52
|
return {
|
|
49
53
|
GET: async () => {
|
|
50
|
-
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
51
|
-
return unauthorized();
|
|
52
|
-
}
|
|
53
54
|
return json(await deps.queries.listAllLinks());
|
|
54
55
|
},
|
|
55
56
|
POST: async (req) => {
|
|
56
|
-
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
57
|
-
return unauthorized();
|
|
58
|
-
}
|
|
59
57
|
const body = (await safeJson(req)) as LinkBody;
|
|
60
58
|
const input = toLinkInput(body);
|
|
61
59
|
if (!input) return badRequest("title and url are required");
|
|
@@ -63,9 +61,6 @@ export function createAdminLinksHandlers(deps: AdminDeps): {
|
|
|
63
61
|
return json(link, { status: 201 });
|
|
64
62
|
},
|
|
65
63
|
PUT: async (req) => {
|
|
66
|
-
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
67
|
-
return unauthorized();
|
|
68
|
-
}
|
|
69
64
|
const body = (await safeJson(req)) as LinkBody & ReorderBody;
|
|
70
65
|
if (Array.isArray(body.orderedIds)) {
|
|
71
66
|
const ids = body.orderedIds
|
|
@@ -89,9 +84,6 @@ export function createAdminLinksHandlers(deps: AdminDeps): {
|
|
|
89
84
|
return json(link);
|
|
90
85
|
},
|
|
91
86
|
DELETE: async (req) => {
|
|
92
|
-
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
93
|
-
return unauthorized();
|
|
94
|
-
}
|
|
95
87
|
const url = new URL(req.url);
|
|
96
88
|
const idRaw = url.searchParams.get("id");
|
|
97
89
|
if (!idRaw) return badRequest("id is required");
|
package/src/handlers/orders.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
2
|
-
import { badRequest, json
|
|
3
|
-
import { isAdminAuthenticated } from "../lib/session";
|
|
2
|
+
import { badRequest, json } from "../lib/responses";
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
|
-
* GET /api/admin/orders?email=… — returns the order history for a given
|
|
7
|
-
* address.
|
|
8
|
-
*
|
|
5
|
+
* GET /api/admin/orders?email=… — returns the order history for a given
|
|
6
|
+
* email address.
|
|
7
|
+
*
|
|
8
|
+
* Auth: the package assumes the request has been gated by the consumer's
|
|
9
|
+
* `proxy.ts` / middleware. This handler doesn't verify a session.
|
|
9
10
|
*/
|
|
10
|
-
export function createAdminOrdersHandler(
|
|
11
|
+
export function createAdminOrdersHandler(
|
|
12
|
+
deps: Pick<AdminDeps, "queries">,
|
|
13
|
+
): RouteHandler {
|
|
11
14
|
return async (req) => {
|
|
12
|
-
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
13
|
-
return unauthorized();
|
|
14
|
-
}
|
|
15
15
|
const url = new URL(req.url);
|
|
16
16
|
const email = url.searchParams.get("email");
|
|
17
17
|
if (!email) return badRequest("email query param is required");
|
package/src/handlers/releases.ts
CHANGED
|
@@ -5,8 +5,9 @@ import {
|
|
|
5
5
|
type ReleaseInput,
|
|
6
6
|
} from "@gigamusic/core";
|
|
7
7
|
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
8
|
-
import { badRequest, json, notFound
|
|
9
|
-
|
|
8
|
+
import { badRequest, json, notFound } from "../lib/responses";
|
|
9
|
+
|
|
10
|
+
type ReleasesDeps = Pick<AdminDeps, "queries" | "storage" | "logger">;
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Draft releases skip the strict published-validation rules so an admin can
|
|
@@ -40,23 +41,22 @@ function flattenIssues(error: z.ZodError): Record<string, string[]> {
|
|
|
40
41
|
return out;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
/**
|
|
45
|
+
* GET /api/admin/releases and POST /api/admin/releases.
|
|
46
|
+
*
|
|
47
|
+
* Auth: the package assumes the request has been gated by the consumer's
|
|
48
|
+
* `proxy.ts` / middleware. These handlers don't verify a session.
|
|
49
|
+
*/
|
|
48
50
|
export function createAdminReleasesHandlers(
|
|
49
|
-
deps:
|
|
51
|
+
deps: Pick<ReleasesDeps, "queries" | "logger">,
|
|
50
52
|
): { GET: RouteHandler; POST: RouteHandler } {
|
|
51
53
|
return {
|
|
52
54
|
GET: async () => {
|
|
53
|
-
if (!(await requireAuth(deps))) return unauthorized();
|
|
54
55
|
const releases = await deps.queries.listAllReleases();
|
|
55
56
|
return json(releases);
|
|
56
57
|
},
|
|
57
58
|
POST: async (req) => {
|
|
58
|
-
|
|
59
|
-
const body = await readJson(req);
|
|
59
|
+
const body = await readJson(req);
|
|
60
60
|
if (!body.ok) return badRequest("Invalid JSON");
|
|
61
61
|
|
|
62
62
|
const parsed = ReleasePayloadSchema.safeParse(body.value);
|
|
@@ -81,22 +81,24 @@ export function createAdminReleasesHandlers(
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* GET/PUT/DELETE /api/admin/releases/[id].
|
|
86
|
+
*
|
|
87
|
+
* Auth: gated by the consumer upstream — see `createAdminReleasesHandlers`.
|
|
88
|
+
*/
|
|
85
89
|
export function createAdminReleaseByIdHandlers(
|
|
86
|
-
deps:
|
|
90
|
+
deps: ReleasesDeps,
|
|
87
91
|
): { GET: RouteHandler; PUT: RouteHandler; DELETE: RouteHandler } {
|
|
88
92
|
return {
|
|
89
93
|
GET: async (_req, ctx) => {
|
|
90
|
-
|
|
91
|
-
const id = await resolveIdParam(ctx);
|
|
94
|
+
const id = await resolveIdParam(ctx);
|
|
92
95
|
if (id == null) return badRequest("Missing id");
|
|
93
96
|
const all = await deps.queries.listAllReleases();
|
|
94
97
|
const found = all.find((r) => r.id === id);
|
|
95
98
|
return found ? json(found) : notFound("Release not found");
|
|
96
99
|
},
|
|
97
100
|
PUT: async (req, ctx) => {
|
|
98
|
-
|
|
99
|
-
const id = await resolveIdParam(ctx);
|
|
101
|
+
const id = await resolveIdParam(ctx);
|
|
100
102
|
if (id == null) return badRequest("Missing id");
|
|
101
103
|
|
|
102
104
|
const body = await readJson(req);
|
|
@@ -125,8 +127,7 @@ export function createAdminReleaseByIdHandlers(
|
|
|
125
127
|
}
|
|
126
128
|
},
|
|
127
129
|
DELETE: async (_req, ctx) => {
|
|
128
|
-
|
|
129
|
-
const id = await resolveIdParam(ctx);
|
|
130
|
+
const id = await resolveIdParam(ctx);
|
|
130
131
|
if (id == null) return badRequest("Missing id");
|
|
131
132
|
|
|
132
133
|
// Snapshot storage keys before the cascade nukes the rows, so we can
|
package/src/handlers/settings.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
2
|
-
import { badRequest, json
|
|
3
|
-
import { isAdminAuthenticated } from "../lib/session";
|
|
2
|
+
import { badRequest, json } from "../lib/responses";
|
|
4
3
|
|
|
5
4
|
interface SettingPayload {
|
|
6
5
|
key?: unknown;
|
|
@@ -10,22 +9,23 @@ interface SettingPayload {
|
|
|
10
9
|
/**
|
|
11
10
|
* GET/PUT /api/admin/settings.
|
|
12
11
|
*
|
|
13
|
-
* GET returns all known settings as a `Record<string, unknown>`. PUT upserts
|
|
14
|
-
* single key/value pair; the consumer's `queries.setSetting` is the source
|
|
15
|
-
* truth for serialization (JSON stringification happens inside the db
|
|
12
|
+
* GET returns all known settings as a `Record<string, unknown>`. PUT upserts
|
|
13
|
+
* a single key/value pair; the consumer's `queries.setSetting` is the source
|
|
14
|
+
* of truth for serialization (JSON stringification happens inside the db
|
|
15
|
+
* layer).
|
|
16
16
|
*
|
|
17
17
|
* Per-key validation is intentionally not done here — settings are
|
|
18
18
|
* heterogeneous and the admin form for each setting validates before
|
|
19
19
|
* submitting.
|
|
20
|
+
*
|
|
21
|
+
* Auth: the package assumes the request has been gated by the consumer's
|
|
22
|
+
* `proxy.ts` / middleware. These handlers don't verify a session.
|
|
20
23
|
*/
|
|
21
24
|
export function createAdminSettingsHandlers(
|
|
22
|
-
deps: AdminDeps,
|
|
25
|
+
deps: Pick<AdminDeps, "queries">,
|
|
23
26
|
): { GET: RouteHandler; PUT: RouteHandler } {
|
|
24
27
|
return {
|
|
25
28
|
GET: async (req) => {
|
|
26
|
-
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
27
|
-
return unauthorized();
|
|
28
|
-
}
|
|
29
29
|
const url = new URL(req.url);
|
|
30
30
|
const key = url.searchParams.get("key");
|
|
31
31
|
if (key) {
|
|
@@ -35,9 +35,6 @@ export function createAdminSettingsHandlers(
|
|
|
35
35
|
return json({});
|
|
36
36
|
},
|
|
37
37
|
PUT: async (req) => {
|
|
38
|
-
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
39
|
-
return unauthorized();
|
|
40
|
-
}
|
|
41
38
|
let body: SettingPayload;
|
|
42
39
|
try {
|
|
43
40
|
body = (await req.json()) as SettingPayload;
|
package/src/handlers/upload.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { join } from "node:path";
|
|
|
4
4
|
import { writeFile, readFile, unlink, stat } from "node:fs/promises";
|
|
5
5
|
import { createReadStream } from "node:fs";
|
|
6
6
|
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
7
|
-
import { badRequest, json, serverError
|
|
8
|
-
|
|
7
|
+
import { badRequest, json, serverError } from "../lib/responses";
|
|
8
|
+
|
|
9
|
+
type PresignDeps = Pick<AdminDeps, "storage">;
|
|
10
|
+
type ProcessDeps = Pick<AdminDeps, "storage" | "audio" | "queries" | "logger">;
|
|
9
11
|
|
|
10
12
|
// Path segments allow the punctuation common in real-world track filenames
|
|
11
13
|
// alongside alphanumerics, dashes, dots, underscores, spaces, parens, and
|
|
@@ -33,13 +35,14 @@ interface ProcessBody {
|
|
|
33
35
|
coverImageUrl?: unknown;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Build POST /api/admin/upload/presign.
|
|
40
|
+
*
|
|
41
|
+
* Auth: the package assumes the request has been gated by the consumer's
|
|
42
|
+
* `proxy.ts` / middleware. This handler doesn't verify a session.
|
|
43
|
+
*/
|
|
44
|
+
export function createAdminUploadPresignHandler(deps: PresignDeps): RouteHandler {
|
|
38
45
|
return async (req) => {
|
|
39
|
-
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
40
|
-
return unauthorized();
|
|
41
|
-
}
|
|
42
|
-
|
|
43
46
|
const body = (await safeJson(req)) as {
|
|
44
47
|
key?: unknown;
|
|
45
48
|
contentType?: unknown;
|
|
@@ -118,12 +121,8 @@ function bufferFromDataUrl(value: unknown): Buffer | null {
|
|
|
118
121
|
* because the track row isn't created yet — the release POST handler
|
|
119
122
|
* persists the files alongside the row).
|
|
120
123
|
*/
|
|
121
|
-
export function createAdminUploadProcessHandler(deps:
|
|
124
|
+
export function createAdminUploadProcessHandler(deps: ProcessDeps): RouteHandler {
|
|
122
125
|
return async (req) => {
|
|
123
|
-
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
124
|
-
return unauthorized();
|
|
125
|
-
}
|
|
126
|
-
|
|
127
126
|
const body = (await safeJson(req)) as ProcessBody;
|
|
128
127
|
const key = typeof body.key === "string" ? body.key : "";
|
|
129
128
|
if (!key.endsWith(".wav")) {
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
export { AdminLayout } from "./pages/AdminLayout";
|
|
6
6
|
export { AdminHomePage } from "./pages/AdminHomePage";
|
|
7
|
-
export { AdminLoginPage } from "./pages/AdminLoginPage";
|
|
8
7
|
export { AdminReleasesPage } from "./pages/AdminReleasesPage";
|
|
9
8
|
export { AdminNewReleasePage } from "./pages/AdminNewReleasePage";
|
|
10
9
|
export { AdminEditReleasePage } from "./pages/AdminEditReleasePage";
|
|
@@ -14,7 +13,6 @@ export { AdminOrdersPage } from "./pages/AdminOrdersPage";
|
|
|
14
13
|
|
|
15
14
|
export { ReleaseForm } from "./components/ReleaseForm";
|
|
16
15
|
export type { ReleaseFormProps } from "./components/ReleaseForm";
|
|
17
|
-
export { AdminLoginForm } from "./components/AdminLoginForm";
|
|
18
16
|
export { AdminNav } from "./components/AdminNav";
|
|
19
17
|
|
|
20
18
|
export type {
|
package/src/lib/types.ts
CHANGED
|
@@ -14,16 +14,19 @@ export type RouteHandler = (
|
|
|
14
14
|
) => Promise<Response>;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Aggregate dependency bag for every admin route handler factory. The
|
|
18
|
-
* builds this once at startup and passes a slice (or the whole
|
|
19
|
-
* factory; nothing inside the admin package reads
|
|
17
|
+
* Aggregate dependency bag for every admin route handler factory. The
|
|
18
|
+
* consumer builds this once at startup and passes a slice (or the whole
|
|
19
|
+
* bag) into each factory; nothing inside the admin package reads
|
|
20
|
+
* `process.env` directly.
|
|
21
|
+
*
|
|
22
|
+
* Auth deliberately isn't here: the package's handlers trust that the
|
|
23
|
+
* request has been gated by the consumer's `proxy.ts` (or middleware /
|
|
24
|
+
* route-level wrapper). See `docs/setup.md` for auth recipes.
|
|
20
25
|
*/
|
|
21
26
|
export interface AdminDeps {
|
|
22
27
|
queries: Queries;
|
|
23
28
|
storage: StorageProvider;
|
|
24
29
|
audio: typeof Audio;
|
|
25
|
-
adminPasswordHash: string;
|
|
26
|
-
adminSessionSecret: string;
|
|
27
30
|
branding: EmailBranding;
|
|
28
31
|
/**
|
|
29
32
|
* Optional logger. Defaults to a no-op so the package never writes to
|
package/src/server.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
// Server-side surface: every admin API route-handler factory
|
|
2
|
-
// verification helper for the consumer's `proxy.ts`.
|
|
1
|
+
// Server-side surface: every admin API route-handler factory.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// **Auth is the consumer's responsibility.** Handlers exported here trust
|
|
4
|
+
// that the request has already been gated upstream — typically via the
|
|
5
|
+
// consumer's `proxy.ts` (Next 16) or middleware. See `docs/setup.md` for
|
|
6
|
+
// the three recommended auth recipes (bcrypt env hash, Auth.js, Clerk).
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// Each factory takes a slice of `AdminDeps`:
|
|
9
|
+
// // app/api/admin/releases/route.ts (consumer)
|
|
10
|
+
// import { createAdminReleasesHandlers } from "@gigamusic/admin/server";
|
|
11
|
+
// const handlers = createAdminReleasesHandlers({ queries });
|
|
12
|
+
// export const GET = handlers.GET;
|
|
13
|
+
// export const POST = handlers.POST;
|
|
10
14
|
|
|
11
|
-
export { createAdminLoginHandler, createAdminLogoutHandler } from "./handlers/auth";
|
|
12
15
|
export {
|
|
13
16
|
createAdminReleasesHandlers,
|
|
14
17
|
createAdminReleaseByIdHandlers,
|
|
@@ -21,14 +24,6 @@ export { createAdminSettingsHandlers } from "./handlers/settings";
|
|
|
21
24
|
export { createAdminLinksHandlers } from "./handlers/links";
|
|
22
25
|
export { createAdminOrdersHandler } from "./handlers/orders";
|
|
23
26
|
|
|
24
|
-
export {
|
|
25
|
-
verifyAdminSession,
|
|
26
|
-
isAdminAuthenticated,
|
|
27
|
-
writeAdminSessionCookie,
|
|
28
|
-
clearAdminSessionCookie,
|
|
29
|
-
ADMIN_SESSION_COOKIE,
|
|
30
|
-
} from "./lib/session";
|
|
31
|
-
|
|
32
27
|
export type {
|
|
33
28
|
AdminDeps,
|
|
34
29
|
AdminLogger,
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { useRouter, useSearchParams } from "next/navigation";
|
|
5
|
-
import { useSlot } from "@gigamusic/ui/client";
|
|
6
|
-
|
|
7
|
-
/** Block off-site redirects via the `?next=` param. */
|
|
8
|
-
function safeNext(value: string | null): string | null {
|
|
9
|
-
if (!value) return null;
|
|
10
|
-
if (!value.startsWith("/")) return null;
|
|
11
|
-
if (value.startsWith("//") || value.startsWith("/\\")) return null;
|
|
12
|
-
return value;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function AdminLoginForm() {
|
|
16
|
-
const Button = useSlot("Button");
|
|
17
|
-
const [password, setPassword] = useState("");
|
|
18
|
-
const [error, setError] = useState("");
|
|
19
|
-
const [loading, setLoading] = useState(false);
|
|
20
|
-
const router = useRouter();
|
|
21
|
-
const searchParams = useSearchParams();
|
|
22
|
-
const next = safeNext(searchParams.get("next"));
|
|
23
|
-
|
|
24
|
-
async function handleSubmit(e: React.FormEvent) {
|
|
25
|
-
e.preventDefault();
|
|
26
|
-
setLoading(true);
|
|
27
|
-
setError("");
|
|
28
|
-
const res = await fetch("/api/admin/auth", {
|
|
29
|
-
method: "POST",
|
|
30
|
-
headers: { "Content-Type": "application/json" },
|
|
31
|
-
body: JSON.stringify({ password }),
|
|
32
|
-
});
|
|
33
|
-
if (res.ok) {
|
|
34
|
-
if (next) router.push(next);
|
|
35
|
-
else router.refresh();
|
|
36
|
-
} else {
|
|
37
|
-
setError("Invalid password");
|
|
38
|
-
setLoading(false);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<form onSubmit={handleSubmit} className="gm-admin-login space-y-4">
|
|
44
|
-
<div className="space-y-2">
|
|
45
|
-
<label htmlFor="password" className="text-sm font-medium">
|
|
46
|
-
Password
|
|
47
|
-
</label>
|
|
48
|
-
<input
|
|
49
|
-
id="password"
|
|
50
|
-
type="password"
|
|
51
|
-
value={password}
|
|
52
|
-
onChange={(e) => setPassword(e.target.value)}
|
|
53
|
-
required
|
|
54
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
55
|
-
/>
|
|
56
|
-
</div>
|
|
57
|
-
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
58
|
-
<Button type="submit" className="w-full" disabled={loading}>
|
|
59
|
-
{loading ? "Logging in..." : "Log In"}
|
|
60
|
-
</Button>
|
|
61
|
-
</form>
|
|
62
|
-
);
|
|
63
|
-
}
|
package/src/handlers/auth.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { verifyAdminPassword } from "@gigamusic/core";
|
|
2
|
-
import type { Queries } from "@gigamusic/db";
|
|
3
|
-
import {
|
|
4
|
-
clearAdminSessionCookie,
|
|
5
|
-
writeAdminSessionCookie,
|
|
6
|
-
} from "../lib/session";
|
|
7
|
-
import type { RouteHandler } from "../lib/types";
|
|
8
|
-
import { json, unauthorized } from "../lib/responses";
|
|
9
|
-
import { clientIp, consumeAdminLoginAttempt } from "../lib/rate-limit";
|
|
10
|
-
|
|
11
|
-
interface LoginDeps {
|
|
12
|
-
adminPasswordHash: string;
|
|
13
|
-
adminSessionSecret: string;
|
|
14
|
-
/** Optional — supply to gate brute-force login attempts. */
|
|
15
|
-
queries?: Queries;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Build the `POST /api/admin/auth` handler. Verifies the supplied password
|
|
20
|
-
* against the consumer's bcrypt hash, mints a session cookie on success, and
|
|
21
|
-
* rate-limits per-IP attempts when `queries` is provided.
|
|
22
|
-
*/
|
|
23
|
-
export function createAdminLoginHandler(deps: LoginDeps): RouteHandler {
|
|
24
|
-
return async (req) => {
|
|
25
|
-
if (deps.queries) {
|
|
26
|
-
const result = await consumeAdminLoginAttempt(deps.queries, clientIp(req));
|
|
27
|
-
if (!result.ok) {
|
|
28
|
-
return json(
|
|
29
|
-
{ error: "Too many attempts. Please try again later." },
|
|
30
|
-
{ status: 429 },
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
let password: string;
|
|
36
|
-
try {
|
|
37
|
-
const body = (await req.json()) as { password?: unknown };
|
|
38
|
-
password = typeof body.password === "string" ? body.password : "";
|
|
39
|
-
} catch {
|
|
40
|
-
return unauthorized();
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (!password) return unauthorized();
|
|
44
|
-
const ok = await verifyAdminPassword(password, deps.adminPasswordHash);
|
|
45
|
-
if (!ok) return unauthorized();
|
|
46
|
-
|
|
47
|
-
await writeAdminSessionCookie(deps.adminSessionSecret);
|
|
48
|
-
return json({ ok: true });
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Build the `POST /api/admin/auth/logout` handler. Clears the session cookie. */
|
|
53
|
-
export function createAdminLogoutHandler(): RouteHandler {
|
|
54
|
-
return async () => {
|
|
55
|
-
await clearAdminSessionCookie();
|
|
56
|
-
return json({ ok: true });
|
|
57
|
-
};
|
|
58
|
-
}
|
package/src/lib/rate-limit.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { Queries } from "@gigamusic/db";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Extract a best-effort client IP for rate-limit bucketing. Honours common
|
|
5
|
-
* Vercel/Cloudflare forward headers and falls back to a constant so the
|
|
6
|
-
* handler still rate-limits the entire route when no IP is available.
|
|
7
|
-
*/
|
|
8
|
-
export function clientIp(req: Request): string {
|
|
9
|
-
const forwarded = req.headers.get("x-forwarded-for");
|
|
10
|
-
if (forwarded) {
|
|
11
|
-
const first = forwarded.split(",")[0]?.trim();
|
|
12
|
-
if (first) return first;
|
|
13
|
-
}
|
|
14
|
-
const real = req.headers.get("x-real-ip");
|
|
15
|
-
if (real) return real;
|
|
16
|
-
return "unknown";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const LOGIN_MAX_ATTEMPTS = 10;
|
|
20
|
-
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
|
21
|
-
|
|
22
|
-
/** Consume one slot in the admin-login bucket for `ip`. Returns false when over the limit. */
|
|
23
|
-
export function consumeAdminLoginAttempt(
|
|
24
|
-
queries: Queries,
|
|
25
|
-
ip: string,
|
|
26
|
-
): Promise<{ ok: boolean; remaining: number; resetAt: Date }> {
|
|
27
|
-
return queries.consumeRateLimit(`admin-login:${ip}`, {
|
|
28
|
-
max: LOGIN_MAX_ATTEMPTS,
|
|
29
|
-
windowMs: LOGIN_WINDOW_MS,
|
|
30
|
-
});
|
|
31
|
-
}
|
package/src/lib/session.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { cookies } from "next/headers";
|
|
2
|
-
import {
|
|
3
|
-
createAdminSessionToken,
|
|
4
|
-
verifyAdminSessionToken,
|
|
5
|
-
} from "@gigamusic/core";
|
|
6
|
-
|
|
7
|
-
export const ADMIN_SESSION_COOKIE = "admin_session";
|
|
8
|
-
const SESSION_DURATION_SECONDS = 60 * 60 * 24;
|
|
9
|
-
|
|
10
|
-
/** Default cookie options consumed by every Set-Cookie path. */
|
|
11
|
-
function cookieOptions(maxAge: number) {
|
|
12
|
-
return {
|
|
13
|
-
httpOnly: true,
|
|
14
|
-
secure: true,
|
|
15
|
-
sameSite: "lax" as const,
|
|
16
|
-
maxAge,
|
|
17
|
-
path: "/",
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Mint a fresh admin session token and write the httpOnly cookie. Called
|
|
23
|
-
* after a successful password check from `createAdminLoginHandler`.
|
|
24
|
-
*/
|
|
25
|
-
export async function writeAdminSessionCookie(
|
|
26
|
-
adminSessionSecret: string,
|
|
27
|
-
): Promise<void> {
|
|
28
|
-
const token = await createAdminSessionToken({ secret: adminSessionSecret });
|
|
29
|
-
const jar = await cookies();
|
|
30
|
-
jar.set(ADMIN_SESSION_COOKIE, token, cookieOptions(SESSION_DURATION_SECONDS));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Clear the admin session cookie (logout). */
|
|
34
|
-
export async function clearAdminSessionCookie(): Promise<void> {
|
|
35
|
-
const jar = await cookies();
|
|
36
|
-
jar.set(ADMIN_SESSION_COOKIE, "", cookieOptions(0));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Check the current request's cookie jar for a valid admin session.
|
|
41
|
-
*
|
|
42
|
-
* Returns `true` if the cookie verifies; `false` for missing or invalid tokens.
|
|
43
|
-
* Never throws — callers can branch on the boolean.
|
|
44
|
-
*/
|
|
45
|
-
export async function isAdminAuthenticated(
|
|
46
|
-
adminSessionSecret: string,
|
|
47
|
-
): Promise<boolean> {
|
|
48
|
-
const jar = await cookies();
|
|
49
|
-
const token = jar.get(ADMIN_SESSION_COOKIE)?.value;
|
|
50
|
-
if (!token) return false;
|
|
51
|
-
try {
|
|
52
|
-
await verifyAdminSessionToken(token, adminSessionSecret);
|
|
53
|
-
return true;
|
|
54
|
-
} catch {
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Stateless verification for handlers that already have a `Request`. Reads the
|
|
61
|
-
* cookie out of the request's `Cookie` header so the function works in
|
|
62
|
-
* middleware-like contexts where `next/headers` is unavailable.
|
|
63
|
-
*/
|
|
64
|
-
export async function verifyAdminSession(
|
|
65
|
-
req: Request,
|
|
66
|
-
adminSessionSecret: string,
|
|
67
|
-
): Promise<boolean> {
|
|
68
|
-
const header = req.headers.get("cookie");
|
|
69
|
-
if (!header) return false;
|
|
70
|
-
const token = parseCookieHeader(header, ADMIN_SESSION_COOKIE);
|
|
71
|
-
if (!token) return false;
|
|
72
|
-
try {
|
|
73
|
-
await verifyAdminSessionToken(token, adminSessionSecret);
|
|
74
|
-
return true;
|
|
75
|
-
} catch {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Tiny single-cookie reader. Avoids pulling in a cookie parser dependency. */
|
|
81
|
-
function parseCookieHeader(header: string, name: string): string | null {
|
|
82
|
-
const parts = header.split(";");
|
|
83
|
-
for (const raw of parts) {
|
|
84
|
-
const trimmed = raw.trim();
|
|
85
|
-
if (!trimmed.startsWith(`${name}=`)) continue;
|
|
86
|
-
return decodeURIComponent(trimmed.slice(name.length + 1));
|
|
87
|
-
}
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { AdminLoginForm } from "../components/AdminLoginForm";
|
|
2
|
-
|
|
3
|
-
/** Standalone login page — useful when the consumer wants `/admin/login` separate from layout-driven gating. */
|
|
4
|
-
export function AdminLoginPage() {
|
|
5
|
-
return (
|
|
6
|
-
<div className="gm-admin-shell mx-auto max-w-sm px-4 py-20">
|
|
7
|
-
<h1 className="mb-6 text-center text-2xl font-semibold">Admin Login</h1>
|
|
8
|
-
<AdminLoginForm />
|
|
9
|
-
</div>
|
|
10
|
-
);
|
|
11
|
-
}
|