@knotpad/app 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(app)/calendar/page.tsx +57 -0
- package/app/(app)/error.tsx +35 -0
- package/app/(app)/graph/page.tsx +32 -0
- package/app/(app)/guide/page.tsx +21 -0
- package/app/(app)/kanban/loading.tsx +24 -0
- package/app/(app)/kanban/page.tsx +59 -0
- package/app/(app)/layout.tsx +122 -0
- package/app/(app)/list/loading.tsx +21 -0
- package/app/(app)/list/page.tsx +137 -0
- package/app/(app)/loading.tsx +18 -0
- package/app/(app)/notes/[noteId]/page.tsx +84 -0
- package/app/(app)/notes/layout.tsx +30 -0
- package/app/(app)/notes/page.tsx +39 -0
- package/app/(app)/page.tsx +5 -0
- package/app/(app)/settings/agent-token/page.tsx +59 -0
- package/app/(app)/settings/backup/page.tsx +49 -0
- package/app/(app)/settings/billing/page.tsx +53 -0
- package/app/(app)/settings/calendar/page.tsx +41 -0
- package/app/(app)/settings/layout.test.tsx +39 -0
- package/app/(app)/settings/layout.tsx +71 -0
- package/app/(app)/settings/page.tsx +4 -0
- package/app/(app)/settings/security/page.tsx +43 -0
- package/app/(app)/settings/team/page.tsx +74 -0
- package/app/(app)/settings/workspace/page.tsx +27 -0
- package/app/(app)/tasks/[taskId]/page.tsx +79 -0
- package/app/(auth)/forgot-password/page.tsx +106 -0
- package/app/(auth)/guest/page.tsx +56 -0
- package/app/(auth)/layout.tsx +13 -0
- package/app/(auth)/login/page.tsx +14 -0
- package/app/(auth)/register/page.tsx +193 -0
- package/app/(auth)/reset-password/page.tsx +138 -0
- package/app/api/account/claim/route.tsx +135 -0
- package/app/api/admin/backfill-encryption/route.tsx +43 -0
- package/app/api/admin/license/route.tsx +42 -0
- package/app/api/auth/2fa/route.tsx +148 -0
- package/app/api/auth/[...nextauth]/route.tsx +3 -0
- package/app/api/auth/change-password/route.tsx +61 -0
- package/app/api/auth/check-2fa/route.tsx +19 -0
- package/app/api/auth/forgot-password/route.tsx +65 -0
- package/app/api/auth/reset-password/route.tsx +52 -0
- package/app/api/auth/verify-2fa/route.tsx +88 -0
- package/app/api/backup/download/db/route.ts +29 -0
- package/app/api/backup/download/notes/route.ts +25 -0
- package/app/api/backup/settings/route.ts +92 -0
- package/app/api/billing/checkout/route.tsx +81 -0
- package/app/api/billing/migrate/route.tsx +163 -0
- package/app/api/billing/portal/route.tsx +24 -0
- package/app/api/billing/setup-intent/route.tsx +55 -0
- package/app/api/billing/status/route.tsx +36 -0
- package/app/api/billing/subscribe/route.tsx +85 -0
- package/app/api/billing/webhook/route.tsx +199 -0
- package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
- package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
- package/app/api/calendar-feeds/events/route.tsx +82 -0
- package/app/api/calendar-feeds/route.tsx +52 -0
- package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
- package/app/api/cron/calendar-feeds/route.tsx +31 -0
- package/app/api/cron/stale-tasks/route.tsx +51 -0
- package/app/api/cron/sync/route.tsx +34 -0
- package/app/api/devices/[deviceId]/route.tsx +25 -0
- package/app/api/devices/route.tsx +41 -0
- package/app/api/export/route.tsx +40 -0
- package/app/api/feedback/route.tsx +54 -0
- package/app/api/folders/[folderId]/route.tsx +51 -0
- package/app/api/folders/route.tsx +37 -0
- package/app/api/graph/route.tsx +242 -0
- package/app/api/guest/route.tsx +58 -0
- package/app/api/health/route.tsx +10 -0
- package/app/api/holidays/countries/route.tsx +14 -0
- package/app/api/holidays/route.tsx +49 -0
- package/app/api/holidays/states/route.tsx +21 -0
- package/app/api/invites/[token]/route.tsx +131 -0
- package/app/api/invites/route.tsx +74 -0
- package/app/api/mcp/generate-token/route.tsx +55 -0
- package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
- package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
- package/app/api/notes/[noteId]/export/route.tsx +45 -0
- package/app/api/notes/[noteId]/route.tsx +360 -0
- package/app/api/notes/route.tsx +112 -0
- package/app/api/notifications/route.tsx +44 -0
- package/app/api/register/route.tsx +67 -0
- package/app/api/restore/route.tsx +148 -0
- package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
- package/app/api/sync/conflicts/route.tsx +48 -0
- package/app/api/sync/status/route.tsx +49 -0
- package/app/api/sync/trigger/route.tsx +15 -0
- package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
- package/app/api/tasks/[taskId]/route.tsx +259 -0
- package/app/api/tasks/bulk/route.tsx +133 -0
- package/app/api/tasks/route.tsx +36 -0
- package/app/api/workspace/active/route.tsx +39 -0
- package/app/api/workspace/create-team/route.tsx +42 -0
- package/app/api/workspace/kanban-statuses/route.tsx +71 -0
- package/app/api/workspace/members/[memberId]/route.tsx +69 -0
- package/app/api/workspace/route.tsx +24 -0
- package/app/download/page.tsx +170 -0
- package/app/favicon.ico +0 -0
- package/app/generated/prisma/client.d.ts +1 -0
- package/app/generated/prisma/client.js +5 -0
- package/app/generated/prisma/default.d.ts +1 -0
- package/app/generated/prisma/default.js +5 -0
- package/app/generated/prisma/edge.d.ts +1 -0
- package/app/generated/prisma/edge.js +497 -0
- package/app/generated/prisma/index-browser.js +523 -0
- package/app/generated/prisma/index.d.ts +46376 -0
- package/app/generated/prisma/index.js +497 -0
- package/app/generated/prisma/package.json +144 -0
- package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/app/generated/prisma/runtime/client.d.ts +3386 -0
- package/app/generated/prisma/runtime/client.js +86 -0
- package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
- package/app/generated/prisma/runtime/index-browser.js +6 -0
- package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
- package/app/generated/prisma/schema.prisma +456 -0
- package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
- package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
- package/app/globals.css +54 -0
- package/app/invite/[token]/page.tsx +52 -0
- package/app/layout.tsx +90 -0
- package/app/mcp/route.tsx +430 -0
- package/app/opengraph-image.tsx +120 -0
- package/app/page.tsx +398 -0
- package/app/privacy/page.tsx +69 -0
- package/app/robots.tsx +25 -0
- package/app/sitemap.tsx +36 -0
- package/app/terms/page.tsx +69 -0
- package/app/upgrade/page.tsx +75 -0
- package/auth.config.ts +33 -0
- package/auth.ts +79 -0
- package/bin/brief.js +229 -0
- package/components/auth/login-form.tsx +302 -0
- package/components/auth/password-checklist.tsx +31 -0
- package/components/auth/password-input.tsx +36 -0
- package/components/auth/switch-account-button.test.tsx +22 -0
- package/components/auth/switch-account-button.tsx +19 -0
- package/components/auth/two-factor-input.tsx +116 -0
- package/components/billing/billing-dashboard.tsx +265 -0
- package/components/billing/card-form.tsx +210 -0
- package/components/billing/claim-account-form.tsx +99 -0
- package/components/branding/app-logo.test.tsx +20 -0
- package/components/branding/app-logo.tsx +25 -0
- package/components/calendar/calendar-agenda.tsx +150 -0
- package/components/calendar/calendar-drag.test.tsx +177 -0
- package/components/calendar/calendar-grid.tsx +357 -0
- package/components/calendar/calendar-hooks.test.tsx +27 -0
- package/components/calendar/calendar-hooks.ts +351 -0
- package/components/calendar/calendar-toolbar.test.tsx +68 -0
- package/components/calendar/calendar-toolbar.tsx +291 -0
- package/components/calendar/calendar-types.ts +148 -0
- package/components/calendar/calendar-view.test.tsx +295 -0
- package/components/calendar/calendar-view.tsx +307 -0
- package/components/calendar/day-detail-popover.tsx +174 -0
- package/components/calendar/task-chip.tsx +86 -0
- package/components/command/command-palette.test.tsx +33 -0
- package/components/command/command-palette.tsx +310 -0
- package/components/download-cta.tsx +87 -0
- package/components/feedback/feedback-popup.tsx +207 -0
- package/components/graph/graph-draw.ts +337 -0
- package/components/graph/graph-overlays.tsx +160 -0
- package/components/graph/graph-page.test.tsx +131 -0
- package/components/graph/graph-page.tsx +263 -0
- package/components/graph/graph-types.ts +47 -0
- package/components/graph/graph-view.tsx +322 -0
- package/components/guide/guide-view.tsx +522 -0
- package/components/kanban/kanban-board.test.tsx +128 -0
- package/components/kanban/kanban-board.tsx +361 -0
- package/components/kanban/kanban-card-menu.tsx +102 -0
- package/components/kanban/kanban-card.tsx +227 -0
- package/components/kanban/kanban-column.tsx +49 -0
- package/components/kanban/kanban-status-context.tsx +28 -0
- package/components/landing/calendar-sandbox.test.tsx +15 -0
- package/components/landing/calendar-sandbox.tsx +107 -0
- package/components/landing/graph-sandbox.test.tsx +27 -0
- package/components/landing/graph-sandbox.tsx +80 -0
- package/components/landing/kanban-sandbox.test.tsx +24 -0
- package/components/landing/kanban-sandbox.tsx +101 -0
- package/components/landing/landing-showcase.test.tsx +21 -0
- package/components/landing/landing-showcase.tsx +54 -0
- package/components/landing/list-sandbox.tsx +86 -0
- package/components/landing/mock-workspace.ts +168 -0
- package/components/landing/notes-sandbox.test.tsx +14 -0
- package/components/landing/notes-sandbox.tsx +88 -0
- package/components/layout/app-shell.tsx +83 -0
- package/components/layout/backup-scheduler.tsx +122 -0
- package/components/layout/bottom-nav.tsx +43 -0
- package/components/layout/icon-bar.test.tsx +29 -0
- package/components/layout/icon-bar.tsx +118 -0
- package/components/layout/mobile-top-bar.tsx +68 -0
- package/components/layout/notes-panel-folder.tsx +127 -0
- package/components/layout/notes-panel-note-item.tsx +140 -0
- package/components/layout/notes-panel-task-tab.tsx +63 -0
- package/components/layout/notes-panel-types.ts +44 -0
- package/components/layout/notes-panel.tsx +476 -0
- package/components/layout/notification-bell.tsx +251 -0
- package/components/layout/paywall-screen.tsx +41 -0
- package/components/layout/pro-banner.tsx +76 -0
- package/components/layout/sw-register.tsx +27 -0
- package/components/layout/workspace-switcher.tsx +90 -0
- package/components/notes/mobile-bottom-sheet.tsx +99 -0
- package/components/notes/note-editor-context-menu.tsx +47 -0
- package/components/notes/note-editor-dom.ts +33 -0
- package/components/notes/note-editor-dropdowns.tsx +484 -0
- package/components/notes/note-editor-hooks.ts +692 -0
- package/components/notes/note-editor-keyboard.ts +305 -0
- package/components/notes/note-editor-overlay.tsx +90 -0
- package/components/notes/note-editor.test.tsx +372 -0
- package/components/notes/note-editor.tsx +662 -0
- package/components/notes/note-preview-pane.tsx +156 -0
- package/components/notes/note-tabs.tsx +120 -0
- package/components/notes/note-types.tsx +157 -0
- package/components/settings/accept-invite.tsx +108 -0
- package/components/settings/agent-token-settings.tsx +369 -0
- package/components/settings/backup-restore-settings.test.tsx +25 -0
- package/components/settings/backup-restore-settings.tsx +327 -0
- package/components/settings/calendar-feeds-settings.tsx +489 -0
- package/components/settings/calendar-general-settings.tsx +174 -0
- package/components/settings/confirm-danger-action.test.tsx +215 -0
- package/components/settings/confirm-danger-action.tsx +65 -0
- package/components/settings/security-settings.tsx +252 -0
- package/components/settings/settings-guidance.test.tsx +98 -0
- package/components/settings/team-settings.tsx +319 -0
- package/components/settings/two-factor-auth.tsx +296 -0
- package/components/settings/workspace-settings-client.tsx +363 -0
- package/components/settings/workspace-settings-form.tsx +73 -0
- package/components/sync/conflict-viewer.tsx +247 -0
- package/components/sync/sync-indicator.tsx +171 -0
- package/components/tasks/snippet-thread.tsx +119 -0
- package/components/tasks/status-dot.tsx +47 -0
- package/components/tasks/task-badge.tsx +43 -0
- package/components/tasks/task-detail.test.tsx +187 -0
- package/components/tasks/task-detail.tsx +458 -0
- package/components/tasks/task-list-filters.test.tsx +75 -0
- package/components/tasks/task-list-filters.tsx +163 -0
- package/components/tasks/task-list-types.ts +20 -0
- package/components/tasks/task-list.test.tsx +175 -0
- package/components/tasks/task-list.tsx +481 -0
- package/components/tasks/task-row.tsx +85 -0
- package/components/tasks/task-table-row.tsx +259 -0
- package/components/ui/skeleton.tsx +3 -0
- package/components/ui/toast.test.tsx +42 -0
- package/components/ui/toast.tsx +70 -0
- package/instrumentation.tsx +23 -0
- package/lib/api-error.ts +50 -0
- package/lib/backup/backup-runner.test.ts +32 -0
- package/lib/backup/backup-runner.ts +19 -0
- package/lib/backup/backup-schedule.test.ts +23 -0
- package/lib/backup/backup-schedule.ts +55 -0
- package/lib/backup/backup-settings.test.ts +30 -0
- package/lib/backup/backup-settings.ts +27 -0
- package/lib/backup/export-notes-zip.test.ts +26 -0
- package/lib/backup/export-notes-zip.ts +82 -0
- package/lib/backup/export-workspace-backup.test.ts +17 -0
- package/lib/backup/export-workspace-backup.ts +77 -0
- package/lib/backup/restore-workspace-from-export.test.ts +18 -0
- package/lib/backup/restore-workspace-from-export.ts +183 -0
- package/lib/backup/types.ts +14 -0
- package/lib/brand-icons.ts +1 -0
- package/lib/calendar-feed-crypto.ts +38 -0
- package/lib/calendar-feed.ts +239 -0
- package/lib/client/online-status.ts +47 -0
- package/lib/conflict-resolver.test.ts +57 -0
- package/lib/conflict-resolver.ts +240 -0
- package/lib/db-init.ts +79 -0
- package/lib/email.ts +159 -0
- package/lib/encryption.test.ts +41 -0
- package/lib/encryption.ts +98 -0
- package/lib/extract-snippet.test.ts +123 -0
- package/lib/extract-snippet.ts +69 -0
- package/lib/kanban-status.ts +55 -0
- package/lib/license.ts +21 -0
- package/lib/limits.ts +31 -0
- package/lib/mcp-auth.test.ts +58 -0
- package/lib/mcp-auth.ts +65 -0
- package/lib/mcp-contract.test.ts +25 -0
- package/lib/mcp-contract.ts +210 -0
- package/lib/mcp-handler.ts +31 -0
- package/lib/mcp-url.test.ts +12 -0
- package/lib/mcp-url.ts +7 -0
- package/lib/mentions.test.ts +45 -0
- package/lib/mentions.ts +73 -0
- package/lib/note-crypto.ts +108 -0
- package/lib/note-sync.ts +201 -0
- package/lib/note-title.ts +93 -0
- package/lib/prisma.ts +193 -0
- package/lib/pro-flush.ts +292 -0
- package/lib/rate-limit.ts +57 -0
- package/lib/stripe.ts +38 -0
- package/lib/sync-worker.ts +388 -0
- package/lib/task-parser.test.ts +91 -0
- package/lib/task-parser.ts +81 -0
- package/lib/task-utils.ts +52 -0
- package/lib/use-is-electron.ts +19 -0
- package/lib/use-is-mobile.ts +22 -0
- package/lib/validation/calendar-feed.ts +31 -0
- package/lib/validation/note.ts +27 -0
- package/lib/validation/task.ts +26 -0
- package/lib/view-preferences.test.ts +54 -0
- package/lib/view-preferences.ts +28 -0
- package/lib/workspace.ts +66 -0
- package/next.config.ts +21 -0
- package/package.json +54 -3
- package/postcss.config.mjs +7 -0
- package/prisma/migrations/20260519021916_init/migration.sql +388 -0
- package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
- package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
- package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
- package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
- package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
- package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
- package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
- package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
- package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
- package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
- package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
- package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
- package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
- package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
- package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
- package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
- package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +457 -0
- package/public/Logo_icon.svg +1 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon_dark.svg +1 -0
- package/public/knotpad_icon.svg +1 -0
- package/public/knotpad_logo_full.svg +1 -0
- package/public/manifest.json +14 -0
- package/public/next.svg +1 -0
- package/public/sw.js +137 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +35 -0
- package/brief.js +0 -311
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { prisma } from "@/lib/prisma";
|
|
3
|
+
import { encryptContent } from "@/lib/note-crypto";
|
|
4
|
+
import { isEncrypted } from "@/lib/encryption";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/admin/backfill-encryption
|
|
8
|
+
* Header: Authorization: Bearer <ADMIN_SECRET>
|
|
9
|
+
*
|
|
10
|
+
* One-off migration: encrypts any note.content rows still stored as plaintext
|
|
11
|
+
* (e.g. created while encryption was disabled). Idempotent — already-encrypted
|
|
12
|
+
* rows are skipped via isEncrypted(), so it is safe to re-run.
|
|
13
|
+
*/
|
|
14
|
+
export async function POST(req: NextRequest) {
|
|
15
|
+
const secret = process.env.ADMIN_SECRET;
|
|
16
|
+
if (!secret) return NextResponse.json({ error: "Not configured" }, { status: 501 });
|
|
17
|
+
|
|
18
|
+
const auth = req.headers.get("authorization");
|
|
19
|
+
if (auth !== `Bearer ${secret}`) {
|
|
20
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const notes = await prisma.note.findMany({ select: { id: true, content: true, workspaceId: true } });
|
|
24
|
+
|
|
25
|
+
let encrypted = 0;
|
|
26
|
+
let skipped = 0;
|
|
27
|
+
for (const note of notes) {
|
|
28
|
+
if (isEncrypted(note.content)) {
|
|
29
|
+
skipped++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const ciphertext = await encryptContent(note.content, note.workspaceId);
|
|
33
|
+
// version stays put (storage-format change, not a content edit); @updatedAt
|
|
34
|
+
// will refresh, which simply makes sync push the now-encrypted row once.
|
|
35
|
+
await prisma.note.update({
|
|
36
|
+
where: { id: note.id },
|
|
37
|
+
data: { content: ciphertext },
|
|
38
|
+
});
|
|
39
|
+
encrypted++;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return NextResponse.json({ ok: true, total: notes.length, encrypted, skipped });
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { prisma } from "@/lib/prisma";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* POST /api/admin/license
|
|
6
|
+
* Body: { workspaceId: string, licenseType: "STANDARD" | "COMPLIMENTARY" }
|
|
7
|
+
* Header: Authorization: Bearer <ADMIN_SECRET>
|
|
8
|
+
*
|
|
9
|
+
* Sets the license type on a workspace.
|
|
10
|
+
* COMPLIMENTARY workspaces get isPro + isCloud for free — no Stripe subscription needed.
|
|
11
|
+
*/
|
|
12
|
+
export async function POST(req: NextRequest) {
|
|
13
|
+
const secret = process.env.ADMIN_SECRET;
|
|
14
|
+
if (!secret) return NextResponse.json({ error: "Not configured" }, { status: 501 });
|
|
15
|
+
|
|
16
|
+
const auth = req.headers.get("authorization");
|
|
17
|
+
if (auth !== `Bearer ${secret}`) {
|
|
18
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { workspaceId, licenseType } = await req.json().catch(() => ({}));
|
|
22
|
+
|
|
23
|
+
if (!workspaceId) return NextResponse.json({ error: "workspaceId required" }, { status: 400 });
|
|
24
|
+
if (!["STANDARD", "COMPLIMENTARY"].includes(licenseType)) {
|
|
25
|
+
return NextResponse.json({ error: "licenseType must be STANDARD or COMPLIMENTARY" }, { status: 400 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isComplimentary = licenseType === "COMPLIMENTARY";
|
|
29
|
+
|
|
30
|
+
const workspace = await prisma.workspace.update({
|
|
31
|
+
where: { id: workspaceId },
|
|
32
|
+
data: {
|
|
33
|
+
licenseType,
|
|
34
|
+
isPro: isComplimentary ? true : undefined,
|
|
35
|
+
isCloud: isComplimentary ? true : undefined,
|
|
36
|
+
// Downgrading to STANDARD: let Stripe state govern isPro/isCloud (don't touch them here)
|
|
37
|
+
},
|
|
38
|
+
select: { id: true, name: true, licenseType: true, isPro: true, isCloud: true, planType: true },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return NextResponse.json(workspace);
|
|
42
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { auth } from "@/auth";
|
|
3
|
+
import { prisma } from "@/lib/prisma";
|
|
4
|
+
import { randomBytes, createHmac } from "crypto";
|
|
5
|
+
|
|
6
|
+
// TOTP implementation using Node.js crypto
|
|
7
|
+
function generateSecret(): string {
|
|
8
|
+
return randomBytes(20).toString("base64url").slice(0, 32);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function generateTOTP(secret: string, window = 0): string {
|
|
12
|
+
const counter = Math.floor(Date.now() / 1000 / 30) + window;
|
|
13
|
+
const counterBuffer = Buffer.alloc(8);
|
|
14
|
+
counterBuffer.writeBigUInt64BE(BigInt(counter), 0);
|
|
15
|
+
|
|
16
|
+
const secretBuffer = Buffer.from(secret, "base64url");
|
|
17
|
+
const hmac = createHmac("sha1", secretBuffer);
|
|
18
|
+
hmac.update(counterBuffer);
|
|
19
|
+
const hash = hmac.digest();
|
|
20
|
+
|
|
21
|
+
const offset = hash[hash.length - 1] & 0x0f;
|
|
22
|
+
const code =
|
|
23
|
+
((hash[offset] & 0x7f) << 24) |
|
|
24
|
+
((hash[offset + 1] & 0xff) << 16) |
|
|
25
|
+
((hash[offset + 2] & 0xff) << 8) |
|
|
26
|
+
(hash[offset + 3] & 0xff);
|
|
27
|
+
|
|
28
|
+
return (code % 1000000).toString().padStart(6, "0");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function verifyTOTP(secret: string, token: string): boolean {
|
|
32
|
+
// Check current and adjacent time windows (±1 window = ±30 seconds)
|
|
33
|
+
for (let window = -1; window <= 1; window++) {
|
|
34
|
+
if (generateTOTP(secret, window) === token) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function generateQRCodeURL(secret: string, email: string, appName: string): string {
|
|
42
|
+
const encodedSecret = secret.replace(/=/g, "");
|
|
43
|
+
const encodedApp = encodeURIComponent(appName);
|
|
44
|
+
const encodedEmail = encodeURIComponent(email);
|
|
45
|
+
return `otpauth://totp/${encodedApp}:${encodedEmail}?secret=${encodedSecret}&issuer=${encodedApp}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// GET: Check if 2FA is enabled for current user
|
|
49
|
+
export async function GET() {
|
|
50
|
+
const session = await auth();
|
|
51
|
+
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
52
|
+
|
|
53
|
+
const user = await prisma.user.findUnique({
|
|
54
|
+
where: { id: session.user.id },
|
|
55
|
+
select: { twoFactorEnabled: true },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return NextResponse.json({ enabled: user?.twoFactorEnabled ?? false });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// POST: Enable 2FA - generate secret and return QR code
|
|
62
|
+
export async function POST(req: NextRequest) {
|
|
63
|
+
const session = await auth();
|
|
64
|
+
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
65
|
+
|
|
66
|
+
const { action } = await req.json();
|
|
67
|
+
|
|
68
|
+
if (action === "setup") {
|
|
69
|
+
// Generate new secret
|
|
70
|
+
const secret = generateSecret();
|
|
71
|
+
const qrCodeURL = generateQRCodeURL(secret, session.user.email, "Knotpad");
|
|
72
|
+
|
|
73
|
+
// Store secret temporarily (not enabled until verified)
|
|
74
|
+
await prisma.user.update({
|
|
75
|
+
where: { id: session.user.id },
|
|
76
|
+
data: { twoFactorSecret: secret, twoFactorEnabled: false, twoFactorVerified: false },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return NextResponse.json({
|
|
80
|
+
secret,
|
|
81
|
+
qrCodeURL,
|
|
82
|
+
manualEntryKey: secret.replace(/=/g, "").slice(0, 32).toUpperCase().replace(/(.{4})/g, "$1 ").trim(),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (action === "verify") {
|
|
87
|
+
const { code } = await req.json();
|
|
88
|
+
if (!code || !/^\d{6}$/.test(code)) {
|
|
89
|
+
return NextResponse.json({ error: "Invalid code format" }, { status: 400 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const user = await prisma.user.findUnique({
|
|
93
|
+
where: { id: session.user.id },
|
|
94
|
+
select: { twoFactorSecret: true, twoFactorEnabled: true },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!user?.twoFactorSecret) {
|
|
98
|
+
return NextResponse.json({ error: "2FA not set up" }, { status: 400 });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (verifyTOTP(user.twoFactorSecret, code)) {
|
|
102
|
+
await prisma.user.update({
|
|
103
|
+
where: { id: session.user.id },
|
|
104
|
+
data: { twoFactorEnabled: true, twoFactorVerified: true },
|
|
105
|
+
});
|
|
106
|
+
return NextResponse.json({ success: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return NextResponse.json({ error: "Invalid code" }, { status: 400 });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (action === "disable") {
|
|
113
|
+
const { code, password } = await req.json();
|
|
114
|
+
|
|
115
|
+
// Verify password first
|
|
116
|
+
const user = await prisma.user.findUnique({
|
|
117
|
+
where: { id: session.user.id },
|
|
118
|
+
select: { passwordHash: true, twoFactorSecret: true, twoFactorEnabled: true },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!user?.passwordHash) {
|
|
122
|
+
return NextResponse.json({ error: "Password required" }, { status: 400 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Import bcrypt for password verification
|
|
126
|
+
const { default: bcrypt } = await import("bcryptjs");
|
|
127
|
+
const validPassword = await bcrypt.compare(password, user.passwordHash);
|
|
128
|
+
if (!validPassword) {
|
|
129
|
+
return NextResponse.json({ error: "Invalid password" }, { status: 400 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Verify 2FA code if enabled
|
|
133
|
+
if (user.twoFactorEnabled && user.twoFactorSecret) {
|
|
134
|
+
if (!code || !verifyTOTP(user.twoFactorSecret, code)) {
|
|
135
|
+
return NextResponse.json({ error: "Invalid 2FA code" }, { status: 400 });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await prisma.user.update({
|
|
140
|
+
where: { id: session.user.id },
|
|
141
|
+
data: { twoFactorSecret: null, twoFactorEnabled: false, twoFactorVerified: false },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return NextResponse.json({ success: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
|
148
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import bcrypt from "bcryptjs";
|
|
3
|
+
import { auth } from "@/auth";
|
|
4
|
+
import { prisma, getCloudPrisma } from "@/lib/prisma";
|
|
5
|
+
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
|
6
|
+
|
|
7
|
+
const RL_MAX = 10;
|
|
8
|
+
const RL_WINDOW_MS = 60 * 60_000;
|
|
9
|
+
|
|
10
|
+
export async function POST(req: NextRequest) {
|
|
11
|
+
const session = await auth();
|
|
12
|
+
if (!session?.user?.id) {
|
|
13
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ip = getClientIp(req);
|
|
17
|
+
const rl = rateLimit(`change-password:${ip}`, RL_MAX, RL_WINDOW_MS);
|
|
18
|
+
if (rl.limited) {
|
|
19
|
+
return NextResponse.json(
|
|
20
|
+
{ error: "Too many attempts. Try again later." },
|
|
21
|
+
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } }
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { currentPassword, newPassword } = await req.json();
|
|
26
|
+
|
|
27
|
+
if (!currentPassword || typeof currentPassword !== "string") {
|
|
28
|
+
return NextResponse.json({ error: "Current password required" }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
if (!newPassword || typeof newPassword !== "string" || newPassword.length < 8) {
|
|
31
|
+
return NextResponse.json({ error: "New password must be at least 8 characters" }, { status: 400 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const db = getCloudPrisma() ?? prisma;
|
|
35
|
+
|
|
36
|
+
const user = await db.user.findUnique({
|
|
37
|
+
where: { id: session.user.id },
|
|
38
|
+
select: { id: true, passwordHash: true },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!user || !user.passwordHash) {
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
{ error: "Password change is not available for this account." },
|
|
44
|
+
{ status: 400 }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
|
49
|
+
if (!valid) {
|
|
50
|
+
return NextResponse.json({ error: "Current password is incorrect" }, { status: 400 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const passwordHash = await bcrypt.hash(newPassword, 12);
|
|
54
|
+
|
|
55
|
+
await db.user.update({
|
|
56
|
+
where: { id: user.id },
|
|
57
|
+
data: { passwordHash },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return NextResponse.json({ ok: true });
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { prisma } from "@/lib/prisma";
|
|
3
|
+
|
|
4
|
+
export async function POST(req: NextRequest) {
|
|
5
|
+
const { email } = await req.json();
|
|
6
|
+
|
|
7
|
+
if (!email) {
|
|
8
|
+
return NextResponse.json({ error: "Email required" }, { status: 400 });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const user = await prisma.user.findUnique({
|
|
12
|
+
where: { email },
|
|
13
|
+
select: { twoFactorEnabled: true },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return NextResponse.json({
|
|
17
|
+
requires2FA: user?.twoFactorEnabled ?? false,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { prisma, getCloudPrisma } from "@/lib/prisma";
|
|
3
|
+
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
|
4
|
+
import { sendAuthEmail } from "@/lib/email";
|
|
5
|
+
|
|
6
|
+
const RL_MAX = 5;
|
|
7
|
+
const RL_WINDOW_MS = 60 * 60_000; // 5 attempts per IP per hour
|
|
8
|
+
|
|
9
|
+
export async function POST(req: NextRequest) {
|
|
10
|
+
const ip = getClientIp(req);
|
|
11
|
+
const rl = rateLimit(`forgot-password:${ip}`, RL_MAX, RL_WINDOW_MS);
|
|
12
|
+
if (rl.limited) {
|
|
13
|
+
return NextResponse.json(
|
|
14
|
+
{ error: "Too many attempts. Try again later." },
|
|
15
|
+
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } }
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { email } = await req.json();
|
|
20
|
+
if (!email || typeof email !== "string") {
|
|
21
|
+
return NextResponse.json({ error: "Email required" }, { status: 400 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Look up user in the same DB auth reads from (cloud if available, local otherwise).
|
|
25
|
+
const db = getCloudPrisma() ?? prisma;
|
|
26
|
+
const user = await db.user.findUnique({ where: { email: email.toLowerCase().trim() } });
|
|
27
|
+
|
|
28
|
+
// Always return 200 to prevent email enumeration.
|
|
29
|
+
if (!user || !user.passwordHash) {
|
|
30
|
+
return NextResponse.json({ ok: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Invalidate any existing unused tokens for this user.
|
|
34
|
+
await db.passwordResetToken.updateMany({
|
|
35
|
+
where: { userId: user.id, usedAt: null },
|
|
36
|
+
data: { usedAt: new Date() },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const record = await db.passwordResetToken.create({
|
|
40
|
+
data: {
|
|
41
|
+
userId: user.id,
|
|
42
|
+
expiresAt: new Date(Date.now() + 60 * 60_000), // 1 hour
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
|
47
|
+
const resetUrl = `${appUrl}/reset-password?token=${record.token}`;
|
|
48
|
+
|
|
49
|
+
// Send reset email via Brevo. If no email provider is configured the URL is
|
|
50
|
+
// returned in the response so admins/devs can copy it manually.
|
|
51
|
+
const sent = await sendAuthEmail(
|
|
52
|
+
email,
|
|
53
|
+
"Reset your Knotpad password",
|
|
54
|
+
`
|
|
55
|
+
<p>Hi${user.name ? ` ${user.name}` : ""},</p>
|
|
56
|
+
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
|
|
57
|
+
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
|
58
|
+
<p>If you didn't request this, you can ignore this email.</p>
|
|
59
|
+
`
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// When no email provider is configured, expose the URL for local/dev use.
|
|
63
|
+
if (!sent) return NextResponse.json({ ok: true, resetUrl });
|
|
64
|
+
return NextResponse.json({ ok: true });
|
|
65
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import bcrypt from "bcryptjs";
|
|
3
|
+
import { prisma, getCloudPrisma } from "@/lib/prisma";
|
|
4
|
+
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
|
5
|
+
|
|
6
|
+
const RL_MAX = 10;
|
|
7
|
+
const RL_WINDOW_MS = 60 * 60_000;
|
|
8
|
+
|
|
9
|
+
export async function POST(req: NextRequest) {
|
|
10
|
+
const ip = getClientIp(req);
|
|
11
|
+
const rl = rateLimit(`reset-password:${ip}`, RL_MAX, RL_WINDOW_MS);
|
|
12
|
+
if (rl.limited) {
|
|
13
|
+
return NextResponse.json(
|
|
14
|
+
{ error: "Too many attempts. Try again later." },
|
|
15
|
+
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } }
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { token, password } = await req.json();
|
|
20
|
+
if (!token || typeof token !== "string") {
|
|
21
|
+
return NextResponse.json({ error: "Token required" }, { status: 400 });
|
|
22
|
+
}
|
|
23
|
+
if (!password || typeof password !== "string" || password.length < 8) {
|
|
24
|
+
return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const db = getCloudPrisma() ?? prisma;
|
|
28
|
+
|
|
29
|
+
const record = await db.passwordResetToken.findUnique({
|
|
30
|
+
where: { token },
|
|
31
|
+
include: { user: { select: { id: true, email: true } } },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!record || record.usedAt || record.expiresAt < new Date()) {
|
|
35
|
+
return NextResponse.json({ error: "Invalid or expired token" }, { status: 400 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const passwordHash = await bcrypt.hash(password, 12);
|
|
39
|
+
|
|
40
|
+
await db.$transaction(async (tx) => {
|
|
41
|
+
await tx.user.update({
|
|
42
|
+
where: { id: record.userId },
|
|
43
|
+
data: { passwordHash },
|
|
44
|
+
});
|
|
45
|
+
await tx.passwordResetToken.update({
|
|
46
|
+
where: { id: record.id },
|
|
47
|
+
data: { usedAt: new Date() },
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return NextResponse.json({ ok: true });
|
|
52
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { prisma } from "@/lib/prisma";
|
|
3
|
+
import { createHmac } from "crypto";
|
|
4
|
+
import bcrypt from "bcryptjs";
|
|
5
|
+
|
|
6
|
+
function generateTOTP(secret: string, window = 0): string {
|
|
7
|
+
const counter = Math.floor(Date.now() / 1000 / 30) + window;
|
|
8
|
+
const counterBuffer = Buffer.alloc(8);
|
|
9
|
+
counterBuffer.writeBigUInt64BE(BigInt(counter), 0);
|
|
10
|
+
|
|
11
|
+
const secretBuffer = Buffer.from(secret, "base64url");
|
|
12
|
+
const hmac = createHmac("sha1", secretBuffer);
|
|
13
|
+
hmac.update(counterBuffer);
|
|
14
|
+
const hash = hmac.digest();
|
|
15
|
+
|
|
16
|
+
const offset = hash[hash.length - 1] & 0x0f;
|
|
17
|
+
const code =
|
|
18
|
+
((hash[offset] & 0x7f) << 24) |
|
|
19
|
+
((hash[offset + 1] & 0xff) << 16) |
|
|
20
|
+
((hash[offset + 2] & 0xff) << 8) |
|
|
21
|
+
(hash[offset + 3] & 0xff);
|
|
22
|
+
|
|
23
|
+
return (code % 1000000).toString().padStart(6, "0");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function verifyTOTP(secret: string, token: string): boolean {
|
|
27
|
+
for (let window = -1; window <= 1; window++) {
|
|
28
|
+
if (generateTOTP(secret, window) === token) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function POST(req: NextRequest) {
|
|
36
|
+
const { email, password, code } = await req.json();
|
|
37
|
+
|
|
38
|
+
if (!email || !password || !code) {
|
|
39
|
+
return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!/^\d{6}$/.test(code)) {
|
|
43
|
+
return NextResponse.json({ error: "Invalid 2FA code format" }, { status: 400 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const db = prisma;
|
|
47
|
+
const user = await db.user.findUnique({
|
|
48
|
+
where: { email },
|
|
49
|
+
select: {
|
|
50
|
+
id: true,
|
|
51
|
+
email: true,
|
|
52
|
+
name: true,
|
|
53
|
+
passwordHash: true,
|
|
54
|
+
twoFactorSecret: true,
|
|
55
|
+
twoFactorEnabled: true,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!user || !user.passwordHash) {
|
|
60
|
+
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Verify password first
|
|
64
|
+
const validPassword = await bcrypt.compare(password, user.passwordHash);
|
|
65
|
+
if (!validPassword) {
|
|
66
|
+
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check if 2FA is enabled
|
|
70
|
+
if (!user.twoFactorEnabled || !user.twoFactorSecret) {
|
|
71
|
+
return NextResponse.json({ error: "2FA not enabled" }, { status: 400 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Verify TOTP code
|
|
75
|
+
if (!verifyTOTP(user.twoFactorSecret, code)) {
|
|
76
|
+
return NextResponse.json({ error: "Invalid 2FA code" }, { status: 401 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Return user info for client-side session creation
|
|
80
|
+
return NextResponse.json({
|
|
81
|
+
success: true,
|
|
82
|
+
user: {
|
|
83
|
+
id: user.id,
|
|
84
|
+
email: user.email,
|
|
85
|
+
name: user.name ?? "",
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { auth } from "@/auth";
|
|
3
|
+
import { prisma } from "@/lib/prisma";
|
|
4
|
+
import { buildWorkspaceBackup } from "@/lib/backup/export-workspace-backup";
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
const session = await auth();
|
|
8
|
+
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
9
|
+
|
|
10
|
+
const member = await prisma.workspaceMember.findFirst({
|
|
11
|
+
where: { userId: session.user.id, revokedAt: null },
|
|
12
|
+
include: { workspace: true },
|
|
13
|
+
});
|
|
14
|
+
if (!member) return NextResponse.json({ error: "No workspace" }, { status: 404 });
|
|
15
|
+
|
|
16
|
+
const { filename, payload } = await buildWorkspaceBackup(
|
|
17
|
+
member.workspaceId,
|
|
18
|
+
member.workspace.name,
|
|
19
|
+
member.workspace.slug
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
return new NextResponse(JSON.stringify(payload, null, 2), {
|
|
23
|
+
headers: {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
26
|
+
"x-brief-filename": filename,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { auth } from "@/auth";
|
|
3
|
+
import { prisma } from "@/lib/prisma";
|
|
4
|
+
import { buildNotesZipExport } from "@/lib/backup/export-notes-zip";
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
const session = await auth();
|
|
8
|
+
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
9
|
+
|
|
10
|
+
const member = await prisma.workspaceMember.findFirst({
|
|
11
|
+
where: { userId: session.user.id, revokedAt: null },
|
|
12
|
+
include: { workspace: true },
|
|
13
|
+
});
|
|
14
|
+
if (!member) return NextResponse.json({ error: "No workspace" }, { status: 404 });
|
|
15
|
+
|
|
16
|
+
const { filename, bytes } = await buildNotesZipExport(member.workspaceId);
|
|
17
|
+
|
|
18
|
+
return new NextResponse(Buffer.from(bytes), {
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": "application/zip",
|
|
21
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
22
|
+
"x-brief-filename": filename,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { auth } from "@/auth";
|
|
3
|
+
import { prisma } from "@/lib/prisma";
|
|
4
|
+
import { validateBackupSettingsInput } from "@/lib/backup/backup-settings";
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
const session = await auth();
|
|
8
|
+
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
9
|
+
|
|
10
|
+
const member = await prisma.workspaceMember.findFirst({
|
|
11
|
+
where: {
|
|
12
|
+
userId: session.user.id,
|
|
13
|
+
role: { in: ["OWNER", "ADMIN"] },
|
|
14
|
+
revokedAt: null,
|
|
15
|
+
},
|
|
16
|
+
include: {
|
|
17
|
+
workspace: {
|
|
18
|
+
include: {
|
|
19
|
+
backupSettings: true,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
if (!member) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
25
|
+
|
|
26
|
+
return NextResponse.json({
|
|
27
|
+
workspace: {
|
|
28
|
+
id: member.workspaceId,
|
|
29
|
+
isCloud: member.workspace.isCloud,
|
|
30
|
+
isPro: member.workspace.isPro,
|
|
31
|
+
},
|
|
32
|
+
settings: member.workspace.backupSettings,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function PUT(req: NextRequest) {
|
|
37
|
+
const session = await auth();
|
|
38
|
+
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
39
|
+
|
|
40
|
+
const member = await prisma.workspaceMember.findFirst({
|
|
41
|
+
where: {
|
|
42
|
+
userId: session.user.id,
|
|
43
|
+
role: { in: ["OWNER", "ADMIN"] },
|
|
44
|
+
revokedAt: null,
|
|
45
|
+
},
|
|
46
|
+
include: { workspace: true },
|
|
47
|
+
});
|
|
48
|
+
if (!member) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
49
|
+
|
|
50
|
+
const body = await req.json().catch(() => null);
|
|
51
|
+
if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
|
52
|
+
|
|
53
|
+
const validation = validateBackupSettingsInput({
|
|
54
|
+
scheduleEnabled: Boolean(body.scheduleEnabled),
|
|
55
|
+
scheduleCadence: body.scheduleCadence,
|
|
56
|
+
destinationPath: typeof body.destinationPath === "string" ? body.destinationPath : "",
|
|
57
|
+
includeMarkdownZip: Boolean(body.includeMarkdownZip),
|
|
58
|
+
isCloudWorkspace: member.workspace.isCloud,
|
|
59
|
+
});
|
|
60
|
+
if (!validation.success) {
|
|
61
|
+
return NextResponse.json({ error: validation.error }, { status: 422 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const settings = await prisma.workspaceBackupSettings.upsert({
|
|
65
|
+
where: { workspaceId: member.workspaceId },
|
|
66
|
+
update: {
|
|
67
|
+
scheduleEnabled: Boolean(body.scheduleEnabled),
|
|
68
|
+
scheduleCadence: body.scheduleCadence,
|
|
69
|
+
destinationPath: body.destinationPath?.trim() ? body.destinationPath.trim() : null,
|
|
70
|
+
includeMarkdownZip: Boolean(body.includeMarkdownZip),
|
|
71
|
+
lastBackupAt: body.lastBackupAt ? new Date(body.lastBackupAt) : undefined,
|
|
72
|
+
lastBackupStatus:
|
|
73
|
+
typeof body.lastBackupStatus === "string" ? body.lastBackupStatus : undefined,
|
|
74
|
+
lastBackupError:
|
|
75
|
+
typeof body.lastBackupError === "string" ? body.lastBackupError : body.lastBackupError === null ? null : undefined,
|
|
76
|
+
},
|
|
77
|
+
create: {
|
|
78
|
+
workspaceId: member.workspaceId,
|
|
79
|
+
scheduleEnabled: Boolean(body.scheduleEnabled),
|
|
80
|
+
scheduleCadence: body.scheduleCadence,
|
|
81
|
+
destinationPath: body.destinationPath?.trim() ? body.destinationPath.trim() : null,
|
|
82
|
+
includeMarkdownZip: Boolean(body.includeMarkdownZip),
|
|
83
|
+
lastBackupAt: body.lastBackupAt ? new Date(body.lastBackupAt) : null,
|
|
84
|
+
lastBackupStatus:
|
|
85
|
+
typeof body.lastBackupStatus === "string" ? body.lastBackupStatus : "idle",
|
|
86
|
+
lastBackupError:
|
|
87
|
+
typeof body.lastBackupError === "string" ? body.lastBackupError : null,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return NextResponse.json({ settings });
|
|
92
|
+
}
|