@knotpad/app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- 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 +224 -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/electron/main.ts +251 -0
- package/electron/preload.ts +56 -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 +99 -0
- 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-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +4 -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
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* POST /api/account/claim
|
|
9
|
+
* Body: { name, email, password }
|
|
10
|
+
*
|
|
11
|
+
* Converts the current GUEST (local, no-password `*@local.brief`) into a real
|
|
12
|
+
* account WITHOUT orphaning their notes:
|
|
13
|
+
* 1. Attach real name/email/passwordHash to the existing local user (same id,
|
|
14
|
+
* same workspace → their notes stay put).
|
|
15
|
+
* 2. Mirror the identity (User + each Workspace + WorkspaceMember + that
|
|
16
|
+
* workspace's KanbanStatuses) into Neon BY ID, because identity is
|
|
17
|
+
* cloud-scoped (PrismaAdapter(cloud)) and `/api/billing/migrate` is keyed by
|
|
18
|
+
* workspaceId — both need the records to exist in cloud.
|
|
19
|
+
*
|
|
20
|
+
* After this, the client signs in with the new credentials and proceeds to
|
|
21
|
+
* billing (setup-intent → subscribe); the Stripe webhook flips the plan flags
|
|
22
|
+
* and triggers the data migration. This route does NOT touch Stripe or plan
|
|
23
|
+
* flags — it only establishes the real, cloud-resolvable identity.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const AUTH_MAX = 10;
|
|
27
|
+
const AUTH_WINDOW_MS = 60 * 60_000;
|
|
28
|
+
|
|
29
|
+
export async function POST(req: NextRequest) {
|
|
30
|
+
const ip = getClientIp(req);
|
|
31
|
+
const rl = rateLimit(`claim:${ip}`, AUTH_MAX, AUTH_WINDOW_MS);
|
|
32
|
+
if (rl.limited) {
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ error: "Too many attempts. Try again later." },
|
|
35
|
+
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const session = await auth();
|
|
40
|
+
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
41
|
+
|
|
42
|
+
const cloud = getCloudPrisma();
|
|
43
|
+
if (!cloud) {
|
|
44
|
+
return NextResponse.json(
|
|
45
|
+
{ error: "Cloud is not configured — upgrading requires a cloud connection." },
|
|
46
|
+
{ status: 503 }
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The signed-in user must be an unclaimed guest.
|
|
51
|
+
const me = await prisma.user.findUnique({ where: { id: session.user.id } });
|
|
52
|
+
if (!me || !me.email?.endsWith("@local.brief") || me.passwordHash) {
|
|
53
|
+
return NextResponse.json(
|
|
54
|
+
{ error: "This account can't be upgraded (already a real account)." },
|
|
55
|
+
{ status: 400 }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { name, email, password } = await req.json().catch(() => ({}));
|
|
60
|
+
if (!name || !email || !password) {
|
|
61
|
+
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
|
62
|
+
}
|
|
63
|
+
if (typeof password !== "string" || password.length < 8) {
|
|
64
|
+
return NextResponse.json({ error: "Password must be at least 8 characters" }, { status: 400 });
|
|
65
|
+
}
|
|
66
|
+
if (typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
67
|
+
return NextResponse.json({ error: "Invalid email address" }, { status: 400 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Email must be free in BOTH databases (auth checks cloud-first, but the local
|
|
71
|
+
// DB also holds users in fully-local mode).
|
|
72
|
+
for (const db of [prisma, cloud]) {
|
|
73
|
+
const taken = await db.user.findUnique({ where: { email } });
|
|
74
|
+
if (taken) return NextResponse.json({ error: "Email already in use" }, { status: 409 });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const passwordHash = await bcrypt.hash(password, 12);
|
|
78
|
+
|
|
79
|
+
// 1) Promote the local user in place (keeps id + workspace + notes).
|
|
80
|
+
await prisma.user.update({
|
|
81
|
+
where: { id: me.id },
|
|
82
|
+
data: { name, email, passwordHash },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// 2) Mirror identity into Neon by id so cloud login + migrate resolve.
|
|
86
|
+
const memberships = await prisma.workspaceMember.findMany({
|
|
87
|
+
where: { userId: me.id, revokedAt: null },
|
|
88
|
+
include: { workspace: true },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await cloud.user.upsert({
|
|
92
|
+
where: { id: me.id },
|
|
93
|
+
create: { id: me.id, name, email, passwordHash, role: me.role, createdAt: me.createdAt },
|
|
94
|
+
update: { name, email, passwordHash },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
for (const m of memberships) {
|
|
98
|
+
const ws = m.workspace;
|
|
99
|
+
await cloud.workspace.upsert({
|
|
100
|
+
where: { id: ws.id },
|
|
101
|
+
create: {
|
|
102
|
+
id: ws.id,
|
|
103
|
+
name: ws.name,
|
|
104
|
+
slug: ws.slug,
|
|
105
|
+
type: ws.type,
|
|
106
|
+
planType: ws.planType,
|
|
107
|
+
licenseType: ws.licenseType,
|
|
108
|
+
isCloud: ws.isCloud,
|
|
109
|
+
isPro: ws.isPro,
|
|
110
|
+
seatCount: ws.seatCount,
|
|
111
|
+
encryptionSalt: ws.encryptionSalt,
|
|
112
|
+
createdAt: ws.createdAt,
|
|
113
|
+
},
|
|
114
|
+
update: { name: ws.name, slug: ws.slug, encryptionSalt: ws.encryptionSalt },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await cloud.workspaceMember.upsert({
|
|
118
|
+
where: { userId_workspaceId: { userId: me.id, workspaceId: ws.id } },
|
|
119
|
+
create: { id: m.id, userId: me.id, workspaceId: ws.id, role: m.role, joinedAt: m.joinedAt },
|
|
120
|
+
update: { role: m.role },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Mirror the workspace's kanban statuses so the board looks identical in cloud.
|
|
124
|
+
const statuses = await prisma.kanbanStatus.findMany({ where: { workspaceId: ws.id } });
|
|
125
|
+
for (const s of statuses) {
|
|
126
|
+
await cloud.kanbanStatus.upsert({
|
|
127
|
+
where: { workspaceId_key: { workspaceId: ws.id, key: s.key } },
|
|
128
|
+
create: { workspaceId: ws.id, key: s.key, label: s.label, color: s.color, order: s.order, isVisible: s.isVisible },
|
|
129
|
+
update: { label: s.label, color: s.color, order: s.order, isVisible: s.isVisible },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return NextResponse.json({ ok: true, email });
|
|
135
|
+
}
|
|
@@ -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
|
+
}
|