diff --git a/.env.example b/.env.example index c4ae9229..195af580 100644 --- a/.env.example +++ b/.env.example @@ -4,14 +4,10 @@ # Docker Compose app dev (`bun run dev:docker`) # overrides DATABASE_URL and S3 endpoint to use service DNS names. DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/life_ustc_dev" -JWT_SECRET="replace-with-random-secret" WEBHOOK_SECRET="replace-with-random-secret" AUTH_SECRET="replace-with-random-secret" APP_PUBLIC_ORIGIN="http://localhost:3000" APP_CANONICAL_ORIGIN="https://life-ustc.tiankaima.dev" -BETTER_AUTH_URL="http://localhost:3000" -# Optional dedicated key for encrypting OIDC client secrets at rest. -# OIDC_CLIENT_SECRET_ENCRYPTION_KEY="replace-with-random-secret" # Storage # These values also drive the shared MinIO defaults used by `docker-compose.dev.yml` @@ -45,7 +41,7 @@ AUTH_OIDC_CLIENT_SECRET="" # Dev-only defaults # UPLOAD_TOTAL_QUOTA_MB="1024" # DEV_DEBUG_USERNAME="dev-user" -# DEV_DEBUG_NAME="Dev Debug User" +# DEV_DEBUG_NAME="Dev User" # DEV_ADMIN_USERNAME="dev-admin" # DEV_ADMIN_NAME="Dev Admin User" # When E2E_DEBUG_AUTH=1 (e.g. Playwright), set both — no defaults in non-dev NODE_ENV: diff --git a/.github/workflows/e2e-snapshot-artifacts.yml b/.github/workflows/e2e-snapshot-artifacts.yml index 96d73cb2..70b8982e 100644 --- a/.github/workflows/e2e-snapshot-artifacts.yml +++ b/.github/workflows/e2e-snapshot-artifacts.yml @@ -19,11 +19,10 @@ jobs: id: commits shell: bash run: | - if [ "${{ github.event_name }}" = "push" ]; then - shas="$(jq -c --arg sha "$GITHUB_SHA" '[.commits[].id] | if length == 0 then [$sha] else . end' "$GITHUB_EVENT_PATH")" - else - shas="$(jq -c --arg sha "$GITHUB_SHA" '[$sha]' <<< '{}')" - fi + # Always snapshot the workflow commit only. Replaying every commit from a + # multi-commit push breaks when intermediate commits have stale tooling + # paths or transient build failures that the head commit has already fixed. + shas="$(jq -c --arg sha "$GITHUB_SHA" '[$sha]' <<< '{}')" echo "shas=$shas" >> "$GITHUB_OUTPUT" comment-artifacts: @@ -118,7 +117,7 @@ jobs: exit 1 fi - bun run snapshot:e2e + bun run snapshot - name: Upload E2E snapshot artifact id: upload @@ -176,7 +175,7 @@ jobs: if: ${{ always() }} shell: bash run: | - bun run tools/dev/artifacts/render-e2e-snapshot-comment.ts \ + bun run tools/dev/artifacts/snapshots/render-snapshot-comment.ts \ --snapshot-dir test-results/e2e-snapshots \ --artifact-url "${{ steps.upload.outputs.artifact-url }}" \ --commit "${{ matrix.sha }}" \ @@ -191,7 +190,7 @@ jobs: GH_TOKEN: ${{ github.token }} shell: bash run: | - bun run tools/dev/artifacts/comment-e2e-snapshot-diff.ts \ + bun run tools/dev/artifacts/snapshots/comment-snapshot-diff.ts \ --body-file test-results/e2e-snapshot-comment.md \ --commit "${{ matrix.sha }}" diff --git a/AGENTS.md b/AGENTS.md index 82410b1d..b29fecc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,7 +195,7 @@ buildPaginatedResponse(items, page, pageSize, total) - Use `bun run verify:fast` for most commits and PR updates. - Use `bun run check:static-import -- --baseline-ref ` only when changing `tools/production/load/load-from-static.ts` and you need a DB-backed regression comparison against a real baseline. - Use `bun run verify:full` before pushing changes that affect data flows, auth, browser flows, docs contracts, or shared tooling. -- Use `bun run verify:e2e` before `bun run test:e2e`; `test:e2e:bootstrap` is now just a compatibility alias. +- Use `bun run verify:e2e` before `bun run test:e2e`. **No Stray Reports**: - Do not leave migration plans, improvement reports, status summaries, scratch artifacts, or one-time analysis outputs in the repo. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 84bb8d33..0ff2015f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -76,7 +76,6 @@ services: PORT: 3000 DATABASE_URL: postgresql://postgres:postgres@postgres:5432/life_ustc_dev APP_PUBLIC_ORIGIN: http://127.0.0.1:3000 - BETTER_AUTH_URL: http://127.0.0.1:3000 S3_BUCKET: *minio-dev-bucket AWS_ENDPOINT_URL_S3: *minio-internal-endpoint CHOKIDAR_USEPOLLING: "1" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5058b233..cc4f24d7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,7 +9,6 @@ services: APP_PUBLIC_ORIGIN: ${APP_PUBLIC_ORIGIN:?Set APP_PUBLIC_ORIGIN to the public https:// origin served by this deployment} APP_CANONICAL_ORIGIN: ${APP_CANONICAL_ORIGIN:-} DATABASE_URL: ${DATABASE_URL} - JWT_SECRET: ${JWT_SECRET} WEBHOOK_SECRET: ${WEBHOOK_SECRET} AUTH_SECRET: ${AUTH_SECRET} S3_BUCKET: ${S3_BUCKET} diff --git a/docs/features/_ui.json b/docs/features/_ui.json index bd6a235c..f58f9eec 100644 --- a/docs/features/_ui.json +++ b/docs/features/_ui.json @@ -1,4 +1,5 @@ { + "Layout Principles": "* Put reading-first content in the left/main column: introductions, descriptions, markdown, homework content, and comments.\n* Put structured facts in the right/side column: identifiers, metadata, dates, counts, actions, mini calendars, and quick links.\n* On mobile, stack in that order.\n* Reuse existing grids, cards, borders, muted text, tabs, and buttons.\n* Popups with discussion use details on the left and discussion on the right on desktop; stack on mobile.", "List Table": "* Used for discovery-type lists such as courses, sections, and teachers.\n* Primary display information is placed in the first visual column.\n* Structured fields serve disambiguation and comparison.\n* Rows are fully clickable and navigate to the detail page.", "Detail Hero": "* Used at the top of course, section, and teacher detail pages.\n* Contains breadcrumb, h1, and an optional subtitle.\n* h1 uses the primary display name of the current object, not an internal ID.", "Basic Info Card": "* Used in detail page sidebars or collapsible panels.\n* Displays structured fields; does not carry comment or homework interactions.", diff --git a/docs/features/dashboard-link.json b/docs/features/dashboard-link.json index c85e459e..96ec036a 100644 --- a/docs/features/dashboard-link.json +++ b/docs/features/dashboard-link.json @@ -30,7 +30,8 @@ "link.slug visit action", "link.group", "link.isPinned (if authenticated)", - "Search input/query" + "Search input/query", + "Grid/list view mode persisted in browser storage" ] } }, diff --git a/docs/features/homework.json b/docs/features/homework.json index 09500467..81f49119 100644 --- a/docs/features/homework.json +++ b/docs/features/homework.json @@ -9,7 +9,8 @@ "rules": { "attached-to-section": "Homework is attached to a section, not a personal user todo.", "no-subscription-required": "Creating homework does not require the user to be subscribed to the section first.", - "entity-and-completion-separated": "Homework entity and homework completion state are strictly separated to prevent 'I completed it' from becoming 'the homework was modified'." + "entity-and-completion-separated": "Homework entity and homework completion state are strictly separated to prevent 'I completed it' from becoming 'the homework was modified'.", + "compact-card-list-surface": "In card and list views, the default homework surface only shows title, subtitle (course name when useful plus non-default attribute badges), submission due date, relative due label, and a small completion action. Standard/default homework is not shown as a separate badge. Course/section context, description, homework timestamps, discussion, and secondary actions belong in a centered detail popup that opens on click and closes via outside click or Escape." }, "capabilities": { "cross-section-homework-summary": { @@ -52,7 +53,9 @@ "homework.isMajor badge", "homework.requiresTeam badge", "completionStatus (completed/pending)", - "filter: incomplete/completed/all" + "filter: incomplete/completed/all", + "cards/list view mode persisted in browser storage", + "detail popup order: description, due summary, vertical metadata excluding platform createdAt, action controls, discussion; desktop places discussion to the right of the details" ] } }, @@ -128,12 +131,14 @@ "homework.title", "homework.description.content", "homework.submissionDueAt", - "homework.createdAt", "homework.submissionStartAt", - "homework.publishedAt", - "commentCount / comments action", + "homework.publishedAt as homework publication date", + "inline homework discussion", "user completion status", - "edit action" + "edit action", + "cards/list view mode persisted in browser storage", + "detail popup order: description, due summary, vertical metadata excluding platform createdAt, edit/completion controls, inline discussion; desktop places discussion to the right of the details", + "section cards use a responsive multi-column layout" ] } }, @@ -191,14 +196,6 @@ "notes": [ "Pass completed=true to mark as done, completed=false to revert to incomplete." ] - }, - { - "name": "unset_my_homework_completion", - "returns": "{ success: Boolean, completion: { homeworkId: String, completed: Boolean, completedAt: DateTime? } }", - "rest_equivalent": "PUT /api/homeworks/[id]/completion", - "notes": [ - "Dedicated tool to revert a completed homework back to incomplete; equivalent to set_my_homework_completion with completed=false." - ] } ] }, diff --git a/docs/features/mcp.json b/docs/features/mcp.json index 2e480431..14f7cb67 100644 --- a/docs/features/mcp.json +++ b/docs/features/mcp.json @@ -9,10 +9,11 @@ "rules": { "personal-workspace-focus": "MCP focuses by default on personal learning workspace, public query, and low-risk personal state write capabilities; admin capabilities are not exposed by default.", "text-formatted-json": "Current tool output is uniformly text-formatted JSON.", - "output-modes": "Output mode has three levels: summary for counts/top samples, default for compact structured data, and full for exact raw records. Default is recommended for most agent calls.", + "output-modes": "Output mode has three levels: summary for counts/returned-item totals plus top samples, default for compact structured data, and full for exact raw records. Default is recommended for most agent calls.", "coverage": "MCP currently covers profile, todos, courses, sections, teachers, semesters, subscriptions, schedules, calendar events, assistant dashboard snapshots, and bus discovery/next-trip queries; comment, upload, description governance, link management, and admin capabilities do not yet have corresponding tools.", "aggregate-before-fanout": "Prefer assistant-oriented aggregate or filtered tools first; raw dataset tools remain available for power clients that need local post-processing.", "privacy-safe-summary": "Summary/default outputs may omit repeated low-value nested objects and redact token-bearing URLs or other sensitive strings; full mode is the escape hatch when exact raw values are required.", + "actionable-errors": "Validation and common not-found payloads prefer plain-language messages and may include a hint that points to the next useful tool or query to recover.", "resource-bound-access-token": "MCP transport requests must present a resource-bound Bearer token for /api/mcp. JWT access tokens minted with resource=/api/mcp are accepted; opaque tokens minted without a resource indicator are rejected because the server cannot prove MCP audience binding from those token records.", "flexible-date-inputs": "Date and datetime parameters on MCP tools accept ISO 8601 with timezone offset (2026-05-01T08:00:00+08:00), bare date strings (2026-05-01, treated as UTC midnight for @db.Date columns), or timezone-less datetimes (2026-05-01T08:00:00, interpreted as Asia/Shanghai). Invalid strings produce a descriptive error response rather than a validation rejection.", "time-override": "Time-sensitive tools (get_my_7days_timeline, get_upcoming_deadlines, get_my_overview, get_next_buses) accept an optional atTime parameter to anchor their internal clock to a caller-supplied moment instead of the server clock, enabling reproducible queries and future-scenario planning." @@ -110,7 +111,6 @@ "tools": [ "list_my_homeworks", "set_my_homework_completion", - "unset_my_homework_completion", "list_my_schedules", "list_my_exams", "list_homeworks_by_section", diff --git a/docs/features/oauth.json b/docs/features/oauth.json index 0f004959..32113bd7 100644 --- a/docs/features/oauth.json +++ b/docs/features/oauth.json @@ -22,6 +22,7 @@ "protected-resource-canonical-path": "For protected resources with a path (currently MCP resource /api/mcp), the canonical entry per RFC 9728 should be /.well-known/oauth-protected-resource/api/mcp; the root-level /.well-known/oauth-protected-resource serves only as a compatibility alias redirect to the canonical address, to avoid returning metadata inconsistent with the resource field.", "mcp-discovery-compatibility-aliases": "Because some MCP clients probe resource-relative or issuer-style well-known paths before settling on canonical metadata, /api/mcp/.well-known/oauth-authorization-server, /.well-known/oauth-authorization-server/api/mcp, /api/mcp/.well-known/openid-configuration, and /.well-known/openid-configuration/api/mcp should redirect to the issuer metadata used by the MCP protected resource.", "aliases-use-redirect": "Compatibility aliases should use redirects rather than redundantly returning a JSON that looks usable but is inconsistent with issuer/resource validation, so that clients ultimately complete metadata validation at the canonical address.", + "discovery-route-targets": "Discovery metadata and compatibility aliases are wired through a shared route-target table so canonical metadata and alias redirects stay consistent when paths are added or retired.", "discovery-cors": "Discovery metadata should support cross-origin reading; at minimum return Access-Control-Allow-Origin: * for OpenID discovery, and keep the CORS behavior of authorization server metadata and protected resource metadata consistent, reducing compatibility risks for browser-type clients and debugging tools.", "transport-cors": "The MCP transport endpoint /api/mcp should also support browser-based clients and debugging tools using Bearer tokens: answer OPTIONS preflights, allow the MCP-specific request headers, and expose MCP-Session-Id and WWW-Authenticate on cross-origin transport responses.", "transport-origin-validation": "Per the MCP Streamable HTTP transport guidance, /api/mcp should reject requests carrying an Origin header unless that origin matches the app's trusted origin set (public/canonical origin, localhost dev aliases, and allowed preview hosts). Non-browser clients that omit Origin remain supported." diff --git a/docs/features/user.json b/docs/features/user.json index acc625ec..8890612a 100644 --- a/docs/features/user.json +++ b/docs/features/user.json @@ -63,6 +63,7 @@ "display": { "fields": [ "callbackUrl query parameter (origin page)", + "Sign-in action links include the current path and query as callbackUrl", "Fallback to home page if no origin" ] } diff --git a/messages/en-us.json b/messages/en-us.json index 3b9f7770..878664e2 100644 --- a/messages/en-us.json +++ b/messages/en-us.json @@ -500,6 +500,9 @@ "searchShortcutHint": "Press ⌘K or Ctrl+K to focus search", "allSitesTab": "All websites", "overviewHint": "Overview shows 5 sites only: your pins first, then recommended ones.", + "viewMode": "Website view", + "gridView": "Grid", + "listView": "List", "pin": "Pin", "unpin": "Unpin", "pinFailedTitle": "Pin update failed", @@ -582,6 +585,7 @@ "startShort": "From", "endShort": "To", "empty": "No routes serve that stop pair in the selected direction. Try reversing the direction or picking another stop.", + "emptyReverseAction": "Reverse direction", "departIn": "Departs in about {count} minutes", "departEtaMinutes": "{count, plural, one {# minute} other {# minutes}}", "departEtaHours": "{count, plural, one {# hour} other {# hours}}", @@ -589,6 +593,8 @@ "etaUnknown": "ETA unavailable", "estimatedHint": "~ marks an estimated time inferred from nearby stops on the same trip.", "clientHint": "Day type, route matching, and ranking are computed in your browser from the raw timetable data.", + "direction": "Direction", + "routes": "Routes", "routeSectionsCount": "{count} route sections", "departureColumn": "Depart", "routeColumn": "Route timetable", @@ -1145,6 +1151,7 @@ "descriptionLabel": "Details", "descriptionPlaceholder": "Add requirements, submission format, and grading notes", "publishedAt": "Published", + "homeworkPublishedAt": "Homework published", "submissionStart": "Submission opens", "submissionDue": "Submission due", "helperPublishNow": "Publish now", @@ -1216,6 +1223,9 @@ "filterIncomplete": "Incomplete", "filterCompleted": "Completed", "filterAll": "All", + "viewMode": "Homework view", + "cardView": "Cards", + "listView": "List", "filterEmptyTitle": "No homework under this filter", "filterEmptyDescription": "Try another filter, or check back later.", "addButton": "Add homework", @@ -1566,6 +1576,9 @@ "previewScopeCount": "{count, plural, =0 {No scopes selected} one {# scope selected} other {# scopes selected}}", "existingClients": "Existing Clients", "existingClientsDescription": "Trusted first-party clients are separated from external and public clients so the admin inventory is easier to audit.", + "clientPageStatus": "Showing {start}-{end} of {total}", + "previousPage": "Previous", + "nextPage": "Next", "tableColumnScopes": "Scopes", "tableColumnRedirects": "Redirects", "tableColumnActions": "Actions", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index d76b9641..19827601 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -500,6 +500,9 @@ "searchShortcutHint": "按 Ctrl+K 或 ⌘K 聚焦搜索", "allSitesTab": "全部网站", "overviewHint": "总览仅展示 5 个网站:优先展示你的置顶,其余按推荐补齐。", + "viewMode": "网站视图", + "gridView": "网格", + "listView": "列表", "pin": "置顶", "unpin": "取消置顶", "pinFailedTitle": "置顶更新失败", @@ -582,6 +585,7 @@ "startShort": "起", "endShort": "终", "empty": "当前方向下没有可用路线。可以尝试反向或重新选择站点。", + "emptyReverseAction": "反向查询", "departIn": "约 {count} 分钟后发车", "departEtaMinutes": "{count} 分钟", "departEtaHours": "{count} 小时", @@ -589,6 +593,8 @@ "etaUnknown": "到站时间未知", "estimatedHint": "~ 表示该时间由同班次相邻站点推算得出。", "clientHint": "工作日/周末判断、路线匹配和排序都在浏览器端基于原始时刻表完成。", + "direction": "方向", + "routes": "路线", "routeSectionsCount": "共 {count} 条路线分组", "departureColumn": "出发", "routeColumn": "路线时刻", @@ -1122,6 +1128,7 @@ "descriptionLabel": "说明", "descriptionPlaceholder": "补充作业要求、提交方式、评分规则等", "publishedAt": "发布日期", + "homeworkPublishedAt": "作业发布日期", "submissionStart": "提交开始", "submissionDue": "提交截止", "helperPublishNow": "立即发布", @@ -1193,6 +1200,9 @@ "filterIncomplete": "未完成", "filterCompleted": "已完成", "filterAll": "全部", + "viewMode": "作业视图", + "cardView": "卡片", + "listView": "列表", "filterEmptyTitle": "当前筛选下暂无作业", "filterEmptyDescription": "切换筛选条件,或稍后再试。", "addButton": "添加作业", @@ -1543,6 +1553,9 @@ "previewScopeCount": "{count, plural, =0 {尚未选择权限} one {已选择 # 个权限} other {已选择 # 个权限}}", "existingClients": "已有客户端", "existingClientsDescription": "把可信第一方客户端与外部 / 公共客户端分开展示,更方便后台审计。", + "clientPageStatus": "显示第 {start}-{end} 个,共 {total} 个", + "previousPage": "上一页", + "nextPage": "下一页", "tableColumnScopes": "权限范围", "tableColumnRedirects": "重定向", "tableColumnActions": "操作", diff --git a/package.json b/package.json index 40e4e726..b2588a78 100644 --- a/package.json +++ b/package.json @@ -44,12 +44,12 @@ "production:load:static": "bun run tools/production/load/load-from-static.ts", "production:register-ios-client": "bun run tools/production/util/register-ios-client.ts", "release:semantic": "bun run tools/release/run-semantic-release.ts", - "snapshot:e2e": "bun run tools/dev/artifacts/dump-e2e-snapshots.ts", - "snapshot:e2e:api": "bun run tools/dev/artifacts/dump-api-snapshots.ts", - "snapshot:e2e:comment": "bun run tools/dev/artifacts/comment-e2e-snapshot-diff.ts", - "snapshot:e2e:diff": "bun run tools/dev/artifacts/diff-e2e-snapshots.ts", - "snapshot:e2e:mcp": "bun run tools/dev/artifacts/dump-mcp-snapshots.ts", - "snapshot:e2e:pages": "bun run tools/dev/artifacts/dump-page-snapshots.ts", + "snapshot": "bun run tools/dev/artifacts/snapshots/dump-snapshots.ts", + "snapshot:api": "bun run tools/dev/artifacts/snapshots/dump-api-snapshots.ts", + "snapshot:comment": "bun run tools/dev/artifacts/snapshots/comment-snapshot-diff.ts", + "snapshot:diff": "bun run tools/dev/artifacts/snapshots/diff-snapshots.ts", + "snapshot:mcp": "bun run tools/dev/artifacts/snapshots/dump-mcp-snapshots.ts", + "snapshot:pages": "bun run tools/dev/artifacts/snapshots/dump-page-snapshots.ts", "start": "next start", "test": "vitest run", "test:e2e": "playwright test --reporter=list", diff --git a/public/openapi.generated.json b/public/openapi.generated.json index 2f1a3350..fad5b239 100644 --- a/public/openapi.generated.json +++ b/public/openapi.generated.json @@ -159,7 +159,7 @@ "/.well-known/oauth-protected-resource/api/mcp": { "get": { "operationId": "get-.well-known-oauth-protected-resource-api-mcp", - "summary": "", + "summary": "Canonical RFC 9728 protected resource metadata for MCP.", "description": "", "tags": [ "Api" @@ -167,27 +167,19 @@ "parameters": [], "responses": { "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": {} - } - } + "description": "Response 200" } } }, "options": { "operationId": "options-.well-known-oauth-protected-resource-api-mcp", - "summary": "Canonical RFC 9728 protected resource metadata for MCP.", + "summary": "", "description": "", "tags": [ "Api" ], "parameters": [], "responses": { - "200": { - "description": "Response 200" - }, "204": { "description": "Response 204" } diff --git a/src/app/.well-known/oauth-authorization-server/api/auth/route.ts b/src/app/.well-known/oauth-authorization-server/api/auth/route.ts index de911349..1396b60f 100644 --- a/src/app/.well-known/oauth-authorization-server/api/auth/route.ts +++ b/src/app/.well-known/oauth-authorization-server/api/auth/route.ts @@ -1,7 +1,4 @@ -import { - createDiscoveryMetadataRoute, - getAuthServerMetadataResponse, -} from "@/lib/oauth/discovery-metadata"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; @@ -9,6 +6,4 @@ export const dynamic = "force-dynamic"; * Canonical RFC 8414 authorization server metadata for issuer `/api/auth`. * @response 200 */ -export const { GET, OPTIONS } = createDiscoveryMetadataRoute( - getAuthServerMetadataResponse, -); +export const { GET, OPTIONS } = createOAuthDiscoveryRoute("authServerMetadata"); diff --git a/src/app/.well-known/oauth-authorization-server/api/mcp/route.ts b/src/app/.well-known/oauth-authorization-server/api/mcp/route.ts index 324aab31..0734c2ee 100644 --- a/src/app/.well-known/oauth-authorization-server/api/mcp/route.ts +++ b/src/app/.well-known/oauth-authorization-server/api/mcp/route.ts @@ -1,5 +1,4 @@ -import { getOAuthAuthorizationServerMetadataUrl } from "@/lib/mcp/urls"; -import { createDiscoveryRedirectRoute } from "@/lib/oauth/discovery-metadata"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; @@ -7,6 +6,4 @@ export const dynamic = "force-dynamic"; * Compatibility alias for clients that probe resource-path authorization-server metadata. * @response 307 */ -export const { GET, OPTIONS } = createDiscoveryRedirectRoute((request) => - getOAuthAuthorizationServerMetadataUrl(request), -); +export const { GET, OPTIONS } = createOAuthDiscoveryRoute("authServerAlias"); diff --git a/src/app/.well-known/oauth-authorization-server/route.ts b/src/app/.well-known/oauth-authorization-server/route.ts index 3bdabc39..d45a543c 100644 --- a/src/app/.well-known/oauth-authorization-server/route.ts +++ b/src/app/.well-known/oauth-authorization-server/route.ts @@ -1,5 +1,4 @@ -import { getOAuthAuthorizationServerMetadataUrl } from "@/lib/mcp/urls"; -import { createDiscoveryRedirectRoute } from "@/lib/oauth/discovery-metadata"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; /** @@ -7,6 +6,4 @@ export const dynamic = "force-dynamic"; * The canonical metadata URL for issuer `/api/auth` is path-specific. * @response 307 */ -export const { GET, OPTIONS } = createDiscoveryRedirectRoute(() => - getOAuthAuthorizationServerMetadataUrl(), -); +export const { GET, OPTIONS } = createOAuthDiscoveryRoute("authServerAlias"); diff --git a/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts b/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts index 50368b0e..c93a47fb 100644 --- a/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts +++ b/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts @@ -1,10 +1,4 @@ -import { NextResponse } from "next/server"; -import { getMcpServerUrl, getOAuthIssuerUrl } from "@/lib/mcp/urls"; -import { - createDiscoveryMetadataRoute, - getDiscoveryOptionsResponse, -} from "@/lib/oauth/discovery-metadata"; -import { MCP_TOOLS_SCOPE } from "@/lib/oauth/utils"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; @@ -12,35 +6,6 @@ export const dynamic = "force-dynamic"; * Canonical RFC 9728 protected resource metadata for MCP. * @response 200 */ -async function getProtectedResourceMetadataResponse(request: Request) { - const issuerUrl = getOAuthIssuerUrl(request); - - return NextResponse.json( - { - resource: getMcpServerUrl(request).toString(), - authorization_servers: [issuerUrl.toString()], - scopes_supported: [MCP_TOOLS_SCOPE], - bearer_methods_supported: ["header"], - resource_documentation: new URL("/api-docs", issuerUrl).toString(), - }, - { - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - }, - }, - ); -} - -export const { GET } = createDiscoveryMetadataRoute( - getProtectedResourceMetadataResponse, +export const { GET, OPTIONS } = createOAuthDiscoveryRoute( + "protectedResourceMetadata", ); - -/** - * CORS preflight for protected resource metadata. - * @response 204 - */ -export function OPTIONS() { - return getDiscoveryOptionsResponse(); -} diff --git a/src/app/.well-known/oauth-protected-resource/route.ts b/src/app/.well-known/oauth-protected-resource/route.ts index 4a1ab97b..fc5bf94b 100644 --- a/src/app/.well-known/oauth-protected-resource/route.ts +++ b/src/app/.well-known/oauth-protected-resource/route.ts @@ -1,5 +1,4 @@ -import { getOAuthProtectedResourceMetadataUrl } from "@/lib/mcp/urls"; -import { createDiscoveryRedirectRoute } from "@/lib/oauth/discovery-metadata"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; @@ -8,6 +7,6 @@ export const dynamic = "force-dynamic"; * The canonical RFC 9728 URL is path-specific for resource `/api/mcp`. * @response 307 */ -export const { GET, OPTIONS } = createDiscoveryRedirectRoute(() => - getOAuthProtectedResourceMetadataUrl(), +export const { GET, OPTIONS } = createOAuthDiscoveryRoute( + "protectedResourceAlias", ); diff --git a/src/app/.well-known/openid-configuration/api/auth/route.ts b/src/app/.well-known/openid-configuration/api/auth/route.ts index 6dad123c..acee7e56 100644 --- a/src/app/.well-known/openid-configuration/api/auth/route.ts +++ b/src/app/.well-known/openid-configuration/api/auth/route.ts @@ -1,7 +1,4 @@ -import { - createDiscoveryMetadataRoute, - getOpenIdMetadataResponse, -} from "@/lib/oauth/discovery-metadata"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; @@ -9,6 +6,4 @@ export const dynamic = "force-dynamic"; * RFC 8414-compatible path form for OpenID provider metadata. * @response 200 */ -export const { GET, OPTIONS } = createDiscoveryMetadataRoute( - getOpenIdMetadataResponse, -); +export const { GET, OPTIONS } = createOAuthDiscoveryRoute("openIdMetadata"); diff --git a/src/app/.well-known/openid-configuration/api/mcp/route.ts b/src/app/.well-known/openid-configuration/api/mcp/route.ts index 98fc89d4..2a72fbfa 100644 --- a/src/app/.well-known/openid-configuration/api/mcp/route.ts +++ b/src/app/.well-known/openid-configuration/api/mcp/route.ts @@ -1,5 +1,4 @@ -import { getOAuthOpenIdConfigurationUrl } from "@/lib/mcp/urls"; -import { createDiscoveryRedirectRoute } from "@/lib/oauth/discovery-metadata"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; @@ -7,6 +6,4 @@ export const dynamic = "force-dynamic"; * Compatibility alias for clients that probe resource-path OIDC metadata. * @response 307 */ -export const { GET, OPTIONS } = createDiscoveryRedirectRoute((request) => - getOAuthOpenIdConfigurationUrl(request), -); +export const { GET, OPTIONS } = createOAuthDiscoveryRoute("openIdAlias"); diff --git a/src/app/.well-known/openid-configuration/route.ts b/src/app/.well-known/openid-configuration/route.ts index 84e55a25..f610e018 100644 --- a/src/app/.well-known/openid-configuration/route.ts +++ b/src/app/.well-known/openid-configuration/route.ts @@ -1,5 +1,4 @@ -import { getOAuthOpenIdConfigurationUrl } from "@/lib/mcp/urls"; -import { createDiscoveryRedirectRoute } from "@/lib/oauth/discovery-metadata"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; /** @@ -7,6 +6,4 @@ export const dynamic = "force-dynamic"; * The canonical OIDC discovery URL remains `{issuer}/.well-known/openid-configuration`. * @response 307 */ -export const { GET, OPTIONS } = createDiscoveryRedirectRoute(() => - getOAuthOpenIdConfigurationUrl(), -); +export const { GET, OPTIONS } = createOAuthDiscoveryRoute("openIdAlias"); diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts new file mode 100644 index 00000000..0dbb3b4c --- /dev/null +++ b/src/app/actions/auth.ts @@ -0,0 +1,19 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { signOut } from "@/auth"; +import { logServerActionError } from "@/lib/log/app-logger"; + +export async function signOutCurrentUser() { + try { + await signOut({ redirect: false }); + revalidatePath("/"); + + return { success: true }; + } catch (error) { + logServerActionError("Failed to sign out", error, { + action: "signOutCurrentUser", + }); + return { error: "Failed to sign out" }; + } +} diff --git a/src/app/actions/oauth.ts b/src/app/actions/oauth.ts index deb98327..0442ad1f 100644 --- a/src/app/actions/oauth.ts +++ b/src/app/actions/oauth.ts @@ -7,71 +7,83 @@ import { auth, authApi } from "@/auth"; import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db/prisma"; import { logServerActionError } from "@/lib/log/app-logger"; -import { resolveOAuthClientScopes } from "@/lib/oauth/client-registration"; -import { asOAuthProviderApi } from "@/lib/oauth/provider-api"; import { - DEFAULT_OAUTH_CLIENT_SCOPES, + resolveOAuthClientGrantTypes, + resolveOAuthClientScopes, +} from "@/lib/oauth/client-registration"; +import { + isSupportedOAuthClientAuthMethod, OAUTH_CLIENT_SECRET_BASIC_AUTH_METHOD, OAUTH_CLIENT_SECRET_POST_AUTH_METHOD, + OAUTH_CODE_RESPONSE_TYPE, OAUTH_PUBLIC_CLIENT_AUTH_METHOD, -} from "@/lib/oauth/utils"; + type SupportedOAuthClientAuthMethod, +} from "@/lib/oauth/constants"; +import { asOAuthProviderApi } from "@/lib/oauth/provider-api"; type CreateOAuthClientResult = | { error: string } | { success: true; clientId: string; clientSecret: string | null }; -function resolveAdminOAuthClientPattern(tokenEndpointAuthMethod: string) { - if (tokenEndpointAuthMethod === OAUTH_PUBLIC_CLIENT_AUTH_METHOD) { - return { - pattern: "public_pkce", - skipConsent: false, - enableEndSession: false, - } as const; - } - - if (tokenEndpointAuthMethod === OAUTH_CLIENT_SECRET_POST_AUTH_METHOD) { - return { - pattern: "confidential_connector", - skipConsent: false, - enableEndSession: false, - } as const; +const ADMIN_OAUTH_CLIENT_PATTERNS: Record< + SupportedOAuthClientAuthMethod, + { + pattern: "public_pkce" | "confidential_connector" | "trusted_first_party"; + skipConsent: boolean; + enableEndSession: boolean; } - - return { +> = { + [OAUTH_PUBLIC_CLIENT_AUTH_METHOD]: { + pattern: "public_pkce", + skipConsent: false, + enableEndSession: false, + }, + [OAUTH_CLIENT_SECRET_POST_AUTH_METHOD]: { + pattern: "confidential_connector", + skipConsent: false, + enableEndSession: false, + }, + [OAUTH_CLIENT_SECRET_BASIC_AUTH_METHOD]: { pattern: "trusted_first_party", skipConsent: true, enableEndSession: true, - } as const; + }, +} as const; + +function resolveAdminOAuthClientPattern(tokenEndpointAuthMethod: string) { + return isSupportedOAuthClientAuthMethod(tokenEndpointAuthMethod) + ? ADMIN_OAUTH_CLIENT_PATTERNS[tokenEndpointAuthMethod] + : null; +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; } function getOAuthActionErrorMessage(error: unknown, fallback: string) { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; + const errorMessage = + error instanceof Error ? nonEmptyString(error.message) : null; + if (errorMessage) { + return errorMessage; } if (error && typeof error === "object") { const record = error as Record; - if ( - typeof record.message === "string" && - record.message.trim().length > 0 - ) { - return record.message; + const recordMessage = nonEmptyString(record.message); + if (recordMessage) { + return recordMessage; } const body = record.body; if (body && typeof body === "object") { const bodyRecord = body as Record; - if ( - typeof bodyRecord.error_description === "string" && - bodyRecord.error_description.trim().length > 0 - ) { - return bodyRecord.error_description; + const errorDescription = nonEmptyString(bodyRecord.error_description); + if (errorDescription) { + return errorDescription; } - if ( - typeof bodyRecord.message === "string" && - bodyRecord.message.trim().length > 0 - ) { - return bodyRecord.message; + const bodyMessage = nonEmptyString(bodyRecord.message); + if (bodyMessage) { + return bodyMessage; } } } @@ -120,21 +132,16 @@ export async function createOAuthClient( .map((s) => s.trim()) .filter(Boolean); - const scopesResult = resolveOAuthClientScopes({ - defaultScopes: [...DEFAULT_OAUTH_CLIENT_SCOPES], - requestedScopes: requestedScopes.length > 0 ? requestedScopes : undefined, - }); + const scopesResult = resolveOAuthClientScopes( + requestedScopes.length > 0 ? requestedScopes : undefined, + ); if ("error" in scopesResult) { return { error: scopesResult.error }; } const scopes = scopesResult.scopes; const clientPattern = resolveAdminOAuthClientPattern(tokenEndpointAuthMethod); - if ( - tokenEndpointAuthMethod !== OAUTH_CLIENT_SECRET_BASIC_AUTH_METHOD && - tokenEndpointAuthMethod !== OAUTH_CLIENT_SECRET_POST_AUTH_METHOD && - tokenEndpointAuthMethod !== OAUTH_PUBLIC_CLIENT_AUTH_METHOD - ) { + if (!clientPattern) { return { error: "Unsupported token endpoint auth method" }; } @@ -145,10 +152,8 @@ export async function createOAuthClient( client_name: name, redirect_uris: redirectUris, token_endpoint_auth_method: tokenEndpointAuthMethod, - grant_types: scopes.includes("offline_access") - ? ["authorization_code", "refresh_token"] - : ["authorization_code"], - response_types: ["code"], + grant_types: resolveOAuthClientGrantTypes(scopes), + response_types: [OAUTH_CODE_RESPONSE_TYPE], scope: scopes.join(" "), require_pkce: true, skip_consent: clientPattern.skipConsent, diff --git a/src/app/admin/bus/bus-version-manager.tsx b/src/app/admin/bus/bus-version-manager.tsx index 3d2f0e46..07628e4e 100644 --- a/src/app/admin/bus/bus-version-manager.tsx +++ b/src/app/admin/bus/bus-version-manager.tsx @@ -102,9 +102,14 @@ export function BusVersionManager({ versions }: { versions: VersionRow[] }) { return (
-
+

