@knotpad/app 0.1.4 → 0.1.6
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 +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/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 +49 -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-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
- package/brief.js +0 -311
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
|
|
6
|
+
export default function ForgotPasswordPage() {
|
|
7
|
+
const [email, setEmail] = useState("");
|
|
8
|
+
const [loading, setLoading] = useState(false);
|
|
9
|
+
const [sent, setSent] = useState(false);
|
|
10
|
+
const [resetUrl, setResetUrl] = useState(""); // dev/local mode only
|
|
11
|
+
const [error, setError] = useState("");
|
|
12
|
+
|
|
13
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
setLoading(true);
|
|
16
|
+
setError("");
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch("/api/auth/forgot-password", {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "Content-Type": "application/json" },
|
|
21
|
+
body: JSON.stringify({ email }),
|
|
22
|
+
});
|
|
23
|
+
if (res.ok) {
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
setSent(true);
|
|
26
|
+
// In local/dev mode the API returns the URL directly (no email configured).
|
|
27
|
+
if (data.resetUrl) setResetUrl(data.resetUrl);
|
|
28
|
+
} else {
|
|
29
|
+
const d = await res.json();
|
|
30
|
+
setError(d.error ?? "Something went wrong");
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
setError("Something went wrong. Please try again.");
|
|
34
|
+
} finally {
|
|
35
|
+
setLoading(false);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (sent) {
|
|
40
|
+
return (
|
|
41
|
+
<div className="w-full max-w-sm space-y-4 text-center">
|
|
42
|
+
<h1 className="text-2xl font-semibold tracking-tight">Check your email</h1>
|
|
43
|
+
<p className="text-sm text-zinc-400">
|
|
44
|
+
If an account exists for <span className="text-zinc-300">{email}</span>, we've sent a
|
|
45
|
+
reset link. It expires in 1 hour.
|
|
46
|
+
</p>
|
|
47
|
+
{resetUrl && (
|
|
48
|
+
<div className="rounded-md border border-amber-800/40 bg-amber-950/20 px-4 py-3 text-left space-y-1">
|
|
49
|
+
<p className="text-xs text-amber-400 font-medium">Dev mode — no email configured</p>
|
|
50
|
+
<p className="text-xs text-zinc-400 break-all">
|
|
51
|
+
<a href={resetUrl} className="underline hover:text-zinc-200">{resetUrl}</a>
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
<Link href="/login" className="block text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
|
56
|
+
Back to sign in
|
|
57
|
+
</Link>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="w-full max-w-sm space-y-6">
|
|
64
|
+
<div>
|
|
65
|
+
<h1 className="text-2xl font-semibold tracking-tight">Forgot password?</h1>
|
|
66
|
+
<p className="mt-1 text-sm text-zinc-400">
|
|
67
|
+
Enter your email and we'll send you a reset link.
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
72
|
+
<div className="space-y-1">
|
|
73
|
+
<label htmlFor="email" className="text-sm font-medium text-zinc-300">
|
|
74
|
+
Email
|
|
75
|
+
</label>
|
|
76
|
+
<input
|
|
77
|
+
id="email"
|
|
78
|
+
type="email"
|
|
79
|
+
required
|
|
80
|
+
value={email}
|
|
81
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
82
|
+
autoComplete="email"
|
|
83
|
+
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-zinc-500 focus:outline-none"
|
|
84
|
+
placeholder="you@example.com"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
89
|
+
|
|
90
|
+
<button
|
|
91
|
+
type="submit"
|
|
92
|
+
disabled={loading}
|
|
93
|
+
className="w-full rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
|
|
94
|
+
>
|
|
95
|
+
{loading ? "Sending…" : "Send reset link"}
|
|
96
|
+
</button>
|
|
97
|
+
</form>
|
|
98
|
+
|
|
99
|
+
<p className="text-center text-sm text-zinc-500">
|
|
100
|
+
<Link href="/login" className="text-zinc-300 underline underline-offset-2 hover:text-white">
|
|
101
|
+
Back to sign in
|
|
102
|
+
</Link>
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { signIn } from "next-auth/react";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
|
|
7
|
+
export default function GuestPage() {
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
const [status, setStatus] = useState<"creating" | "error">("creating");
|
|
10
|
+
// Guard against React Strict Mode double-invocation — only create one guest account.
|
|
11
|
+
const started = useRef(false);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (started.current) return;
|
|
15
|
+
started.current = true;
|
|
16
|
+
|
|
17
|
+
async function createGuest() {
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch("/api/guest", { method: "POST" });
|
|
20
|
+
if (!res.ok) throw new Error("Failed to create guest");
|
|
21
|
+
const { email } = await res.json();
|
|
22
|
+
|
|
23
|
+
const result = await signIn("credentials", {
|
|
24
|
+
email,
|
|
25
|
+
password: "", // guest: no password
|
|
26
|
+
redirect: false,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (result?.error) throw new Error(result.error);
|
|
30
|
+
router.push("/notes");
|
|
31
|
+
router.refresh();
|
|
32
|
+
} catch {
|
|
33
|
+
setStatus("error");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
createGuest();
|
|
37
|
+
}, [router]);
|
|
38
|
+
|
|
39
|
+
if (status === "error") {
|
|
40
|
+
return (
|
|
41
|
+
<div className="text-center space-y-3">
|
|
42
|
+
<p className="text-zinc-300">Something went wrong starting guest mode.</p>
|
|
43
|
+
<a href="/login" className="text-sm text-zinc-500 underline">
|
|
44
|
+
Back to sign in
|
|
45
|
+
</a>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex flex-col items-center gap-3 text-center">
|
|
52
|
+
<div className="h-6 w-6 rounded-full border-2 border-zinc-600 border-t-zinc-300 animate-spin" />
|
|
53
|
+
<p className="text-sm text-zinc-500">Setting up your local workspace…</p>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
export const metadata: Metadata = {
|
|
4
|
+
robots: "noindex, nofollow",
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex min-h-full items-center justify-center">
|
|
10
|
+
{children}
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { LoginForm } from "@/components/auth/login-form";
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: "Sign In",
|
|
6
|
+
robots: "noindex, nofollow",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Server component so it can read the runtime IS_CLOUD flag (set per-runtime by
|
|
10
|
+
// the desktop/NPX launcher vs the cloud deploy) and pass it to the client form.
|
|
11
|
+
export default function LoginPage() {
|
|
12
|
+
const isCloud = process.env.IS_CLOUD === "true";
|
|
13
|
+
return <LoginForm isCloud={isCloud} />;
|
|
14
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, Suspense } from "react";
|
|
4
|
+
import { signIn } from "next-auth/react";
|
|
5
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
6
|
+
import Link from "next/link";
|
|
7
|
+
import { AppLogo } from "@/components/branding/app-logo";
|
|
8
|
+
import { PasswordInput } from "@/components/auth/password-input";
|
|
9
|
+
import { PasswordChecklist } from "@/components/auth/password-checklist";
|
|
10
|
+
|
|
11
|
+
export default function RegisterPage() {
|
|
12
|
+
return (
|
|
13
|
+
<Suspense fallback={<RegisterSkeleton />}>
|
|
14
|
+
<RegisterForm />
|
|
15
|
+
</Suspense>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function RegisterSkeleton() {
|
|
20
|
+
return (
|
|
21
|
+
<div className="w-full max-w-sm space-y-6">
|
|
22
|
+
<div className="h-8 w-48 animate-pulse rounded bg-zinc-800" />
|
|
23
|
+
<div className="h-4 w-64 animate-pulse rounded bg-zinc-800" />
|
|
24
|
+
<div className="space-y-4 pt-4">
|
|
25
|
+
<div className="h-10 animate-pulse rounded bg-zinc-800" />
|
|
26
|
+
<div className="h-10 animate-pulse rounded bg-zinc-800" />
|
|
27
|
+
<div className="h-10 animate-pulse rounded bg-zinc-800" />
|
|
28
|
+
<div className="h-10 animate-pulse rounded bg-zinc-800" />
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function RegisterForm() {
|
|
35
|
+
const router = useRouter();
|
|
36
|
+
const searchParams = useSearchParams();
|
|
37
|
+
const [error, setError] = useState("");
|
|
38
|
+
const [loading, setLoading] = useState(false);
|
|
39
|
+
const [password, setPassword] = useState("");
|
|
40
|
+
const [confirm, setConfirm] = useState("");
|
|
41
|
+
|
|
42
|
+
const rawNext = searchParams.get("next") ?? "";
|
|
43
|
+
const redirectTo =
|
|
44
|
+
rawNext.startsWith("/") && !rawNext.startsWith("//") ? rawNext : "/notes";
|
|
45
|
+
|
|
46
|
+
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
|
|
49
|
+
if (password !== confirm) {
|
|
50
|
+
setError("Passwords don't match");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setLoading(true);
|
|
55
|
+
setError("");
|
|
56
|
+
|
|
57
|
+
const form = e.currentTarget;
|
|
58
|
+
const name = (form.elements.namedItem("name") as HTMLInputElement).value;
|
|
59
|
+
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch("/api/register", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ name, email, password }),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
setError(data.error ?? "Registration failed");
|
|
71
|
+
setLoading(false);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = await signIn("credentials", { email, password, redirect: false });
|
|
76
|
+
if (result?.error) {
|
|
77
|
+
setError("Account created but sign-in failed. Please sign in manually.");
|
|
78
|
+
setLoading(false);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
router.push(redirectTo);
|
|
83
|
+
router.refresh();
|
|
84
|
+
} catch {
|
|
85
|
+
setError("Something went wrong. Please try again.");
|
|
86
|
+
setLoading(false);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="w-full max-w-sm space-y-6">
|
|
92
|
+
<div className="flex justify-end">
|
|
93
|
+
<Link href="/" className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
|
94
|
+
back to home
|
|
95
|
+
</Link>
|
|
96
|
+
</div>
|
|
97
|
+
<div>
|
|
98
|
+
<AppLogo className="mb-4 h-8 w-auto" />
|
|
99
|
+
<h1 className="text-2xl font-semibold tracking-tight">Create your account</h1>
|
|
100
|
+
<p className="mt-1 text-sm text-zinc-400">Start writing. Tasks will follow.</p>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
104
|
+
<div className="space-y-1">
|
|
105
|
+
<label htmlFor="name" className="text-sm font-medium text-zinc-300">
|
|
106
|
+
Name
|
|
107
|
+
</label>
|
|
108
|
+
<input
|
|
109
|
+
id="name"
|
|
110
|
+
name="name"
|
|
111
|
+
type="text"
|
|
112
|
+
required
|
|
113
|
+
autoComplete="name"
|
|
114
|
+
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-zinc-500 focus:outline-none"
|
|
115
|
+
placeholder="Your name"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="space-y-1">
|
|
120
|
+
<label htmlFor="email" className="text-sm font-medium text-zinc-300">
|
|
121
|
+
Email
|
|
122
|
+
</label>
|
|
123
|
+
<input
|
|
124
|
+
id="email"
|
|
125
|
+
name="email"
|
|
126
|
+
type="email"
|
|
127
|
+
required
|
|
128
|
+
autoComplete="email"
|
|
129
|
+
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-zinc-500 focus:outline-none"
|
|
130
|
+
placeholder="you@example.com"
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div className="space-y-1">
|
|
135
|
+
<label htmlFor="password" className="text-sm font-medium text-zinc-300">
|
|
136
|
+
Password
|
|
137
|
+
</label>
|
|
138
|
+
<PasswordInput
|
|
139
|
+
id="password"
|
|
140
|
+
name="password"
|
|
141
|
+
required
|
|
142
|
+
minLength={8}
|
|
143
|
+
autoComplete="new-password"
|
|
144
|
+
value={password}
|
|
145
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
146
|
+
placeholder="••••••••"
|
|
147
|
+
/>
|
|
148
|
+
{password.length > 0 && (
|
|
149
|
+
<div className="pt-2">
|
|
150
|
+
<PasswordChecklist password={password} />
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div className="space-y-1">
|
|
156
|
+
<label htmlFor="confirm" className="text-sm font-medium text-zinc-300">
|
|
157
|
+
Confirm password
|
|
158
|
+
</label>
|
|
159
|
+
<PasswordInput
|
|
160
|
+
id="confirm"
|
|
161
|
+
name="confirm"
|
|
162
|
+
required
|
|
163
|
+
minLength={8}
|
|
164
|
+
autoComplete="new-password"
|
|
165
|
+
value={confirm}
|
|
166
|
+
onChange={(e) => setConfirm(e.target.value)}
|
|
167
|
+
placeholder="••••••••"
|
|
168
|
+
/>
|
|
169
|
+
{confirm.length > 0 && password !== confirm && (
|
|
170
|
+
<p className="text-xs text-red-400 pt-1">Passwords don't match</p>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
175
|
+
|
|
176
|
+
<button
|
|
177
|
+
type="submit"
|
|
178
|
+
disabled={loading}
|
|
179
|
+
className="w-full rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
|
|
180
|
+
>
|
|
181
|
+
{loading ? "Creating account…" : "Create account"}
|
|
182
|
+
</button>
|
|
183
|
+
</form>
|
|
184
|
+
|
|
185
|
+
<p className="text-center text-sm text-zinc-500">
|
|
186
|
+
Already have an account?{" "}
|
|
187
|
+
<Link href="/login" className="text-zinc-300 underline underline-offset-2 hover:text-white">
|
|
188
|
+
Sign in
|
|
189
|
+
</Link>
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, Suspense } from "react";
|
|
4
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { PasswordInput } from "@/components/auth/password-input";
|
|
7
|
+
import { PasswordChecklist } from "@/components/auth/password-checklist";
|
|
8
|
+
|
|
9
|
+
export default function ResetPasswordPage() {
|
|
10
|
+
return (
|
|
11
|
+
<Suspense fallback={<ResetSkeleton />}>
|
|
12
|
+
<ResetForm />
|
|
13
|
+
</Suspense>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ResetSkeleton() {
|
|
18
|
+
return (
|
|
19
|
+
<div className="w-full max-w-sm space-y-6">
|
|
20
|
+
<div className="h-8 w-48 animate-pulse rounded bg-zinc-800" />
|
|
21
|
+
<div className="h-4 w-64 animate-pulse rounded bg-zinc-800" />
|
|
22
|
+
<div className="space-y-4 pt-4">
|
|
23
|
+
<div className="h-10 animate-pulse rounded bg-zinc-800" />
|
|
24
|
+
<div className="h-10 animate-pulse rounded bg-zinc-800" />
|
|
25
|
+
<div className="h-10 animate-pulse rounded bg-zinc-800" />
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ResetForm() {
|
|
32
|
+
const router = useRouter();
|
|
33
|
+
const searchParams = useSearchParams();
|
|
34
|
+
const token = searchParams.get("token") ?? "";
|
|
35
|
+
|
|
36
|
+
const [password, setPassword] = useState("");
|
|
37
|
+
const [confirm, setConfirm] = useState("");
|
|
38
|
+
const [loading, setLoading] = useState(false);
|
|
39
|
+
const [error, setError] = useState("");
|
|
40
|
+
|
|
41
|
+
if (!token) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="w-full max-w-sm text-center space-y-3">
|
|
44
|
+
<h1 className="text-2xl font-semibold tracking-tight">Invalid link</h1>
|
|
45
|
+
<p className="text-sm text-zinc-400">This reset link is missing a token.</p>
|
|
46
|
+
<Link href="/forgot-password" className="text-sm text-zinc-300 underline">
|
|
47
|
+
Request a new one
|
|
48
|
+
</Link>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
if (password !== confirm) {
|
|
56
|
+
setError("Passwords don't match");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setLoading(true);
|
|
60
|
+
setError("");
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch("/api/auth/reset-password", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ token, password }),
|
|
66
|
+
});
|
|
67
|
+
if (res.ok) {
|
|
68
|
+
router.push("/login?reset=1");
|
|
69
|
+
} else {
|
|
70
|
+
const d = await res.json();
|
|
71
|
+
setError(d.error ?? "Reset failed. The link may have expired.");
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
setError("Something went wrong. Please try again.");
|
|
75
|
+
} finally {
|
|
76
|
+
setLoading(false);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="w-full max-w-sm space-y-6">
|
|
82
|
+
<div>
|
|
83
|
+
<h1 className="text-2xl font-semibold tracking-tight">Set new password</h1>
|
|
84
|
+
<p className="mt-1 text-sm text-zinc-400">Choose a password with at least 8 characters.</p>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
88
|
+
<div className="space-y-1">
|
|
89
|
+
<label htmlFor="password" className="text-sm font-medium text-zinc-300">
|
|
90
|
+
New password
|
|
91
|
+
</label>
|
|
92
|
+
<PasswordInput
|
|
93
|
+
id="password"
|
|
94
|
+
required
|
|
95
|
+
minLength={8}
|
|
96
|
+
value={password}
|
|
97
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
98
|
+
autoComplete="new-password"
|
|
99
|
+
placeholder="••••••••"
|
|
100
|
+
/>
|
|
101
|
+
{password.length > 0 && (
|
|
102
|
+
<div className="pt-2">
|
|
103
|
+
<PasswordChecklist password={password} />
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div className="space-y-1">
|
|
109
|
+
<label htmlFor="confirm" className="text-sm font-medium text-zinc-300">
|
|
110
|
+
Confirm password
|
|
111
|
+
</label>
|
|
112
|
+
<PasswordInput
|
|
113
|
+
id="confirm"
|
|
114
|
+
required
|
|
115
|
+
minLength={8}
|
|
116
|
+
value={confirm}
|
|
117
|
+
onChange={(e) => setConfirm(e.target.value)}
|
|
118
|
+
autoComplete="new-password"
|
|
119
|
+
placeholder="••••••••"
|
|
120
|
+
/>
|
|
121
|
+
{confirm.length > 0 && password !== confirm && (
|
|
122
|
+
<p className="text-xs text-red-400 pt-1">Passwords don't match</p>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
127
|
+
|
|
128
|
+
<button
|
|
129
|
+
type="submit"
|
|
130
|
+
disabled={loading}
|
|
131
|
+
className="w-full rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
|
|
132
|
+
>
|
|
133
|
+
{loading ? "Saving…" : "Set new password"}
|
|
134
|
+
</button>
|
|
135
|
+
</form>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -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
|
+
}
|