{t("versionsTitle")}

- + )} + {!v.isEnabled && ( + + )} +
+ + + ))} + + +
+ )}
{t(option.strategyTitleKey)} - + {t(option.labelKey)}
diff --git a/src/app/admin/oauth/oauth-client-list.tsx b/src/app/admin/oauth/oauth-client-list.tsx index ccaf2337..3d8f8ee3 100644 --- a/src/app/admin/oauth/oauth-client-list.tsx +++ b/src/app/admin/oauth/oauth-client-list.tsx @@ -1,6 +1,7 @@ "use client"; -import { Copy } from "lucide-react"; +import { ChevronLeft, ChevronRight, Copy } from "lucide-react"; +import { useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -13,13 +14,14 @@ import { import { Field, FieldLabel } from "@/components/ui/field"; import { type CreatedCredentials, - getClientPatternDescriptionKey, getClientTypeBadgeVariant, getClientTypeLabel, type OAuthClientInfo, type OAuthTranslator, } from "./oauth-client-manager-shared"; +const CLIENTS_PER_SECTION_PAGE = 3; + function CopyField({ label, value, @@ -36,20 +38,23 @@ function CopyField({ return ( {label} -
+
+

+ {dateTimeFormatter.format(new Date(client.createdAt))} +

-
-
-

+

+
+ {t("clientIdLabel")} -

-
- - {client.clientId} - - -
+
+ + {client.clientId} + +
-
-

+

+

{t("tableColumnRedirects")}

{redirectUris.length === 0 ? ( -

+

) : ( -
+
{redirectUris.map((redirectUri) => (
- {redirectUri} - +
-
-
-

- {t("tableColumnScopes")} +

+ {client.scopes + .map((scope) => t(`scope_${scope}`, { fallback: scope })) + .join(" · ")}

-
- {client.scopes.map((scope) => ( - - - {t(`scope_${scope}`, { fallback: scope })} - - - ))} -
- - + + +
+ ); } @@ -225,36 +211,94 @@ function OAuthClientSection({ description: string; t: OAuthTranslator; }) { + const [page, setPage] = useState(1); + const totalPages = Math.max( + 1, + Math.ceil(clients.length / CLIENTS_PER_SECTION_PAGE), + ); + const currentPage = Math.min(page, totalPages); + const startIndex = (currentPage - 1) * CLIENTS_PER_SECTION_PAGE; + const visibleClients = clients.slice( + startIndex, + startIndex + CLIENTS_PER_SECTION_PAGE, + ); + const showingStart = clients.length === 0 ? 0 : startIndex + 1; + const showingEnd = Math.min( + startIndex + visibleClients.length, + clients.length, + ); + return ( - - -
- {title} +
+
+
+

{title}

{clients.length}
- {description} - - +

+ {description} +

+
+
{clients.length === 0 ? ( -
+
{t(emptyKey)}
) : ( - clients.map((client) => ( - - )) + <> +
+ {visibleClients.map((client) => ( + + ))} +
+ {totalPages > 1 ? ( +
+

+ {t("clientPageStatus", { + start: showingStart, + end: showingEnd, + total: clients.length, + })} +

+
+ + +
+
+ ) : null} + )} - - +
+
); } @@ -270,7 +314,7 @@ export function CreatedCredentialsCard({ t: OAuthTranslator; }) { return ( - + {t("credentialsTitle")} {t("credentialsWarning")} @@ -374,22 +418,22 @@ export function OAuthClientList({ }) { if (clients.length === 0) { return ( - - - {t("existingClients")} - {t("existingClientsDescription")} - - -
- {t("noClients")} -
-
-
+
+

+ {t("existingClients")} +

+

+ {t("existingClientsDescription")} +

+
+ {t("noClients")} +
+
); } return ( -
+
string; export type AuthMethodOption = { - value: string; + value: SupportedOAuthClientAuthMethod; icon: LucideIcon; labelKey: string; descriptionKey: string; @@ -43,6 +52,7 @@ export type AuthMethodOption = { strategyHintKey: string; accentClassName: string; accentIconClassName: string; + badgeVariant: "info" | "success" | "warning"; }; export const AUTH_METHOD_OPTIONS: AuthMethodOption[] = [ @@ -54,10 +64,9 @@ export const AUTH_METHOD_OPTIONS: AuthMethodOption[] = [ strategyTitleKey: "strategyFirstPartyTitle", strategyDescriptionKey: "strategyFirstPartyDescription", strategyHintKey: "strategyFirstPartyHint", - accentClassName: - "border-sky-500/24 bg-sky-500/[0.08] text-sky-800 dark:text-sky-200", - accentIconClassName: - "border-sky-500/24 bg-sky-500/[0.12] text-sky-700 dark:text-sky-200", + accentClassName: "border-foreground bg-muted/45 text-foreground", + accentIconClassName: "border-border bg-background text-foreground", + badgeVariant: "info", }, { value: OAUTH_PUBLIC_CLIENT_AUTH_METHOD, @@ -67,10 +76,9 @@ export const AUTH_METHOD_OPTIONS: AuthMethodOption[] = [ strategyTitleKey: "strategyPublicTitle", strategyDescriptionKey: "strategyPublicDescription", strategyHintKey: "strategyPublicHint", - accentClassName: - "border-emerald-500/24 bg-emerald-500/[0.08] text-emerald-800 dark:text-emerald-200", - accentIconClassName: - "border-emerald-500/24 bg-emerald-500/[0.12] text-emerald-700 dark:text-emerald-200", + accentClassName: "border-foreground bg-muted/45 text-foreground", + accentIconClassName: "border-border bg-background text-foreground", + badgeVariant: "success", }, { value: OAUTH_CLIENT_SECRET_POST_AUTH_METHOD, @@ -80,20 +88,19 @@ export const AUTH_METHOD_OPTIONS: AuthMethodOption[] = [ strategyTitleKey: "strategyAdvancedTitle", strategyDescriptionKey: "strategyAdvancedDescription", strategyHintKey: "strategyAdvancedHint", - accentClassName: - "border-amber-500/24 bg-amber-500/[0.08] text-amber-800 dark:text-amber-100", - accentIconClassName: - "border-amber-500/24 bg-amber-500/[0.12] text-amber-700 dark:text-amber-100", + accentClassName: "border-foreground bg-muted/45 text-foreground", + accentIconClassName: "border-border bg-background text-foreground", + badgeVariant: "warning", }, ]; export const SCOPE_OPTIONS = [ { - value: "openid", + value: OAUTH_OPENID_SCOPE, descriptionKey: "scopeOpenIdDescription", }, { - value: "profile", + value: OAUTH_PROFILE_SCOPE, descriptionKey: "scopeProfileDescription", }, { @@ -103,23 +110,11 @@ export const SCOPE_OPTIONS = [ ] as const; export function getClientTypeBadgeVariant(method: string) { - if (method === OAUTH_PUBLIC_CLIENT_AUTH_METHOD) { - return "success" as const; - } - if (method === OAUTH_CLIENT_SECRET_POST_AUTH_METHOD) { - return "warning" as const; - } - return "info" as const; + return getAuthMethodOption(method).badgeVariant; } export function getClientTypeLabel(t: OAuthTranslator, method: string) { - if (method === OAUTH_PUBLIC_CLIENT_AUTH_METHOD) { - return t("clientTypePublic"); - } - if (method === OAUTH_CLIENT_SECRET_POST_AUTH_METHOD) { - return t("clientTypeConfidentialPost"); - } - return t("clientTypeConfidentialBasic"); + return t(getAuthMethodOption(method).labelKey); } export function getScopeInputId(scope: string) { @@ -140,14 +135,22 @@ export function getAuthMethodOption(value: string) { ); } +export function isTrustedClientAuthMethod(method: string) { + return method === OAUTH_CLIENT_SECRET_BASIC_AUTH_METHOD; +} + +export function isPublicClientAuthMethod(method: string) { + return method === OAUTH_PUBLIC_CLIENT_AUTH_METHOD; +} + export function getClientPatternDescriptionKey(client: OAuthClientInfo) { if (client.isTrusted) { return "clientKindTrustedDescription"; } - if (client.tokenEndpointAuthMethod === OAUTH_PUBLIC_CLIENT_AUTH_METHOD) { + if (isPublicClientAuthMethod(client.tokenEndpointAuthMethod)) { return "clientKindPublicDescription"; } return "clientKindExternalDescription"; } -export const authMethodLeadIcon = KeyRound; +export const AuthMethodLeadIcon = KeyRound; diff --git a/src/app/admin/oauth/oauth-client-manager.tsx b/src/app/admin/oauth/oauth-client-manager.tsx index b7421df1..aad2c6a3 100644 --- a/src/app/admin/oauth/oauth-client-manager.tsx +++ b/src/app/admin/oauth/oauth-client-manager.tsx @@ -11,8 +11,8 @@ import { type CreatedCredentials, DEFAULT_AUTH_METHOD, DEFAULT_SCOPE_VALUES, - OAUTH_CLIENT_SECRET_BASIC_AUTH_METHOD, - OAUTH_PUBLIC_CLIENT_AUTH_METHOD, + isPublicClientAuthMethod, + isTrustedClientAuthMethod, type OAuthClientInfo, parseRedirectUris, } from "./oauth-client-manager-shared"; @@ -43,9 +43,8 @@ export function OAuthClientManager({ ); const publicClients = useMemo( () => - clients.filter( - (client) => - client.tokenEndpointAuthMethod === OAUTH_PUBLIC_CLIENT_AUTH_METHOD, + clients.filter((client) => + isPublicClientAuthMethod(client.tokenEndpointAuthMethod), ), [clients], ); @@ -96,8 +95,7 @@ export function OAuthClientManager({ .getAll("scopes") .map((value) => String(value).trim()) .filter(Boolean); - const isTrusted = - tokenEndpointAuthMethod === OAUTH_CLIENT_SECRET_BASIC_AUTH_METHOD; + const isTrusted = isTrustedClientAuthMethod(tokenEndpointAuthMethod); setLoading(true); const result = await createOAuthClient(formData); diff --git a/src/app/admin/oauth/oauth-client-overview.tsx b/src/app/admin/oauth/oauth-client-overview.tsx index 8d8e7ddb..553df1d2 100644 --- a/src/app/admin/oauth/oauth-client-overview.tsx +++ b/src/app/admin/oauth/oauth-client-overview.tsx @@ -1,21 +1,9 @@ "use client"; import { ShieldCheck } from "lucide-react"; -import { PageStatCard, PageStatGrid } from "@/components/page-layout"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { - Card, - CardDescription, - CardHeader, - CardPanel, - CardTitle, -} from "@/components/ui/card"; -import { cn } from "@/lib/utils"; -import { - AUTH_METHOD_OPTIONS, - authMethodLeadIcon, - getClientTypeBadgeVariant, + AuthMethodLeadIcon, type OAuthTranslator, } from "./oauth-client-manager-shared"; @@ -32,114 +20,39 @@ export function OAuthClientOverview({ onOpenCreateDialog: (method?: string) => void; t: OAuthTranslator; }) { - const LeadIcon = authMethodLeadIcon; - return ( - <> - - -
-
- - Better Auth OAuth Provider -
-
-

- {t("panelGuideTitle")} -

-

- {t("panelGuideDescription")} -

-
-
- {t("strategyFirstPartyTitle")} - {t("strategyPublicTitle")} - {t("strategyAdvancedTitle")} -
+
+
+
+
+ + + Better Auth OAuth Provider +
- -
-

{t("createClient")}

-

- {t("createClientHint")} -

- -

- {t("createClientFootnote")} +

+

+ {t("panelGuideTitle")} +

+

+ {t("panelGuideDescription")}

- - - - - - - - - - - - {t("strategyTitle")} - {t("strategyDescription")} - - - {AUTH_METHOD_OPTIONS.map((option) => { - const Icon = option.icon; +

+ {t("overviewClients")}: {clientCount} · {t("overviewTrusted")}:{" "} + {trustedCount} · {t("overviewPublic")}: {publicCount} +

+
- return ( - - -
- -
-
-
-

- {t(option.strategyTitleKey)} -

- - {t(option.labelKey)} - -
-

- {t(option.strategyDescriptionKey)} -

-

- {t(option.strategyHintKey)} -

-
- -
-
- ); - })} - - - + +
+
); } diff --git a/src/app/admin/oauth/page.tsx b/src/app/admin/oauth/page.tsx index 46d2721b..b89f249a 100644 --- a/src/app/admin/oauth/page.tsx +++ b/src/app/admin/oauth/page.tsx @@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server"; import { PageBreadcrumbs, PageLayout } from "@/components/page-layout"; import { requireAdminPage } from "@/lib/admin-utils"; import { prisma } from "@/lib/db/prisma"; +import { OAUTH_CLIENT_SECRET_BASIC_AUTH_METHOD } from "@/lib/oauth/constants"; import { toShanghaiIsoString } from "@/lib/time/serialize-date-output"; import { OAuthClientManager } from "./oauth-client-manager"; @@ -62,7 +63,7 @@ export default async function AdminOAuthPage() { clientId: c.clientId, name: c.name ?? "", tokenEndpointAuthMethod: - c.tokenEndpointAuthMethod ?? "client_secret_basic", + c.tokenEndpointAuthMethod ?? OAUTH_CLIENT_SECRET_BASIC_AUTH_METHOD, redirectUris: c.redirectUris, scopes: c.scopes, isTrusted: Boolean(c.skipConsent), diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index ec37f67e..729e9af4 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,8 +1,8 @@ -import { Search } from "lucide-react"; import type { Metadata } from "next"; import dynamic from "next/dynamic"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; +import { FiltersBarSearch } from "@/components/filters/filters-bar"; import { PageBreadcrumbs, PageLayout, @@ -11,15 +11,11 @@ import { import { Button } from "@/components/ui/button"; import { Field, FieldLabel } from "@/components/ui/field"; import { Form } from "@/components/ui/form"; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, - InputGroupText, -} from "@/components/ui/input-group"; import { Link } from "@/i18n/routing"; import { requireAdminPage } from "@/lib/admin-utils"; +import { parseInteger } from "@/lib/api/request-integers"; import { prisma } from "@/lib/db/prisma"; +import { buildSearchParams } from "@/lib/navigation/search-params"; import { ilike } from "@/lib/query-helpers"; import { toShanghaiIsoString } from "@/lib/time/serialize-date-output"; import { ADMIN_USERS_PAGE_SIZE } from "./constants"; @@ -43,22 +39,21 @@ export default async function AdminUsersPage({ searchParams: Promise<{ page?: string; search?: string }>; }) { const searchP = await searchParams; - const callbackParams = new URLSearchParams(); - if (searchP.page) { - callbackParams.set("page", searchP.page); - } - if (searchP.search) { - callbackParams.set("search", searchP.search); - } - const callbackUrl = callbackParams.size - ? `/admin/users?${callbackParams.toString()}` + const callbackQuery = buildSearchParams({ + values: { + page: searchP.page, + search: searchP.search, + }, + }); + const callbackUrl = callbackQuery + ? `/admin/users?${callbackQuery}` : "/admin/users"; const admin = await requireAdminPage(callbackUrl); if (!admin) { notFound(); } - const page = Math.max(parseInt(searchP.page ?? "1", 10) || 1, 1); + const page = Math.max(parseInteger(searchP.page) ?? 1, 1); const search = searchP.search?.trim() ?? ""; const skip = (page - 1) * ADMIN_USERS_PAGE_SIZE; @@ -120,21 +115,13 @@ export default async function AdminUsersPage({
{tCommon("search")} - - - - - - - - + {search ? ( diff --git a/src/app/api-docs/swagger-viewer.tsx b/src/app/api-docs/swagger-viewer.tsx index 9095316c..327aa1c8 100644 --- a/src/app/api-docs/swagger-viewer.tsx +++ b/src/app/api-docs/swagger-viewer.tsx @@ -26,15 +26,76 @@ export function SwaggerViewer() { }, []); return ( -
-
-

Loading OpenAPI docs...

-
    - {FALLBACK_PATHS.map((path) => ( -
  • {path}
  • - ))} -
-
-
+ <> + +
+
+

+ Loading OpenAPI docs... +

+
    + {FALLBACK_PATHS.map((path) => ( +
  • {path}
  • + ))} +
+
+
+ ); } diff --git a/src/app/api/admin/comments/[id]/route.ts b/src/app/api/admin/comments/[id]/route.ts index 3566ff3c..2940b8e4 100644 --- a/src/app/api/admin/comments/[id]/route.ts +++ b/src/app/api/admin/comments/[id]/route.ts @@ -2,33 +2,16 @@ import type { CommentStatus } from "@/generated/prisma/client"; import { withAdminRoute } from "@/lib/admin-utils"; import { jsonResponse, + notFound, + parseResourceIdParam, parseRouteJsonBody, - parseRouteParams, } from "@/lib/api/helpers"; -import { - adminModerateCommentRequestSchema, - resourceIdPathParamsSchema, -} from "@/lib/api/schemas/request-schemas"; -import { writeAuditLog } from "@/lib/audit/write-audit-log"; +import { adminModerateCommentRequestSchema } from "@/lib/api/schemas/request-schemas"; +import { fireAuditLog } from "@/lib/audit/write-audit-log"; import { prisma } from "@/lib/db/prisma"; export const dynamic = "force-dynamic"; -async function parseCommentId( - params: Promise<{ id: string }>, -): Promise { - const parsed = await parseRouteParams( - params, - resourceIdPathParamsSchema, - "Invalid comment ID", - ); - if (parsed instanceof Response) { - return parsed; - } - - return parsed.id; -} - /** * Moderate one comment. * @pathParams resourceIdPathParamsSchema @@ -41,7 +24,7 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { return withAdminRoute("Failed to update comment", async (admin) => { - const parsed = await parseCommentId(params); + const parsed = await parseResourceIdParam(params, "comment"); if (parsed instanceof Response) { return parsed; } @@ -55,6 +38,14 @@ export async function PATCH( return parsedBody; } + const existing = await prisma.comment.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!existing) { + return notFound(); + } + const { status, moderationNote } = parsedBody; const updated = await prisma.comment.update({ where: { id }, @@ -67,13 +58,13 @@ export async function PATCH( }, }); - writeAuditLog({ + fireAuditLog({ action: "admin_comment_moderate", userId: admin.userId, targetId: id, targetType: "comment", metadata: { status, moderationNote: moderationNote ?? null }, - }).catch(() => {}); + }); return jsonResponse({ comment: updated }); }); diff --git a/src/app/api/admin/comments/route.ts b/src/app/api/admin/comments/route.ts index 0b0591ed..237474f7 100644 --- a/src/app/api/admin/comments/route.ts +++ b/src/app/api/admin/comments/route.ts @@ -1,10 +1,9 @@ import type { CommentStatus } from "@/generated/prisma/client"; import { withAdminRoute } from "@/lib/admin-utils"; import { - getPagination, getRequestSearchParams, jsonResponse, - parseRouteInput, + parseRouteQuery, } from "@/lib/api/helpers"; import { adminCommentsQuerySchema } from "@/lib/api/schemas/request-schemas"; import { prisma } from "@/lib/db/prisma"; @@ -22,24 +21,22 @@ const STATUS_FILTERS = ["active", "softbanned", "deleted"] as const; export async function GET(request: Request) { return withAdminRoute("Failed to fetch moderation queue", async () => { const searchParams = getRequestSearchParams(request); - const parsedQuery = parseRouteInput( - { - status: searchParams.get("status") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - }, + const parsed = parseRouteQuery( + searchParams, adminCommentsQuerySchema, "Invalid moderation query", - { logErrors: true }, + { + logErrors: true, + pagination: { defaultPageSize: 50, maxPageSize: 200 }, + }, ); - if (parsedQuery instanceof Response) { - return parsedQuery; + if (parsed instanceof Response) { + return parsed; } + const { query: parsedQuery, pagination } = parsed; const status = parsedQuery.status ?? ""; - const { pageSize: limit } = getPagination(searchParams, { - defaultPageSize: 50, - maxPageSize: 200, - }); + const { pageSize: limit } = pagination; const now = new Date(); const where = diff --git a/src/app/api/admin/descriptions/route.ts b/src/app/api/admin/descriptions/route.ts index 1985420f..4054a4b7 100644 --- a/src/app/api/admin/descriptions/route.ts +++ b/src/app/api/admin/descriptions/route.ts @@ -1,9 +1,8 @@ import { withAdminRoute } from "@/lib/admin-utils"; import { - getPagination, getRequestSearchParams, jsonResponse, - parseRouteInput, + parseRouteQuery, } from "@/lib/api/helpers"; import { adminDescriptionsQuerySchema } from "@/lib/api/schemas/request-schemas"; import { prisma } from "@/lib/db/prisma"; @@ -22,28 +21,24 @@ export async function GET(request: Request) { "Failed to fetch descriptions moderation queue", async () => { const searchParams = getRequestSearchParams(request); - const parsedQuery = parseRouteInput( - { - targetType: searchParams.get("targetType") ?? undefined, - hasContent: searchParams.get("hasContent") ?? undefined, - search: searchParams.get("search") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - }, + const parsed = parseRouteQuery( + searchParams, adminDescriptionsQuerySchema, "Invalid descriptions moderation query", - { logErrors: true }, + { + logErrors: true, + pagination: { defaultPageSize: 50, maxPageSize: 200 }, + }, ); - if (parsedQuery instanceof Response) { - return parsedQuery; + if (parsed instanceof Response) { + return parsed; } + const { query: parsedQuery, pagination } = parsed; const targetType = parsedQuery.targetType ?? "all"; const hasContent = parsedQuery.hasContent ?? "withContent"; const search = parsedQuery.search?.trim() ?? ""; - const { pageSize: limit } = getPagination(searchParams, { - defaultPageSize: 50, - maxPageSize: 200, - }); + const { pageSize: limit } = pagination; const targetTypeWhere = targetType === "section" diff --git a/src/app/api/admin/homeworks/[id]/route.ts b/src/app/api/admin/homeworks/[id]/route.ts index 48e26434..9e14101f 100644 --- a/src/app/api/admin/homeworks/[id]/route.ts +++ b/src/app/api/admin/homeworks/[id]/route.ts @@ -1,25 +1,13 @@ import { withAdminRoute } from "@/lib/admin-utils"; -import { jsonResponse, notFound, parseRouteParams } from "@/lib/api/helpers"; -import { resourceIdPathParamsSchema } from "@/lib/api/schemas/request-schemas"; +import { + jsonResponse, + notFound, + parseResourceIdParam, +} from "@/lib/api/helpers"; import { prisma } from "@/lib/db/prisma"; export const dynamic = "force-dynamic"; -async function parseHomeworkId( - params: Promise<{ id: string }>, -): Promise { - const parsed = await parseRouteParams( - params, - resourceIdPathParamsSchema, - "Invalid homework ID", - ); - if (parsed instanceof Response) { - return parsed; - } - - return parsed.id; -} - /** * Soft delete one homework (admin). * @pathParams resourceIdPathParamsSchema @@ -31,7 +19,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { return withAdminRoute("Failed to delete homework (admin)", async (admin) => { - const parsed = await parseHomeworkId(params); + const parsed = await parseResourceIdParam(params, "homework"); if (parsed instanceof Response) { return parsed; } diff --git a/src/app/api/admin/homeworks/route.ts b/src/app/api/admin/homeworks/route.ts index 50b675ef..a475b573 100644 --- a/src/app/api/admin/homeworks/route.ts +++ b/src/app/api/admin/homeworks/route.ts @@ -1,9 +1,8 @@ import { withAdminRoute } from "@/lib/admin-utils"; import { - getPagination, getRequestSearchParams, jsonResponse, - parseRouteInput, + parseRouteQuery, } from "@/lib/api/helpers"; import { adminHomeworksQuerySchema } from "@/lib/api/schemas/request-schemas"; import { prisma } from "@/lib/db/prisma"; @@ -22,25 +21,22 @@ export async function GET(request: Request) { "Failed to fetch homework moderation queue", async () => { const searchParams = getRequestSearchParams(request); - const parsedQuery = parseRouteInput( - { - status: searchParams.get("status") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - search: searchParams.get("search") ?? undefined, - }, + const parsed = parseRouteQuery( + searchParams, adminHomeworksQuerySchema, "Invalid homework moderation query", - { logErrors: true }, + { + logErrors: true, + pagination: { defaultPageSize: 50, maxPageSize: 200 }, + }, ); - if (parsedQuery instanceof Response) { - return parsedQuery; + if (parsed instanceof Response) { + return parsed; } + const { query: parsedQuery, pagination } = parsed; const status = parsedQuery.status ?? "all"; - const { pageSize: limit } = getPagination(searchParams, { - defaultPageSize: 50, - maxPageSize: 200, - }); + const { pageSize: limit } = pagination; const search = parsedQuery.search?.trim() ?? ""; const deletedAtFilter = diff --git a/src/app/api/admin/suspensions/[id]/route.ts b/src/app/api/admin/suspensions/[id]/route.ts index 40a2220a..a62de860 100644 --- a/src/app/api/admin/suspensions/[id]/route.ts +++ b/src/app/api/admin/suspensions/[id]/route.ts @@ -1,26 +1,14 @@ import { withAdminRoute } from "@/lib/admin-utils"; -import { jsonResponse, parseRouteParams } from "@/lib/api/helpers"; -import { resourceIdPathParamsSchema } from "@/lib/api/schemas/request-schemas"; -import { writeAuditLog } from "@/lib/audit/write-audit-log"; +import { + jsonResponse, + notFound, + parseResourceIdParam, +} from "@/lib/api/helpers"; +import { fireAuditLog } from "@/lib/audit/write-audit-log"; import { prisma } from "@/lib/db/prisma"; export const dynamic = "force-dynamic"; -async function parseSuspensionId( - params: Promise<{ id: string }>, -): Promise { - const parsed = await parseRouteParams( - params, - resourceIdPathParamsSchema, - "Invalid suspension ID", - ); - if (parsed instanceof Response) { - return parsed; - } - - return parsed.id; -} - /** * Lift one suspension. * @pathParams resourceIdPathParamsSchema @@ -32,11 +20,20 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { return withAdminRoute("Failed to lift suspension", async (admin) => { - const parsed = await parseSuspensionId(params); + const parsed = await parseResourceIdParam(params, "suspension"); if (parsed instanceof Response) { return parsed; } const id = parsed; + + const existing = await prisma.userSuspension.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!existing) { + return notFound(); + } + const suspension = await prisma.userSuspension.update({ where: { id }, data: { @@ -45,13 +42,13 @@ export async function PATCH( }, }); - writeAuditLog({ + fireAuditLog({ action: "admin_user_unsuspend", userId: admin.userId, targetId: suspension.userId, targetType: "user", metadata: { suspensionId: id }, - }).catch(() => {}); + }); return jsonResponse({ suspension }); }); diff --git a/src/app/api/admin/suspensions/route.ts b/src/app/api/admin/suspensions/route.ts index 0998dbeb..b4a72b43 100644 --- a/src/app/api/admin/suspensions/route.ts +++ b/src/app/api/admin/suspensions/route.ts @@ -1,7 +1,7 @@ import { withAdminRoute } from "@/lib/admin-utils"; import { jsonResponse, notFound, parseRouteJsonBody } from "@/lib/api/helpers"; import { adminCreateSuspensionRequestSchema } from "@/lib/api/schemas/request-schemas"; -import { writeAuditLog } from "@/lib/audit/write-audit-log"; +import { fireAuditLog } from "@/lib/audit/write-audit-log"; import { prisma } from "@/lib/db/prisma"; import { parseDateInput } from "@/lib/time/parse-date-input"; @@ -67,13 +67,13 @@ export async function POST(request: Request) { }, }); - writeAuditLog({ + fireAuditLog({ action: "admin_user_suspend", userId: admin.userId, targetId: userId, targetType: "user", metadata: { reason: parsedBody.reason ?? null }, - }).catch(() => {}); + }); return jsonResponse({ suspension }); }); diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 5df16903..2996c769 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -1,15 +1,11 @@ -import { NextResponse } from "next/server"; import { withAdminRoute } from "@/lib/admin-utils"; import { badRequest, jsonResponse, - parseRouteInput, + parseResourceIdParam, parseRouteJsonBody, } from "@/lib/api/helpers"; -import { - adminUpdateUserRequestSchema, - resourceIdPathParamsSchema, -} from "@/lib/api/schemas/request-schemas"; +import { adminUpdateUserRequestSchema } from "@/lib/api/schemas/request-schemas"; import { prisma } from "@/lib/db/prisma"; export const dynamic = "force-dynamic"; @@ -27,22 +23,6 @@ function normalizeUsername(value: unknown) { return trimmed ? trimmed : null; } -async function parseUserId( - params: Promise<{ id: string }>, -): Promise { - const raw = await params; - const parsed = parseRouteInput( - raw, - resourceIdPathParamsSchema, - "Invalid user ID", - ); - if (parsed instanceof Response) { - return badRequest("Invalid user ID"); - } - - return parsed.id; -} - /** * Update one user. * @pathParams resourceIdPathParamsSchema @@ -55,8 +35,8 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { return withAdminRoute("Failed to update user", async () => { - const parsed = await parseUserId(params); - if (parsed instanceof NextResponse) { + const parsed = await parseResourceIdParam(params, "user"); + if (parsed instanceof Response) { return parsed; } const id = parsed; diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index bf434d96..bae1a540 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -3,10 +3,9 @@ import { ADMIN_USERS_PAGE_SIZE } from "@/app/admin/users/constants"; import { withAdminRoute } from "@/lib/admin-utils"; import { buildPaginatedResponse, - getPagination, getRequestSearchParams, jsonResponse, - parseRouteInput, + parseRouteQuery, } from "@/lib/api/helpers"; import { adminUsersQuerySchema } from "@/lib/api/schemas/request-schemas"; import { prisma } from "@/lib/db/prisma"; @@ -23,24 +22,23 @@ export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { return withAdminRoute("Failed to fetch users", async () => { const searchParams = getRequestSearchParams(request); - const parsedQuery = parseRouteInput( - { - search: searchParams.get("search") ?? undefined, - page: searchParams.get("page") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - }, + const parsed = parseRouteQuery( + searchParams, adminUsersQuerySchema, "Invalid user query", - { logErrors: true }, + { + logErrors: true, + pagination: { + defaultPageSize: ADMIN_USERS_PAGE_SIZE, + maxPageSize: 100, + }, + }, ); - if (parsedQuery instanceof Response) { - return parsedQuery; + if (parsed instanceof Response) { + return parsed; } - const pagination = getPagination(searchParams, { - defaultPageSize: ADMIN_USERS_PAGE_SIZE, - maxPageSize: 100, - }); + const { query: parsedQuery, pagination } = parsed; const search = parsedQuery.search ?? ""; const where = search ? { diff --git a/src/app/api/auth/.well-known/openid-configuration/route.ts b/src/app/api/auth/.well-known/openid-configuration/route.ts index 76f74dcf..e42952fb 100644 --- a/src/app/api/auth/.well-known/openid-configuration/route.ts +++ b/src/app/api/auth/.well-known/openid-configuration/route.ts @@ -1,7 +1,4 @@ -import { - createDiscoveryMetadataRoute, - getOpenIdMetadataResponse, -} from "@/lib/oauth/discovery-metadata"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; @@ -9,6 +6,4 @@ export const dynamic = "force-dynamic"; * Canonical OpenID Connect Discovery metadata for issuer `/api/auth`. * @response 200 */ -export const { GET, OPTIONS } = createDiscoveryMetadataRoute( - getOpenIdMetadataResponse, -); +export const { GET, OPTIONS } = createOAuthDiscoveryRoute("openIdMetadata"); diff --git a/src/app/api/auth/oauth2/token/route.ts b/src/app/api/auth/oauth2/token/route.ts index 390c79b0..ceab75a1 100644 --- a/src/app/api/auth/oauth2/token/route.ts +++ b/src/app/api/auth/oauth2/token/route.ts @@ -8,6 +8,7 @@ import { summarizeOAuthRedirectUri, withBetterAuthOAuthDebug, } from "@/lib/log/oauth-debug"; +import { OAUTH_DEVICE_CODE_GRANT_TYPE } from "@/lib/oauth/constants"; import { DEVICE_CODE_ERRORS, DEVICE_CODE_POLL_INTERVAL, @@ -18,7 +19,8 @@ import { hashOAuthClientSecretForDbStorage } from "@/lib/oauth/utils"; export const dynamic = "force-dynamic"; -const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +const DEVICE_ACCESS_TOKEN_EXPIRES_IN = 3600; +const DEVICE_REFRESH_TOKEN_EXPIRES_IN = 30 * 24 * 3600; function deviceCodeError(error: string, status = 400) { return jsonResponse({ error }, { status }); @@ -96,7 +98,6 @@ async function handleDeviceCodeGrant( return deviceCodeError("invalid_request"); } - // Find the device code record const record = await prisma.deviceCode.findUnique({ where: { deviceCode }, select: { @@ -118,16 +119,13 @@ async function handleDeviceCodeGrant( return deviceCodeError("invalid_client"); } - // Check expiry if (record.expiresAt < new Date()) { return deviceCodeError(DEVICE_CODE_ERRORS.EXPIRED_TOKEN); } - // Check polling rate (slow_down) if (record.lastPolledAt) { const elapsed = Date.now() - record.lastPolledAt.getTime(); if (elapsed < DEVICE_CODE_POLL_INTERVAL * 1000) { - // Update lastPolledAt even on slow_down await prisma.deviceCode.update({ where: { id: record.id }, data: { lastPolledAt: new Date() }, @@ -136,13 +134,11 @@ async function handleDeviceCodeGrant( } } - // Update lastPolledAt await prisma.deviceCode.update({ where: { id: record.id }, data: { lastPolledAt: new Date() }, }); - // Check status if (record.status === DEVICE_CODE_STATUS.DENIED) { return deviceCodeError(DEVICE_CODE_ERRORS.ACCESS_DENIED); } @@ -151,20 +147,22 @@ async function handleDeviceCodeGrant( return deviceCodeError(DEVICE_CODE_ERRORS.AUTHORIZATION_PENDING); } - // Status is APPROVED - issue tokens if (!record.userId) { return deviceCodeError("server_error", 500); } const userId = record.userId; - // Generate opaque access token and refresh token const accessTokenPlain = randomBytes(32).toString("base64url"); const refreshTokenPlain = randomBytes(32).toString("base64url"); const accessTokenHash = hashOAuthClientSecretForDbStorage(accessTokenPlain); const refreshTokenHash = hashOAuthClientSecretForDbStorage(refreshTokenPlain); - const accessExpiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour - const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 3600 * 1000); // 30 days + const accessExpiresAt = new Date( + Date.now() + DEVICE_ACCESS_TOKEN_EXPIRES_IN * 1000, + ); + const refreshExpiresAt = new Date( + Date.now() + DEVICE_REFRESH_TOKEN_EXPIRES_IN * 1000, + ); const issued = await prisma.$transaction(async (tx) => { const claimed = await tx.deviceCode.deleteMany({ @@ -213,14 +211,13 @@ async function handleDeviceCodeGrant( return jsonResponse({ access_token: accessTokenPlain, token_type: "Bearer", - expires_in: 3600, + expires_in: DEVICE_ACCESS_TOKEN_EXPIRES_IN, refresh_token: refreshTokenPlain, scope: record.scopes.join(" "), }); } export async function POST(request: Request) { - // Clone request to read body without consuming it const cloned = request.clone(); let params: URLSearchParams; @@ -232,13 +229,12 @@ export async function POST(request: Request) { return withBetterAuthOAuthDebug("POST", request, handlers.POST); } - if (params.get("grant_type") === DEVICE_CODE_GRANT_TYPE) { + if (params.get("grant_type") === OAUTH_DEVICE_CODE_GRANT_TYPE) { return handleDeviceCodeGrant(request, params); } logObservedTokenRedirectRequest(request, params); - // Delegate all other grant types to Better Auth return withBetterAuthOAuthDebug( "POST", await maybeNormalizeTokenLoopbackRedirectRequest(request, params), @@ -246,7 +242,6 @@ export async function POST(request: Request) { ); } -// GET is not used for token endpoint but delegate just in case export function GET(request: Request) { return withBetterAuthOAuthDebug("GET", request, handlers.GET); } diff --git a/src/app/api/bus/preferences/route.ts b/src/app/api/bus/preferences/route.ts index 33753cfe..3bb3ab43 100644 --- a/src/app/api/bus/preferences/route.ts +++ b/src/app/api/bus/preferences/route.ts @@ -6,10 +6,9 @@ import { handleRouteError, jsonResponse, parseRouteJsonBody, - unauthorized, } from "@/lib/api/helpers"; import { busPreferenceRequestSchema } from "@/lib/api/schemas/request-schemas"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { requireAuth } from "@/lib/auth/helpers"; export const dynamic = "force-dynamic"; @@ -19,10 +18,9 @@ export const dynamic = "force-dynamic"; * @response 401:openApiErrorSchema */ export async function GET(request: Request) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; try { const preference = await getBusPreference(userId); @@ -40,10 +38,9 @@ export async function GET(request: Request) { * @response 400:openApiErrorSchema */ export async function POST(request: Request) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; const parsedBody = await parseRouteJsonBody( request, diff --git a/src/app/api/bus/route.ts b/src/app/api/bus/route.ts index 86e87c7d..84b32600 100644 --- a/src/app/api/bus/route.ts +++ b/src/app/api/bus/route.ts @@ -5,10 +5,10 @@ import { handleRouteError, jsonResponse, notFound, - parseRouteInput, + parseRouteSearchParams, } from "@/lib/api/helpers"; -import { busQueryResponseSchema } from "@/lib/api/schemas"; import { busQuerySchema } from "@/lib/api/schemas/request-schemas"; +import { busQueryResponseSchema } from "@/lib/api/schemas/response-schemas"; import { resolveApiUserId } from "@/lib/auth/helpers"; export const dynamic = "force-dynamic"; @@ -21,10 +21,8 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const parsedQuery = parseRouteInput( - { - versionKey: searchParams.get("versionKey") ?? undefined, - }, + const parsedQuery = parseRouteSearchParams( + searchParams, busQuerySchema, "Invalid bus query", { logErrors: true }, diff --git a/src/app/api/calendar-subscriptions/current/route.ts b/src/app/api/calendar-subscriptions/current/route.ts index 8c6262be..3c140b93 100644 --- a/src/app/api/calendar-subscriptions/current/route.ts +++ b/src/app/api/calendar-subscriptions/current/route.ts @@ -1,10 +1,6 @@ import { getUserCalendarSubscription } from "@/features/home/server/subscription-read-model"; -import { - handleRouteError, - jsonResponse, - unauthorized, -} from "@/lib/api/helpers"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { handleRouteError, jsonResponse } from "@/lib/api/helpers"; +import { requireAuth } from "@/lib/auth/helpers"; export const dynamic = "force-dynamic"; @@ -15,10 +11,9 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: Request) { try { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; const subscription = await getUserCalendarSubscription(userId); diff --git a/src/app/api/calendar-subscriptions/route.ts b/src/app/api/calendar-subscriptions/route.ts index 1a79b56e..3b725259 100644 --- a/src/app/api/calendar-subscriptions/route.ts +++ b/src/app/api/calendar-subscriptions/route.ts @@ -3,10 +3,9 @@ import { handleRouteError, jsonResponse, parseRouteJsonBody, - unauthorized, } from "@/lib/api/helpers"; import { calendarSubscriptionCreateRequestSchema } from "@/lib/api/schemas/request-schemas"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { requireAuth } from "@/lib/auth/helpers"; export const dynamic = "force-dynamic"; @@ -18,10 +17,9 @@ export const dynamic = "force-dynamic"; */ export async function POST(request: Request) { try { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; const parsedBody = await parseRouteJsonBody( request, diff --git a/src/app/api/comments/[id]/reactions/route.ts b/src/app/api/comments/[id]/reactions/route.ts index d50fa8db..7cff3bc0 100644 --- a/src/app/api/comments/[id]/reactions/route.ts +++ b/src/app/api/comments/[id]/reactions/route.ts @@ -2,35 +2,16 @@ import { handleRouteError, jsonResponse, notFound, - parseRouteInput, + parseResourceIdParam, parseRouteJsonBody, - parseRouteParams, - unauthorized, + parseRouteSearchParams, } from "@/lib/api/helpers"; -import { - commentReactionRequestSchema, - resourceIdPathParamsSchema, -} from "@/lib/api/schemas/request-schemas"; -import { requireWriteAuth, resolveApiUserId } from "@/lib/auth/helpers"; +import { commentReactionRequestSchema } from "@/lib/api/schemas/request-schemas"; +import { requireAuth, requireWriteAuth } from "@/lib/auth/helpers"; import { prisma } from "@/lib/db/prisma"; export const dynamic = "force-dynamic"; -async function parseCommentId( - params: Promise<{ id: string }>, -): Promise { - const parsed = await parseRouteParams( - params, - resourceIdPathParamsSchema, - "Invalid comment ID", - ); - if (parsed instanceof Response) { - return parsed; - } - - return parsed.id; -} - /** * Add one reaction to a comment. * @pathParams resourceIdPathParamsSchema @@ -47,7 +28,7 @@ export async function POST( } const { userId } = auth; - const parsed = await parseCommentId(params); + const parsed = await parseResourceIdParam(params, "comment"); if (parsed instanceof Response) { return parsed; } @@ -104,21 +85,18 @@ export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; - const parsed = await parseCommentId(params); + const parsed = await parseResourceIdParam(params, "comment"); if (parsed instanceof Response) { return parsed; } const id = parsed; const { searchParams } = new URL(request.url); - const parsedBody = parseRouteInput( - { - type: searchParams.get("type"), - }, + const parsedBody = parseRouteSearchParams( + searchParams, commentReactionRequestSchema, "Invalid reaction", { logErrors: true }, diff --git a/src/app/api/comments/[id]/route.ts b/src/app/api/comments/[id]/route.ts index 41b4f426..4912f748 100644 --- a/src/app/api/comments/[id]/route.ts +++ b/src/app/api/comments/[id]/route.ts @@ -1,4 +1,3 @@ -import { NextResponse } from "next/server"; import { buildCommentNodes, type CommentNode, @@ -9,17 +8,14 @@ import { handleRouteError, jsonResponse, notFound, - parseRouteInput, + parseResourceIdParam, parseRouteJsonBody, unauthorized, } from "@/lib/api/helpers"; +import { commentUpdateRequestSchema } from "@/lib/api/schemas/request-schemas"; import { - commentUpdateRequestSchema, - resourceIdPathParamsSchema, -} from "@/lib/api/schemas/request-schemas"; -import { + fireAuditLog, getAuditRequestMetadata, - writeAuditLog, } from "@/lib/audit/write-audit-log"; import { resolveApiUserId } from "@/lib/auth/helpers"; import { getViewerContext } from "@/lib/auth/viewer-context"; @@ -36,22 +32,6 @@ function findComment(nodes: CommentNode[], id: string): CommentNode | null { return null; } -async function parseCommentId( - params: Promise<{ id: string }>, -): Promise { - const raw = await params; - const parsed = parseRouteInput( - raw, - resourceIdPathParamsSchema, - "Invalid comment ID", - ); - if (parsed instanceof Response) { - return badRequest("Invalid comment ID"); - } - - return parsed.id; -} - /** * Get one comment thread by comment ID. * @pathParams resourceIdPathParamsSchema @@ -62,70 +42,74 @@ export async function GET( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const parsed = await parseCommentId(params); - if (parsed instanceof NextResponse) { + const parsed = await parseResourceIdParam(params, "comment"); + if (parsed instanceof Response) { return parsed; } const id = parsed; try { - const comment = await prisma.comment.findUnique({ - where: { id }, - select: { - sectionId: true, - courseId: true, - teacherId: true, - sectionTeacherId: true, - rootId: true, - id: true, - homework: { - select: { - id: true, - title: true, - section: { - select: { jwId: true, code: true }, + const viewerUserId = await resolveApiUserId(request); + + // Fetch the anchor comment and the viewer context in parallel. + const [comment, viewer] = await Promise.all([ + prisma.comment.findUnique({ + where: { id }, + select: { + sectionId: true, + courseId: true, + teacherId: true, + sectionTeacherId: true, + rootId: true, + id: true, + homework: { + select: { + id: true, + title: true, + section: { + select: { jwId: true, code: true }, + }, }, }, - }, - sectionTeacher: { - select: { - sectionId: true, - teacherId: true, - section: { - select: { - jwId: true, - code: true, - course: { - select: { jwId: true, nameCn: true }, + sectionTeacher: { + select: { + sectionId: true, + teacherId: true, + section: { + select: { + jwId: true, + code: true, + course: { + select: { jwId: true, nameCn: true }, + }, }, }, - }, - teacher: { - select: { nameCn: true }, + teacher: { + select: { nameCn: true }, + }, }, }, + section: { + select: { jwId: true, code: true }, + }, + course: { + select: { jwId: true, nameCn: true }, + }, + teacher: { + select: { nameCn: true }, + }, }, - section: { - select: { jwId: true, code: true }, - }, - course: { - select: { jwId: true, nameCn: true }, - }, - teacher: { - select: { nameCn: true }, - }, - }, - }); + }), + getViewerContext({ + includeAdmin: false, + userId: viewerUserId, + }), + ]); if (!comment) { return notFound(); } - const viewerUserId = await resolveApiUserId(request); - const viewer = await getViewerContext({ - includeAdmin: false, - userId: viewerUserId, - }); const threadKey = comment.rootId ?? comment.id; const threadComments = await prisma.comment.findMany({ @@ -221,8 +205,8 @@ export async function PATCH( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const parsed = await parseCommentId(params); - if (parsed instanceof NextResponse) { + const parsed = await parseResourceIdParam(params, "comment"); + if (parsed instanceof Response) { return parsed; } const id = parsed; @@ -235,16 +219,10 @@ export async function PATCH( return parsedBody; } + // Use schema-parsed values directly — Zod already validated enums/booleans. const content = parsedBody.body; - - const visibility = - typeof parsedBody.visibility === "string" - ? parsedBody.visibility - : undefined; - const isAnonymous = - typeof parsedBody.isAnonymous === "boolean" - ? parsedBody.isAnonymous - : undefined; + const visibility = parsedBody.visibility; + const isAnonymous = parsedBody.isAnonymous; const hasAttachmentUpdate = Array.isArray(parsedBody.attachmentIds); const attachmentIds = hasAttachmentUpdate @@ -367,14 +345,14 @@ export async function PATCH( const { roots } = buildCommentNodes([updatedComment], viewer); - writeAuditLog({ + fireAuditLog({ action: "comment_edit", userId, targetId: id, targetType: "comment", metadata: { body: content?.slice(0, 200) }, ...getAuditRequestMetadata(request), - }).catch(() => {}); + }); return jsonResponse({ success: true, comment: roots[0] }); } catch (error) { @@ -392,8 +370,8 @@ export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const parsed = await parseCommentId(params); - if (parsed instanceof NextResponse) { + const parsed = await parseResourceIdParam(params, "comment"); + if (parsed instanceof Response) { return parsed; } const id = parsed; @@ -425,13 +403,13 @@ export async function DELETE( }, }); - writeAuditLog({ + fireAuditLog({ action: "comment_delete", userId, targetId: id, targetType: "comment", ...getAuditRequestMetadata(request), - }).catch(() => {}); + }); return jsonResponse({ success: true }); } catch (error) { diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts index 81ba71af..4105d7b5 100644 --- a/src/app/api/comments/route.ts +++ b/src/app/api/comments/route.ts @@ -5,16 +5,16 @@ import { handleRouteError, jsonResponse, notFound, - parseRouteInput, parseRouteJsonBody, + parseRouteSearchParams, } from "@/lib/api/helpers"; import { commentCreateRequestSchema, commentsQuerySchema, } from "@/lib/api/schemas/request-schemas"; import { + fireAuditLog, getAuditRequestMetadata, - writeAuditLog, } from "@/lib/audit/write-audit-log"; import { requireWriteAuth, resolveApiUserId } from "@/lib/auth/helpers"; import { getViewerContext } from "@/lib/auth/viewer-context"; @@ -30,13 +30,8 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const parsedQuery = parseRouteInput( - { - targetType: searchParams.get("targetType"), - targetId: searchParams.get("targetId") ?? undefined, - sectionId: searchParams.get("sectionId") ?? undefined, - teacherId: searchParams.get("teacherId") ?? undefined, - }, + const parsedQuery = parseRouteSearchParams( + searchParams, commentsQuerySchema, "Invalid target", ); @@ -139,6 +134,7 @@ export async function POST(request: Request) { const targetType = parsedBody.targetType; const content = parsedBody.body; + // Use schema-parsed values directly — Zod already validated the enum/boolean. const visibility = parsedBody.visibility ?? "public"; const isAnonymous = parsedBody.isAnonymous === true; @@ -154,10 +150,15 @@ export async function POST(request: Request) { sectionId: parsedBody.sectionId, targetType, teacherId: parsedBody.teacherId, + // Verify target entity exists to prevent orphan comments on deleted targets. + verifyExistence: true, }); if (!target) { return badRequest("Invalid target"); } + if (!target.verified) { + return notFound("Target not found"); + } let parentId: string | null = null; let rootId: string | null = null; @@ -224,14 +225,14 @@ export async function POST(request: Request) { }); } - writeAuditLog({ + fireAuditLog({ action: "comment_create", userId, targetId: comment.id, targetType: "comment", metadata: { body: content.slice(0, 200) }, ...getAuditRequestMetadata(request), - }).catch(() => {}); + }); return jsonResponse({ id: comment.id }); } catch (error) { diff --git a/src/app/api/courses/route.ts b/src/app/api/courses/route.ts index 5dd61464..bb21d48e 100644 --- a/src/app/api/courses/route.ts +++ b/src/app/api/courses/route.ts @@ -1,9 +1,8 @@ import type { NextRequest } from "next/server"; import { - getPagination, handleRouteError, jsonResponse, - parseRouteInput, + parseRouteQuery, } from "@/lib/api/helpers"; import { coursesQuerySchema } from "@/lib/api/schemas/request-schemas"; import { buildCourseListWhere } from "@/lib/course-section-queries"; @@ -19,24 +18,17 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const parsedQuery = parseRouteInput( - { - search: searchParams.get("search") ?? undefined, - educationLevelId: searchParams.get("educationLevelId") ?? undefined, - categoryId: searchParams.get("categoryId") ?? undefined, - classTypeId: searchParams.get("classTypeId") ?? undefined, - page: searchParams.get("page") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - }, + const parsed = parseRouteQuery( + searchParams, coursesQuerySchema, "Invalid course query", { logErrors: true }, ); - if (parsedQuery instanceof Response) { - return parsedQuery; + if (parsed instanceof Response) { + return parsed; } - const pagination = getPagination(searchParams); + const { query: parsedQuery, pagination } = parsed; const { search, educationLevelId, categoryId, classTypeId } = parsedQuery; const where = buildCourseListWhere({ search, @@ -46,7 +38,11 @@ export async function GET(request: NextRequest) { }); try { - const result = await paginatedCourseQuery(pagination.page, where); + const result = await paginatedCourseQuery( + pagination.page, + pagination.pageSize, + where, + ); return jsonResponse(result); } catch (error) { return handleRouteError("Failed to fetch courses", error); diff --git a/src/app/api/dashboard-links/visit/route.ts b/src/app/api/dashboard-links/visit/route.ts index 90172e63..d441f453 100644 --- a/src/app/api/dashboard-links/visit/route.ts +++ b/src/app/api/dashboard-links/visit/route.ts @@ -10,6 +10,14 @@ import { logAppEvent } from "@/lib/log/app-logger"; export const dynamic = "force-dynamic"; +function resolveVisitTarget( + schema: typeof dashboardLinkVisitQuerySchema, + slug: FormDataEntryValue | string | null, +) { + const parsed = schema.safeParse({ slug }); + return parsed.success ? resolveDashboardLinkBySlug(parsed.data.slug) : null; +} + /** * Redirect to one dashboard link without side effects. * @params dashboardLinkVisitQuerySchema @@ -17,12 +25,10 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const parsedQuery = dashboardLinkVisitQuerySchema.safeParse({ - slug: searchParams.get("slug"), - }); - const target = parsedQuery.success - ? resolveDashboardLinkBySlug(parsedQuery.data.slug) - : null; + const target = resolveVisitTarget( + dashboardLinkVisitQuerySchema, + searchParams.get("slug"), + ); if (!target) { return NextResponse.redirect(new URL("/", request.url)); @@ -38,12 +44,10 @@ export async function GET(request: Request) { */ export async function POST(request: Request) { const formData = await request.formData(); - const parsedBody = dashboardLinkVisitRequestSchema.safeParse({ - slug: formData.get("slug"), - }); - const target = parsedBody.success - ? resolveDashboardLinkBySlug(parsedBody.data.slug) - : null; + const target = resolveVisitTarget( + dashboardLinkVisitRequestSchema, + formData.get("slug"), + ); if (!target) { return NextResponse.redirect(new URL("/", request.url), 303); diff --git a/src/app/api/descriptions/route.ts b/src/app/api/descriptions/route.ts index 5b586cd1..ede255c7 100644 --- a/src/app/api/descriptions/route.ts +++ b/src/app/api/descriptions/route.ts @@ -8,16 +8,16 @@ import { handleRouteError, jsonResponse, notFound, - parseRouteInput, parseRouteJsonBody, + parseRouteSearchParams, } from "@/lib/api/helpers"; import { descriptionsQuerySchema, descriptionUpsertRequestSchema, } from "@/lib/api/schemas/request-schemas"; import { + fireAuditLog, getAuditRequestMetadata, - writeAuditLog, } from "@/lib/audit/write-audit-log"; import { requireWriteAuth } from "@/lib/auth/helpers"; import { prisma } from "@/lib/db/prisma"; @@ -32,11 +32,8 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const parsedQuery = parseRouteInput( - { - targetType: searchParams.get("targetType"), - targetId: searchParams.get("targetId") ?? "", - }, + const parsedQuery = parseRouteSearchParams( + searchParams, descriptionsQuerySchema, "Invalid target", ); @@ -139,14 +136,14 @@ export async function POST(request: Request) { }); if (result.updated) { - writeAuditLog({ + fireAuditLog({ action: "description_edit", userId, targetId: result.id, targetType: "description", metadata: { targetType, content: content.slice(0, 200) }, ...getAuditRequestMetadata(request), - }).catch(() => {}); + }); } return jsonResponse({ id: result.id, updated: result.updated }); diff --git a/src/app/api/homeworks/[id]/completion/route.ts b/src/app/api/homeworks/[id]/completion/route.ts index 462eddfc..036936c6 100644 --- a/src/app/api/homeworks/[id]/completion/route.ts +++ b/src/app/api/homeworks/[id]/completion/route.ts @@ -2,34 +2,15 @@ import { handleRouteError, jsonResponse, notFound, + parseResourceIdParam, parseRouteJsonBody, - parseRouteParams, - unauthorized, } from "@/lib/api/helpers"; -import { - homeworkCompletionRequestSchema, - resourceIdPathParamsSchema, -} from "@/lib/api/schemas/request-schemas"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { homeworkCompletionRequestSchema } from "@/lib/api/schemas/request-schemas"; +import { requireAuth } from "@/lib/auth/helpers"; import { prisma } from "@/lib/db/prisma"; export const dynamic = "force-dynamic"; -async function parseHomeworkId( - params: Promise<{ id: string }>, -): Promise { - const parsed = await parseRouteParams( - params, - resourceIdPathParamsSchema, - "Invalid homework ID", - ); - if (parsed instanceof Response) { - return parsed; - } - - return parsed.id; -} - /** * Update completion state for one homework. * @pathParams resourceIdPathParamsSchema @@ -41,7 +22,7 @@ export async function PUT( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const parsed = await parseHomeworkId(params); + const parsed = await parseResourceIdParam(params, "homework"); if (parsed instanceof Response) { return parsed; } @@ -55,10 +36,9 @@ export async function PUT( return parsedBody; } - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; try { const homework = await prisma.homework.findUnique({ diff --git a/src/app/api/homeworks/[id]/route.ts b/src/app/api/homeworks/[id]/route.ts index fbb112da..46cf2ca8 100644 --- a/src/app/api/homeworks/[id]/route.ts +++ b/src/app/api/homeworks/[id]/route.ts @@ -1,17 +1,14 @@ -import { NextResponse } from "next/server"; +import type { Prisma } from "@/generated/prisma/client"; import { badRequest, forbidden, handleRouteError, jsonResponse, notFound, - parseRouteInput, + parseResourceIdParam, parseRouteJsonBody, } from "@/lib/api/helpers"; -import { - homeworkUpdateRequestSchema, - resourceIdPathParamsSchema, -} from "@/lib/api/schemas/request-schemas"; +import { homeworkUpdateRequestSchema } from "@/lib/api/schemas/request-schemas"; import { requireWriteAuth } from "@/lib/auth/helpers"; import { getViewerContext } from "@/lib/auth/viewer-context"; import { prisma } from "@/lib/db/prisma"; @@ -19,22 +16,6 @@ import { parseDateInput } from "@/lib/time/parse-date-input"; export const dynamic = "force-dynamic"; -async function parseHomeworkId( - params: Promise<{ id: string }>, -): Promise { - const raw = await params; - const parsed = parseRouteInput( - raw, - resourceIdPathParamsSchema, - "Invalid homework ID", - ); - if (parsed instanceof Response) { - return badRequest("Invalid homework ID"); - } - - return parsed.id; -} - /** * Update one homework. * @pathParams resourceIdPathParamsSchema @@ -46,8 +27,8 @@ export async function PATCH( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const parsed = await parseHomeworkId(params); - if (parsed instanceof NextResponse) { + const parsed = await parseResourceIdParam(params, "homework"); + if (parsed instanceof Response) { return parsed; } const id = parsed; @@ -114,8 +95,8 @@ export async function PATCH( return forbidden("Homework deleted"); } - const updates: Record = { - updatedById: userId, + const updates: Prisma.HomeworkUpdateInput = { + updatedBy: { connect: { id: userId } }, }; if (title !== undefined) updates.title = title; @@ -132,7 +113,17 @@ export async function PATCH( if (submissionDueAt !== undefined) updates.submissionDueAt = submissionDueAt; - if (Object.keys(updates).length === 1) { + // Only count user-provided fields; `updatedBy` is always present. + const userFieldCount = [ + title !== undefined, + parsedBody.isMajor !== undefined, + parsedBody.requiresTeam !== undefined, + hasPublishedAt, + hasSubmissionStartAt, + hasSubmissionDueAt, + ].filter(Boolean).length; + + if (userFieldCount === 0) { return badRequest("No changes"); } @@ -157,8 +148,8 @@ export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const parsed = await parseHomeworkId(params); - if (parsed instanceof NextResponse) { + const parsed = await parseResourceIdParam(params, "homework"); + if (parsed instanceof Response) { return parsed; } const id = parsed; diff --git a/src/app/api/homeworks/route.ts b/src/app/api/homeworks/route.ts index e0e118b9..359246f9 100644 --- a/src/app/api/homeworks/route.ts +++ b/src/app/api/homeworks/route.ts @@ -5,8 +5,8 @@ import { jsonResponse, notFound, parseInteger, - parseRouteInput, parseRouteJsonBody, + parseRouteSearchParams, } from "@/lib/api/helpers"; import { homeworkCreateRequestSchema, @@ -16,6 +16,7 @@ import { requireWriteAuth, resolveApiUserId } from "@/lib/auth/helpers"; import { getViewerContext } from "@/lib/auth/viewer-context"; import { getPrisma, prisma } from "@/lib/db/prisma"; import { parseDateInput } from "@/lib/time/parse-date-input"; + export const dynamic = "force-dynamic"; /** @@ -26,12 +27,8 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const parsedQuery = parseRouteInput( - { - sectionId: searchParams.get("sectionId") ?? undefined, - sectionIds: searchParams.get("sectionIds") ?? undefined, - includeDeleted: searchParams.get("includeDeleted") ?? undefined, - }, + const parsedQuery = parseRouteSearchParams( + searchParams, homeworksQuerySchema, "Invalid homework query", { logErrors: true }, @@ -85,6 +82,11 @@ export async function GET(request: Request) { deletedBy: { select: { id: true, name: true, username: true, image: true }, }, + _count: { + select: { + comments: { where: { status: { not: "deleted" } } }, + }, + }, ...(viewer.userId ? { homeworkCompletions: { @@ -118,30 +120,13 @@ export async function GET(request: Request) { take: 50, }), ]); - const homeworkIds = homeworks.map((homework) => homework.id); - const commentCountRows = - homeworkIds.length > 0 - ? await prisma.comment.groupBy({ - by: ["homeworkId"], - where: { - homeworkId: { in: homeworkIds }, - status: { not: "deleted" }, - }, - _count: { _all: true }, - }) - : []; - const commentCounts = new Map( - commentCountRows.flatMap((row) => - row.homeworkId ? [[row.homeworkId, row._count._all] as const] : [], - ), - ); const responseHomeworks = homeworks.map((homework) => { - const { homeworkCompletions, ...rest } = homework; + const { homeworkCompletions, _count, ...rest } = homework; return { ...rest, completion: homeworkCompletions?.[0] ?? null, - commentCount: commentCounts.get(homework.id) ?? 0, + commentCount: _count.comments, }; }); diff --git a/src/app/api/mcp/.well-known/oauth-authorization-server/route.ts b/src/app/api/mcp/.well-known/oauth-authorization-server/route.ts index a8847a89..76218997 100644 --- a/src/app/api/mcp/.well-known/oauth-authorization-server/route.ts +++ b/src/app/api/mcp/.well-known/oauth-authorization-server/route.ts @@ -1,5 +1,4 @@ -import { getOAuthAuthorizationServerMetadataUrl } from "@/lib/mcp/urls"; -import { createDiscoveryRedirectRoute } from "@/lib/oauth/discovery-metadata"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; @@ -7,6 +6,4 @@ export const dynamic = "force-dynamic"; * Compatibility alias for clients that probe authorization-server metadata relative to /api/mcp. * @response 307 */ -export const { GET, OPTIONS } = createDiscoveryRedirectRoute((request) => - getOAuthAuthorizationServerMetadataUrl(request), -); +export const { GET, OPTIONS } = createOAuthDiscoveryRoute("authServerAlias"); diff --git a/src/app/api/mcp/.well-known/openid-configuration/route.ts b/src/app/api/mcp/.well-known/openid-configuration/route.ts index 6696a804..b8c90f7f 100644 --- a/src/app/api/mcp/.well-known/openid-configuration/route.ts +++ b/src/app/api/mcp/.well-known/openid-configuration/route.ts @@ -1,5 +1,4 @@ -import { getOAuthOpenIdConfigurationUrl } from "@/lib/mcp/urls"; -import { createDiscoveryRedirectRoute } from "@/lib/oauth/discovery-metadata"; +import { createOAuthDiscoveryRoute } from "@/lib/oauth/discovery-routes"; export const dynamic = "force-dynamic"; @@ -7,6 +6,4 @@ export const dynamic = "force-dynamic"; * Compatibility alias for clients that probe OIDC metadata relative to /api/mcp. * @response 307 */ -export const { GET, OPTIONS } = createDiscoveryRedirectRoute((request) => - getOAuthOpenIdConfigurationUrl(request), -); +export const { GET, OPTIONS } = createOAuthDiscoveryRoute("openIdAlias"); diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts index a9510e6a..b51f49dd 100644 --- a/src/app/api/me/route.ts +++ b/src/app/api/me/route.ts @@ -1,10 +1,5 @@ -import { - handleRouteError, - jsonResponse, - notFound, - unauthorized, -} from "@/lib/api/helpers"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { handleRouteError, jsonResponse, notFound } from "@/lib/api/helpers"; +import { requireAuth } from "@/lib/auth/helpers"; import { prisma } from "@/lib/db/prisma"; export const dynamic = "force-dynamic"; @@ -16,10 +11,9 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: Request) { try { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; const user = await prisma.user.findUnique({ where: { id: userId }, diff --git a/src/app/api/me/subscriptions/homeworks/route.ts b/src/app/api/me/subscriptions/homeworks/route.ts index 86e8958b..52ef4103 100644 --- a/src/app/api/me/subscriptions/homeworks/route.ts +++ b/src/app/api/me/subscriptions/homeworks/route.ts @@ -4,12 +4,8 @@ import { listSubscribedHomeworks, } from "@/features/home/server/subscription-read-model"; import { withHomeworkItemState } from "@/features/homeworks/server/homework-item-state"; -import { - handleRouteError, - jsonResponse, - unauthorized, -} from "@/lib/api/helpers"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { handleRouteError, jsonResponse } from "@/lib/api/helpers"; +import { requireAuth } from "@/lib/auth/helpers"; import { getViewerContext } from "@/lib/auth/viewer-context"; export const dynamic = "force-dynamic"; @@ -20,10 +16,9 @@ export const dynamic = "force-dynamic"; * @response 401:openApiErrorSchema */ export async function GET(request: Request) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; try { const viewer = await getViewerContext({ diff --git a/src/app/api/schedules/route.ts b/src/app/api/schedules/route.ts index e282321a..174746f2 100644 --- a/src/app/api/schedules/route.ts +++ b/src/app/api/schedules/route.ts @@ -1,10 +1,9 @@ import type { NextRequest } from "next/server"; import { buildPaginatedResponse, - getPagination, handleRouteError, jsonResponse, - parseRouteInput, + parseRouteQuery, } from "@/lib/api/helpers"; import { schedulesQuerySchema } from "@/lib/api/schemas/request-schemas"; import { getPrisma, prisma } from "@/lib/db/prisma"; @@ -16,6 +15,17 @@ import { parseDateInput } from "@/lib/time/parse-date-input"; import { formatTime } from "@/shared/lib/time-utils"; export const dynamic = "force-dynamic"; +function parseScheduleDateParam(name: "dateFrom" | "dateTo", value?: string) { + if (!value) { + return undefined; + } + + const parsed = parseDateInput(value); + return parsed instanceof Date + ? parsed + : handleRouteError("Invalid schedule query", `Invalid ${name}`, 400); +} + /** * List schedules with filters and pagination. * @params schedulesQuerySchema @@ -25,30 +35,17 @@ export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams; - const parsedQuery = parseRouteInput( - { - sectionId: searchParams.get("sectionId") ?? undefined, - sectionJwId: searchParams.get("sectionJwId") ?? undefined, - sectionCode: searchParams.get("sectionCode") ?? undefined, - teacherId: searchParams.get("teacherId") ?? undefined, - teacherCode: searchParams.get("teacherCode") ?? undefined, - roomId: searchParams.get("roomId") ?? undefined, - roomJwId: searchParams.get("roomJwId") ?? undefined, - dateFrom: searchParams.get("dateFrom") ?? undefined, - dateTo: searchParams.get("dateTo") ?? undefined, - weekday: searchParams.get("weekday") ?? undefined, - page: searchParams.get("page") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - }, + const parsed = parseRouteQuery( + searchParams, schedulesQuerySchema, "Invalid schedule query", { logErrors: true }, ); - if (parsedQuery instanceof Response) { - return parsedQuery; + if (parsed instanceof Response) { + return parsed; } - const pagination = getPagination(searchParams); + const { query: parsedQuery, pagination } = parsed; const { sectionId, sectionJwId, @@ -62,29 +59,13 @@ export async function GET(request: NextRequest) { weekday, } = parsedQuery; - let parsedDateFrom: Date | undefined; - if (dateFrom) { - const nextDateFrom = parseDateInput(dateFrom); - if (!(nextDateFrom instanceof Date)) { - return handleRouteError( - "Invalid schedule query", - "Invalid dateFrom", - 400, - ); - } - parsedDateFrom = nextDateFrom; + const parsedDateFrom = parseScheduleDateParam("dateFrom", dateFrom); + if (parsedDateFrom instanceof Response) { + return parsedDateFrom; } - let parsedDateTo: Date | undefined; - if (dateTo) { - const nextDateTo = parseDateInput(dateTo); - if (!(nextDateTo instanceof Date)) { - return handleRouteError( - "Invalid schedule query", - "Invalid dateTo", - 400, - ); - } - parsedDateTo = nextDateTo; + const parsedDateTo = parseScheduleDateParam("dateTo", dateTo); + if (parsedDateTo instanceof Response) { + return parsedDateTo; } const whereClause = buildScheduleListWhere({ sectionId, diff --git a/src/app/api/sections/calendar.ics/route.ts b/src/app/api/sections/calendar.ics/route.ts index c4ef5cc5..38ed102f 100644 --- a/src/app/api/sections/calendar.ics/route.ts +++ b/src/app/api/sections/calendar.ics/route.ts @@ -3,7 +3,7 @@ import { badRequest, handleRouteError, parseIntegerList, - parseRouteInput, + parseRouteSearchParams, } from "@/lib/api/helpers"; import { sectionsCalendarQuerySchema } from "@/lib/api/schemas/request-schemas"; import { prisma } from "@/lib/db/prisma"; @@ -19,11 +19,8 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: NextRequest) { try { - const { searchParams } = new URL(request.url); - const parsedQuery = parseRouteInput( - { - sectionIds: searchParams.get("sectionIds") ?? "", - }, + const parsedQuery = parseRouteSearchParams( + request.nextUrl.searchParams, sectionsCalendarQuerySchema, "sectionIds parameter is required", { logErrors: true }, diff --git a/src/app/api/sections/route.ts b/src/app/api/sections/route.ts index 9339248d..4ad5c42b 100644 --- a/src/app/api/sections/route.ts +++ b/src/app/api/sections/route.ts @@ -1,9 +1,8 @@ import type { NextRequest } from "next/server"; import { - getPagination, handleRouteError, jsonResponse, - parseRouteInput, + parseRouteQuery, } from "@/lib/api/helpers"; import { sectionsQuerySchema } from "@/lib/api/schemas/request-schemas"; import { buildSectionListQuery } from "@/lib/course-section-queries"; @@ -19,31 +18,17 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const parsedQuery = parseRouteInput( - { - courseId: searchParams.get("courseId") ?? undefined, - courseJwId: searchParams.get("courseJwId") ?? undefined, - semesterId: searchParams.get("semesterId") ?? undefined, - semesterJwId: searchParams.get("semesterJwId") ?? undefined, - campusId: searchParams.get("campusId") ?? undefined, - departmentId: searchParams.get("departmentId") ?? undefined, - teacherId: searchParams.get("teacherId") ?? undefined, - teacherCode: searchParams.get("teacherCode") ?? undefined, - search: searchParams.get("search") ?? undefined, - ids: searchParams.get("ids") ?? undefined, - jwIds: searchParams.get("jwIds") ?? undefined, - page: searchParams.get("page") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - }, + const parsed = parseRouteQuery( + searchParams, sectionsQuerySchema, "Invalid section query", { logErrors: true }, ); - if (parsedQuery instanceof Response) { - return parsedQuery; + if (parsed instanceof Response) { + return parsed; } - const pagination = getPagination(searchParams); + const { query: parsedQuery, pagination } = parsed; const { courseId, courseJwId, @@ -72,7 +57,12 @@ export async function GET(request: NextRequest) { }); try { - const result = await paginatedSectionQuery(pagination.page, where, orderBy); + const result = await paginatedSectionQuery( + pagination.page, + pagination.pageSize, + where, + orderBy, + ); return jsonResponse(result); } catch (error) { return handleRouteError("Failed to fetch sections", error); diff --git a/src/app/api/semesters/route.ts b/src/app/api/semesters/route.ts index 0cbadb41..336c778a 100644 --- a/src/app/api/semesters/route.ts +++ b/src/app/api/semesters/route.ts @@ -1,10 +1,9 @@ import type { NextRequest } from "next/server"; import { buildPaginatedResponse, - getPagination, handleRouteError, jsonResponse, - parseRouteInput, + parseRouteQuery, } from "@/lib/api/helpers"; import { semestersQuerySchema } from "@/lib/api/schemas/request-schemas"; import { prisma } from "@/lib/db/prisma"; @@ -20,19 +19,16 @@ export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams; - const parsedQuery = parseRouteInput( - { - page: searchParams.get("page") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - }, + const parsed = parseRouteQuery( + searchParams, semestersQuerySchema, "Invalid semester query", { logErrors: true }, ); - if (parsedQuery instanceof Response) { - return parsedQuery; + if (parsed instanceof Response) { + return parsed; } - const { page, pageSize, skip } = getPagination(searchParams); + const { page, pageSize, skip } = parsed.pagination; const [semesters, total] = await Promise.all([ prisma.semester.findMany({ diff --git a/src/app/api/teachers/route.ts b/src/app/api/teachers/route.ts index e65d3d08..779b2346 100644 --- a/src/app/api/teachers/route.ts +++ b/src/app/api/teachers/route.ts @@ -1,11 +1,10 @@ import type { NextRequest } from "next/server"; import type { Prisma } from "@/generated/prisma/client"; import { - getPagination, handleRouteError, jsonResponse, parseInteger, - parseRouteInput, + parseRouteQuery, } from "@/lib/api/helpers"; import { teachersQuerySchema } from "@/lib/api/schemas/request-schemas"; import { ilike, paginatedTeacherQuery } from "@/lib/query-helpers"; @@ -20,22 +19,17 @@ export const dynamic = "force-dynamic"; */ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const parsedQuery = parseRouteInput( - { - departmentId: searchParams.get("departmentId") ?? undefined, - search: searchParams.get("search") ?? undefined, - page: searchParams.get("page") ?? undefined, - limit: searchParams.get("limit") ?? undefined, - }, + const parsed = parseRouteQuery( + searchParams, teachersQuerySchema, "Invalid teacher query", { logErrors: true }, ); - if (parsedQuery instanceof Response) { - return parsedQuery; + if (parsed instanceof Response) { + return parsed; } - const pagination = getPagination(searchParams); + const { query: parsedQuery, pagination } = parsed; const { departmentId, search } = parsedQuery; const where: Prisma.TeacherWhereInput = {}; @@ -56,9 +50,14 @@ export async function GET(request: NextRequest) { } try { - const result = await paginatedTeacherQuery(pagination.page, where, { - nameCn: "asc", - }); + const result = await paginatedTeacherQuery( + pagination.page, + pagination.pageSize, + where, + { + nameCn: "asc", + }, + ); return jsonResponse(result); } catch (error) { return handleRouteError("Failed to fetch teachers", error); diff --git a/src/app/api/todos/[id]/route.ts b/src/app/api/todos/[id]/route.ts index eea44f94..4a4ba75c 100644 --- a/src/app/api/todos/[id]/route.ts +++ b/src/app/api/todos/[id]/route.ts @@ -1,37 +1,20 @@ +import type { Prisma } from "@/generated/prisma/client"; import { badRequest, forbidden, handleRouteError, jsonResponse, notFound, + parseResourceIdParam, parseRouteJsonBody, - parseRouteParams, - unauthorized, } from "@/lib/api/helpers"; -import { - resourceIdPathParamsSchema, - todoUpdateRequestSchema, -} from "@/lib/api/schemas/request-schemas"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { todoUpdateRequestSchema } from "@/lib/api/schemas/request-schemas"; +import { requireAuth } from "@/lib/auth/helpers"; import { prisma } from "@/lib/db/prisma"; import { parseDateInput } from "@/lib/time/parse-date-input"; export const dynamic = "force-dynamic"; -async function parseTodoId( - params: Promise<{ id: string }>, -): Promise { - const parsed = await parseRouteParams( - params, - resourceIdPathParamsSchema, - "Invalid todo ID", - ); - if (parsed instanceof Response) { - return parsed; - } - return parsed.id; -} - /** * Update one todo. * @pathParams resourceIdPathParamsSchema @@ -43,16 +26,15 @@ export async function PATCH( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const parsed = await parseTodoId(params); + const parsed = await parseResourceIdParam(params, "todo"); if (parsed instanceof Response) { return parsed; } const id = parsed; - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; const parsedBody = await parseRouteJsonBody( request, @@ -83,7 +65,7 @@ export async function PATCH( return forbidden(); } - const updates: Record = {}; + const updates: Prisma.TodoUpdateInput = {}; if (parsedBody.title !== undefined) updates.title = parsedBody.title; if (Object.hasOwn(parsedBody, "content")) { updates.content = parsedBody.content?.trim() || null; @@ -116,16 +98,15 @@ export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const parsed = await parseTodoId(params); + const parsed = await parseResourceIdParam(params, "todo"); if (parsed instanceof Response) { return parsed; } const id = parsed; - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; try { const todo = await prisma.todo.findUnique({ diff --git a/src/app/api/todos/route.ts b/src/app/api/todos/route.ts index 851f5a45..d6f5c119 100644 --- a/src/app/api/todos/route.ts +++ b/src/app/api/todos/route.ts @@ -1,18 +1,19 @@ +import type { Prisma } from "@/generated/prisma/client"; import { badRequest, handleRouteError, jsonResponse, - parseRouteInput, parseRouteJsonBody, - unauthorized, + parseRouteSearchParams, } from "@/lib/api/helpers"; import { todoCreateRequestSchema, todosQuerySchema, } from "@/lib/api/schemas/request-schemas"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { requireAuth } from "@/lib/auth/helpers"; import { prisma } from "@/lib/db/prisma"; import { parseDateInput } from "@/lib/time/parse-date-input"; + export const dynamic = "force-dynamic"; /** @@ -22,19 +23,13 @@ export const dynamic = "force-dynamic"; * @response 401:openApiErrorSchema */ export async function GET(request: Request) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; const { searchParams } = new URL(request.url); - const parsedQuery = parseRouteInput( - { - completed: searchParams.get("completed") ?? undefined, - priority: searchParams.get("priority") ?? undefined, - dueBefore: searchParams.get("dueBefore") ?? undefined, - dueAfter: searchParams.get("dueAfter") ?? undefined, - }, + const parsedQuery = parseRouteSearchParams( + searchParams, todosQuerySchema, "Invalid todo query", { logErrors: true }, @@ -43,14 +38,20 @@ export async function GET(request: Request) { return parsedQuery; } - const where: Record = { userId }; + const where: Prisma.TodoWhereInput = { userId }; if (parsedQuery.completed === "true") where.completed = true; else if (parsedQuery.completed === "false") where.completed = false; if (parsedQuery.priority) where.priority = parsedQuery.priority; if (parsedQuery.dueBefore || parsedQuery.dueAfter) { - const dueAtFilter: Record = {}; - if (parsedQuery.dueBefore) dueAtFilter.lt = new Date(parsedQuery.dueBefore); - if (parsedQuery.dueAfter) dueAtFilter.gte = new Date(parsedQuery.dueAfter); + const dueAtFilter: Prisma.TodoWhereInput["dueAt"] = {}; + if (parsedQuery.dueBefore) { + const parsed = parseDateInput(parsedQuery.dueBefore); + if (parsed) dueAtFilter.lt = parsed; + } + if (parsedQuery.dueAfter) { + const parsed = parseDateInput(parsedQuery.dueAfter); + if (parsed) dueAtFilter.gte = parsed; + } where.dueAt = dueAtFilter; } @@ -73,10 +74,9 @@ export async function GET(request: Request) { * @response 400:openApiErrorSchema */ export async function POST(request: Request) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; const parsedBody = await parseRouteJsonBody( request, @@ -87,9 +87,13 @@ export async function POST(request: Request) { return parsedBody; } - const dueAt = parseDateInput(parsedBody.dueAt); - if (dueAt === undefined) { - return badRequest("Invalid due date"); + const dueAtRaw = parsedBody.dueAt; + let dueAt: Date | null | undefined; + if (dueAtRaw !== undefined) { + dueAt = parseDateInput(dueAtRaw); + if (dueAt === undefined) { + return badRequest("Invalid due date"); + } } try { @@ -99,7 +103,7 @@ export async function POST(request: Request) { title: parsedBody.title, content: parsedBody.content?.trim() || null, priority: parsedBody.priority ?? "medium", - dueAt, + ...(dueAt !== undefined && { dueAt }), }, }); diff --git a/src/app/api/uploads/[id]/download/route.ts b/src/app/api/uploads/[id]/download/route.ts index 3271fee1..b698a18d 100644 --- a/src/app/api/uploads/[id]/download/route.ts +++ b/src/app/api/uploads/[id]/download/route.ts @@ -1,38 +1,17 @@ import { GetObjectCommand } from "@aws-sdk/client-s3"; import { NextResponse } from "next/server"; +import { buildContentDisposition } from "@/features/uploads/lib/upload-utils"; import { handleRouteError, notFound, - parseRouteParams, - unauthorized, + parseResourceIdParam, } from "@/lib/api/helpers"; -import { resourceIdPathParamsSchema } from "@/lib/api/schemas/request-schemas"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { requireAuth } from "@/lib/auth/helpers"; import { prisma } from "@/lib/db/prisma"; import { getS3Bucket, getS3SignedUrl } from "@/lib/storage/s3"; export const dynamic = "force-dynamic"; -async function parseUploadId( - params: Promise<{ id: string }>, -): Promise { - const parsed = await parseRouteParams( - params, - resourceIdPathParamsSchema, - "Invalid upload ID", - ); - if (parsed instanceof Response) { - return parsed; - } - - return parsed.id; -} - -function buildContentDisposition(filename: string) { - const safeName = filename.replace(/"/g, "'"); - return `attachment; filename="${safeName}"`; -} - /** * Redirect to signed URL for one upload. * @pathParams resourceIdPathParamsSchema @@ -44,12 +23,11 @@ export async function GET( request: Request, context: { params: Promise<{ id: string }> }, ) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; - const parsed = await parseUploadId(context.params); + const parsed = await parseResourceIdParam(context.params, "upload"); if (parsed instanceof Response) { return parsed; } diff --git a/src/app/api/uploads/[id]/route.ts b/src/app/api/uploads/[id]/route.ts index 97c7bcd4..45fe3e2e 100644 --- a/src/app/api/uploads/[id]/route.ts +++ b/src/app/api/uploads/[id]/route.ts @@ -1,46 +1,24 @@ import { DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { sanitizeFilename } from "@/features/uploads/lib/upload-utils"; import { badRequest, handleRouteError, jsonResponse, notFound, + parseResourceIdParam, parseRouteJsonBody, - parseRouteParams, - unauthorized, } from "@/lib/api/helpers"; +import { uploadRenameRequestSchema } from "@/lib/api/schemas/request-schemas"; import { - resourceIdPathParamsSchema, - uploadRenameRequestSchema, -} from "@/lib/api/schemas/request-schemas"; -import { + fireAuditLog, getAuditRequestMetadata, - writeAuditLog, } from "@/lib/audit/write-audit-log"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { requireAuth } from "@/lib/auth/helpers"; import { prisma } from "@/lib/db/prisma"; import { getS3Bucket, sendS3 } from "@/lib/storage/s3"; export const dynamic = "force-dynamic"; -async function parseUploadId( - params: Promise<{ id: string }>, -): Promise { - const parsed = await parseRouteParams( - params, - resourceIdPathParamsSchema, - "Invalid upload ID", - ); - if (parsed instanceof Response) { - return parsed; - } - - return parsed.id; -} - -function sanitizeFilename(filename: string) { - return filename.trim(); -} - /** * Rename one upload. * @pathParams resourceIdPathParamsSchema @@ -54,12 +32,11 @@ export async function PATCH( request: Request, context: { params: Promise<{ id: string }> }, ) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; - const parsed = await parseUploadId(context.params); + const parsed = await parseResourceIdParam(context.params, "upload"); if (parsed instanceof Response) { return parsed; } @@ -125,12 +102,11 @@ export async function DELETE( request: Request, context: { params: Promise<{ id: string }> }, ) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; - const parsed = await parseUploadId(context.params); + const parsed = await parseResourceIdParam(context.params, "upload"); if (parsed instanceof Response) { return parsed; } @@ -146,20 +122,32 @@ export async function DELETE( return notFound(); } - await sendS3( - new DeleteObjectCommand({ Bucket: getS3Bucket(), Key: upload.key }), - ); - + // Delete DB record first, then S3 object. + // If S3 cleanup fails, the record is gone and the orphaned S3 object + // is harmless (no DB reference points to it). A reverse order would + // leave a DB record pointing at a missing S3 object. await prisma.upload.delete({ where: { id: upload.id } }); - writeAuditLog({ + try { + await sendS3( + new DeleteObjectCommand({ Bucket: getS3Bucket(), Key: upload.key }), + ); + } catch (s3Error) { + // S3 cleanup failure is non-critical — the DB record is already gone. + handleRouteError( + "S3 object cleanup failed after upload deletion", + s3Error, + ); + } + + fireAuditLog({ action: "upload_delete", userId, targetId: upload.id, targetType: "upload", metadata: { key: upload.key, size: upload.size }, ...getAuditRequestMetadata(request), - }).catch(() => {}); + }); return jsonResponse({ deletedId: upload.id, diff --git a/src/app/api/uploads/complete/route.ts b/src/app/api/uploads/complete/route.ts index 8839edda..011f742e 100644 --- a/src/app/api/uploads/complete/route.ts +++ b/src/app/api/uploads/complete/route.ts @@ -4,28 +4,21 @@ import { runUploadSerializableTransaction, UploadError, } from "@/features/uploads/lib/upload-quota"; +import { normalizeContentType } from "@/features/uploads/lib/upload-utils"; import { badRequest, forbidden, handleRouteError, jsonResponse, parseRouteJsonBody, - payloadTooLarge, - unauthorized, } from "@/lib/api/helpers"; import { uploadCompleteRequestSchema } from "@/lib/api/schemas/request-schemas"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { requireAuth } from "@/lib/auth/helpers"; import { prisma } from "@/lib/db/prisma"; import { getS3Bucket, sendS3 } from "@/lib/storage/s3"; export const dynamic = "force-dynamic"; -function normalizeContentType(value: unknown) { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - /** * Finalize one upload after S3 put. * @body uploadCompleteRequestSchema @@ -33,10 +26,9 @@ function normalizeContentType(value: unknown) { * @response 400:openApiErrorSchema */ export async function POST(request: Request) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; const parsedBody = await parseRouteJsonBody( request, @@ -94,31 +86,7 @@ export async function POST(request: Request) { }); } - const pendingCheck = await prisma.uploadPending.findUnique({ - where: { key }, - }); - if (!pendingCheck || pendingCheck.userId !== userId) { - return badRequest("Upload session expired"); - } - - const bucket = getS3Bucket(); - const head = await sendS3( - new HeadObjectCommand({ Bucket: bucket, Key: key }), - ); - - const size = head.ContentLength ?? 0; - if (!size || size <= 0) { - return badRequest("Uploaded object missing"); - } - - if (size > uploadConfig.maxFileSizeBytes) { - await sendS3(new DeleteObjectCommand({ Bucket: bucket, Key: key })); - return payloadTooLarge("File too large"); - } - - const contentType = - normalizeContentType(parsedBody.contentType) ?? head.ContentType; - + // Move all checks inside the serializable transaction for consistency. const reservation = await runUploadSerializableTransaction(async (tx) => { const pending = await tx.uploadPending.findUnique({ where: { key } }); if (!pending || pending.userId !== userId) { @@ -130,6 +98,27 @@ export async function POST(request: Request) { throw new UploadError("Upload session expired"); } + // S3 HeadObject check must happen outside the transaction boundary + // since it's an external service call — we verify the file exists + // before committing quota changes. + const bucket = getS3Bucket(); + const head = await sendS3( + new HeadObjectCommand({ Bucket: bucket, Key: key }), + ); + + const size = head.ContentLength ?? 0; + if (!size || size <= 0) { + throw new UploadError("Uploaded object missing"); + } + + if (size > uploadConfig.maxFileSizeBytes) { + await sendS3(new DeleteObjectCommand({ Bucket: bucket, Key: key })); + throw new UploadError("File too large"); + } + + const contentType = + normalizeContentType(parsedBody.contentType) ?? head.ContentType; + const [usage, pendingUsage] = await Promise.all([ tx.upload.aggregate({ where: { userId }, diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index 56d136da..8743f697 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -4,6 +4,7 @@ import { runUploadSerializableTransaction, UploadError, } from "@/features/uploads/lib/upload-quota"; +import { normalizeContentType } from "@/features/uploads/lib/upload-utils"; import { badRequest, handleRouteError, @@ -11,10 +12,9 @@ import { parseInteger, parseRouteJsonBody, payloadTooLarge, - unauthorized, } from "@/lib/api/helpers"; import { uploadCreateRequestSchema } from "@/lib/api/schemas/request-schemas"; -import { resolveApiUserId } from "@/lib/auth/helpers"; +import { requireAuth } from "@/lib/auth/helpers"; import { prisma } from "@/lib/db/prisma"; import { buildUploadKey, getS3Bucket, getS3SignedUrl } from "@/lib/storage/s3"; @@ -26,21 +26,14 @@ function parseFileSize(value: unknown) { return parseInteger(value); } -function normalizeContentType(value: unknown) { - if (typeof value !== "string") return "application/octet-stream"; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : "application/octet-stream"; -} - /** * List uploads of current user. * @response uploadsListResponseSchema */ export async function GET(request: Request) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; try { const now = new Date(); @@ -96,10 +89,9 @@ export async function GET(request: Request) { * @response 400:openApiErrorSchema */ export async function POST(request: Request) { - const userId = await resolveApiUserId(request); - if (!userId) { - return unauthorized(); - } + const auth = await requireAuth(request); + if (auth instanceof Response) return auth; + const { userId } = auth; const parsedBody = await parseRouteJsonBody( request, diff --git a/src/app/courses/[jwId]/page.tsx b/src/app/courses/[jwId]/page.tsx index cb803c8b..30cc39e8 100644 --- a/src/app/courses/[jwId]/page.tsx +++ b/src/app/courses/[jwId]/page.tsx @@ -27,6 +27,8 @@ import { CommentAwareTabs } from "@/features/comments/components/comment-aware-t import { CommentsSection } from "@/features/comments/components/comments-section"; import { getCommentsPayload } from "@/features/comments/server/comments-server"; import { DescriptionLoader } from "@/features/descriptions/components/description-loader"; +import { Link } from "@/i18n/routing"; +import { parseInteger } from "@/lib/api/request-integers"; import { getViewerContext } from "@/lib/auth/viewer-context"; import { prisma as basePrisma, getPrisma } from "@/lib/db/prisma"; @@ -39,9 +41,9 @@ export async function generateMetadata({ const locale = await getLocale(); const prisma = getPrisma(locale); const { jwId } = await params; - const parsedId = parseInt(jwId, 10); + const parsedId = parseInteger(jwId); - if (Number.isNaN(parsedId)) { + if (parsedId === null) { return { title: t("pages.courses") }; } @@ -73,17 +75,13 @@ export async function generateMetadata({ export default async function CoursePage({ params, - searchParams, }: { params: Promise<{ jwId: string }>; - searchParams: Promise<{ view?: string }>; }) { const { jwId } = await params; - const searchP = await searchParams; - const _view = searchP.view || "table"; - const parsedId = parseInt(jwId, 10); + const parsedId = parseInteger(jwId); - if (Number.isNaN(parsedId)) { + if (parsedId === null) { notFound(); } @@ -138,16 +136,21 @@ export default async function CoursePage({ /> } > -
-
+
+
-

{course.namePrimary}

+

+ {course.namePrimary} +

{course.nameSecondary ? ( -

+

{course.nameSecondary}

) : null}
+ }> + +
- - - - {t("semester")} - {t("sectionCode")} - {t("teachers")} - {t("campus")} - {t("capacity")} - - - - {course.sections.map((section) => ( - - - {section.semester ? section.semester.nameCn : "—"} - - - {section.code} - - -
0 + {course.sections.length > 0 ? ( +
+ {course.sections.map((section) => { + const teachers = + section.teachers && section.teachers.length > 0 + ? section.teachers + .map((teacher) => + teacher.nameSecondary + ? `${teacher.namePrimary} (${teacher.nameSecondary})` + : teacher.namePrimary, + ) + .join(", ") + : "—"; + + return ( + +
+
+

+ {section.semester + ? section.semester.nameCn + : "—"} +

+

+ {teachers} +

+
+ + {section.code} + +
+
+
+

+ {t("campus")} +

+

+ {section.campus + ? section.campus.namePrimary + : "—"} +

+
+
+

+ {t("capacity")} +

+

+ {section.stdCount ?? 0} /{" "} + {section.limitCount ?? "—"} +

+
+
+ + ); + })} +
+ ) : null} + +
+
+ + + {t("semester")} + {t("sectionCode")} + {t("teachers")} + {t("campus")} + {t("capacity")} + + + + {course.sections.map((section) => ( + + + {section.semester ? section.semester.nameCn : "—"} + + + {section.code} + + +
0 + ? section.teachers + .map((teacher) => + teacher.nameSecondary + ? `${teacher.namePrimary} (${teacher.nameSecondary})` + : teacher.namePrimary, + ) + .join(", ") + : undefined + } + > + {section.teachers && section.teachers.length > 0 ? section.teachers .map((teacher) => teacher.nameSecondary @@ -209,33 +286,23 @@ export default async function CoursePage({ : teacher.namePrimary, ) .join(", ") - : undefined - } - > - {section.teachers && section.teachers.length > 0 - ? section.teachers - .map((teacher) => - teacher.nameSecondary - ? `${teacher.namePrimary} (${teacher.nameSecondary})` - : teacher.namePrimary, - ) - .join(", ") - : "—"} -
-
- - {section.campus ? section.campus.namePrimary : "—"} - - - - {section.stdCount ?? 0} /{" "} - {section.limitCount ?? "—"} - - -
- ))} -
-
+ : "—"} +
+ + + {section.campus ? section.campus.namePrimary : "—"} + + + + {section.stdCount ?? 0} /{" "} + {section.limitCount ?? "—"} + + + + ))} + + +
{course.sections.length === 0 ? ( @@ -249,10 +316,7 @@ export default async function CoursePage({
-
diff --git a/src/app/guides/markdown-support/page.tsx b/src/app/guides/markdown-support/page.tsx index 6ed94115..c50309a6 100644 --- a/src/app/guides/markdown-support/page.tsx +++ b/src/app/guides/markdown-support/page.tsx @@ -75,7 +75,7 @@ Something went wrong. { title: t("mdxTitle"), description: t("mdxDescription"), - code: `![Cute Cat](https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=a+cute+cat&image_size=landscape_4_3){width=200 align=center} + code: `![Life@USTC icon](/images/icon.png){width=96 align=center} ::::center **Centered content using Directives** diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6f487678..c240c680 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import { IBM_Plex_Sans, JetBrains_Mono, Noto_Sans_SC } from "next/font/google"; import { headers } from "next/headers"; import Script from "next/script"; import { NextIntlClientProvider } from "next-intl"; @@ -13,25 +12,60 @@ import { UserMenu } from "@/components/user-menu"; import { getCanonicalOrigin } from "@/lib/site-url"; import "./globals.css"; -const ibmPlexSans = IBM_Plex_Sans({ - subsets: ["latin"], - weight: ["400", "500", "600", "700"], - display: "swap", - variable: "--font-sans", -}); +type FontClassNames = { + sans: string; + fallback: string; + mono: string; +}; -const notoSansSc = Noto_Sans_SC({ - subsets: ["latin"], - weight: ["400", "500", "600", "700"], - display: "swap", - variable: "--font-sans-fallback", -}); +let fontClassNamesCache: FontClassNames = { + sans: "", + fallback: "", + mono: "", +}; +let fontClassNamesLoaded = false; -const jetBrainsMono = JetBrains_Mono({ - subsets: ["latin"], - display: "swap", - variable: "--font-mono", -}); +async function getFontClassNames(): Promise { + if (fontClassNamesLoaded) return fontClassNamesCache; + fontClassNamesLoaded = true; + + try { + const googleFonts = await import("next/font/google"); + const [ibmPlexSans, notoSansSc, jetBrainsMono] = await Promise.all([ + googleFonts.IBM_Plex_Sans({ + subsets: ["latin"], + weight: ["400", "500", "600", "700"], + display: "swap", + variable: "--font-sans", + }), + googleFonts.Noto_Sans_SC({ + subsets: ["latin"], + weight: ["400", "500", "600", "700"], + display: "swap", + variable: "--font-sans-fallback", + }), + googleFonts.JetBrains_Mono({ + subsets: ["latin"], + display: "swap", + variable: "--font-mono", + }), + ]); + + fontClassNamesCache = { + sans: ibmPlexSans.variable, + fallback: notoSansSc.variable, + mono: jetBrainsMono.variable, + }; + } catch { + fontClassNamesCache = { + sans: "", + fallback: "", + mono: "", + }; + } + + return fontClassNamesCache; +} export async function generateMetadata(): Promise { const t = await getTranslations("metadata"); @@ -60,6 +94,7 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { + const fontClassNames = await getFontClassNames(); const headerStore = await headers(); const nonce = headerStore.get("x-csp-nonce") ?? undefined; @@ -75,7 +110,7 @@ export default async function RootLayout({