@knotpad/app 0.1.5 → 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,302 @@
|
|
|
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 { TwoFactorInput } from "@/components/auth/two-factor-input";
|
|
10
|
+
import { Shield, Loader2 } from "lucide-react";
|
|
11
|
+
|
|
12
|
+
export function LoginSkeleton() {
|
|
13
|
+
return (
|
|
14
|
+
<div className="w-full max-w-sm space-y-6">
|
|
15
|
+
<div className="h-8 w-48 animate-pulse rounded bg-zinc-800" />
|
|
16
|
+
<div className="h-4 w-64 animate-pulse rounded bg-zinc-800" />
|
|
17
|
+
<div className="space-y-4 pt-4">
|
|
18
|
+
<div className="h-10 animate-pulse rounded bg-zinc-800" />
|
|
19
|
+
<div className="h-10 animate-pulse rounded bg-zinc-800" />
|
|
20
|
+
<div className="h-10 animate-pulse rounded bg-zinc-800" />
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// `isCloud` reflects the runtime: desktop/NPX (false) offers a local, no-account
|
|
27
|
+
// "Start writing" path; the browser/cloud build (true) has no local DB, so it
|
|
28
|
+
// only offers Log in / Sign up.
|
|
29
|
+
export function LoginForm({ isCloud }: { isCloud: boolean }) {
|
|
30
|
+
return (
|
|
31
|
+
<Suspense fallback={<LoginSkeleton />}>
|
|
32
|
+
<LoginFormInner isCloud={isCloud} />
|
|
33
|
+
</Suspense>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function LoginFormInner({ isCloud }: { isCloud: boolean }) {
|
|
38
|
+
const router = useRouter();
|
|
39
|
+
const searchParams = useSearchParams();
|
|
40
|
+
const [error, setError] = useState("");
|
|
41
|
+
const [loading, setLoading] = useState(false);
|
|
42
|
+
const [needs2FA, setNeeds2FA] = useState(false);
|
|
43
|
+
const [twoFactorCode, setTwoFactorCode] = useState("");
|
|
44
|
+
const [credentials, setCredentials] = useState({ email: "", password: "" });
|
|
45
|
+
|
|
46
|
+
// Validate that ?next= is a same-origin relative path to prevent open redirect.
|
|
47
|
+
const rawNext = searchParams.get("next") ?? "";
|
|
48
|
+
const redirectTo =
|
|
49
|
+
rawNext.startsWith("/") && !rawNext.startsWith("//") ? rawNext : "/notes";
|
|
50
|
+
const justReset = searchParams.get("reset") === "1";
|
|
51
|
+
|
|
52
|
+
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
setLoading(true);
|
|
55
|
+
setError("");
|
|
56
|
+
|
|
57
|
+
const form = e.currentTarget;
|
|
58
|
+
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
|
|
59
|
+
const password = (form.elements.namedItem("password") as HTMLInputElement).value;
|
|
60
|
+
|
|
61
|
+
setCredentials({ email, password });
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// Check if 2FA is required first
|
|
65
|
+
const checkRes = await fetch("/api/auth/check-2fa", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "Content-Type": "application/json" },
|
|
68
|
+
body: JSON.stringify({ email }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const checkData = await checkRes.json();
|
|
72
|
+
|
|
73
|
+
if (checkData.requires2FA) {
|
|
74
|
+
setNeeds2FA(true);
|
|
75
|
+
setLoading(false);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// No 2FA required, proceed with normal sign in
|
|
80
|
+
const result = await signIn("credentials", {
|
|
81
|
+
email,
|
|
82
|
+
password,
|
|
83
|
+
redirect: false,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (result?.error) {
|
|
87
|
+
setError("Invalid email or password");
|
|
88
|
+
setLoading(false);
|
|
89
|
+
} else {
|
|
90
|
+
router.push(redirectTo);
|
|
91
|
+
router.refresh();
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
setError("Something went wrong. Please try again.");
|
|
95
|
+
setLoading(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function handle2FASubmit(e: React.FormEvent) {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
setLoading(true);
|
|
102
|
+
setError("");
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// Verify 2FA code
|
|
106
|
+
const res = await fetch("/api/auth/verify-2fa", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
email: credentials.email,
|
|
111
|
+
password: credentials.password,
|
|
112
|
+
code: twoFactorCode,
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const data = await res.json();
|
|
117
|
+
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
setError(data.error || "Invalid 2FA code");
|
|
120
|
+
setLoading(false);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 2FA verified, now sign in
|
|
125
|
+
const result = await signIn("credentials", {
|
|
126
|
+
email: credentials.email,
|
|
127
|
+
password: credentials.password,
|
|
128
|
+
redirect: false,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (result?.error) {
|
|
132
|
+
setError("Authentication failed");
|
|
133
|
+
setLoading(false);
|
|
134
|
+
} else {
|
|
135
|
+
router.push(redirectTo);
|
|
136
|
+
router.refresh();
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
setError("Something went wrong. Please try again.");
|
|
140
|
+
setLoading(false);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="w-full max-w-sm space-y-6">
|
|
146
|
+
<div className="flex justify-end">
|
|
147
|
+
<Link href="/" className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
|
148
|
+
back to home
|
|
149
|
+
</Link>
|
|
150
|
+
</div>
|
|
151
|
+
<div>
|
|
152
|
+
<AppLogo className="mb-4 h-8 w-auto" />
|
|
153
|
+
<h1 className="text-2xl font-semibold tracking-tight">Welcome to Knotpad</h1>
|
|
154
|
+
<p className="mt-1 text-sm text-zinc-400">
|
|
155
|
+
{isCloud
|
|
156
|
+
? "Sign in, or create an account to get started."
|
|
157
|
+
: "Start writing instantly — or sign in to your account."}
|
|
158
|
+
</p>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{/* Desktop/local: the primary, no-account path. */}
|
|
162
|
+
{!isCloud && (
|
|
163
|
+
<div className="space-y-2">
|
|
164
|
+
<Link
|
|
165
|
+
href="/guest"
|
|
166
|
+
className="block w-full rounded-md bg-zinc-100 px-4 py-2.5 text-center text-sm font-medium text-zinc-900 hover:bg-zinc-200 transition-colors"
|
|
167
|
+
>
|
|
168
|
+
Start writing
|
|
169
|
+
</Link>
|
|
170
|
+
<p className="text-center text-xs text-zinc-600">
|
|
171
|
+
No account needed. Your notes stay on this device.
|
|
172
|
+
</p>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{!isCloud && (
|
|
177
|
+
<div className="relative">
|
|
178
|
+
<div className="absolute inset-0 flex items-center">
|
|
179
|
+
<div className="w-full border-t border-zinc-800" />
|
|
180
|
+
</div>
|
|
181
|
+
<div className="relative flex justify-center text-xs">
|
|
182
|
+
<span className="bg-zinc-950 px-3 text-zinc-600">or sign in</span>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{justReset && (
|
|
188
|
+
<div className="rounded-md border border-emerald-800/40 bg-emerald-950/30 px-4 py-3">
|
|
189
|
+
<p className="text-sm text-emerald-300">Password updated. Sign in with your new password.</p>
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{needs2FA ? (
|
|
194
|
+
<form onSubmit={handle2FASubmit} className="space-y-4">
|
|
195
|
+
<div className="flex items-center gap-2 text-zinc-300">
|
|
196
|
+
<Shield size={18} />
|
|
197
|
+
<h2 className="text-sm font-medium">Two-Factor Authentication</h2>
|
|
198
|
+
</div>
|
|
199
|
+
<p className="text-xs text-zinc-400">
|
|
200
|
+
Enter the 6-digit code from your authenticator app.
|
|
201
|
+
</p>
|
|
202
|
+
|
|
203
|
+
<div className="space-y-1">
|
|
204
|
+
<label className="text-sm font-medium text-zinc-300">
|
|
205
|
+
2FA Code
|
|
206
|
+
</label>
|
|
207
|
+
<TwoFactorInput
|
|
208
|
+
value={twoFactorCode}
|
|
209
|
+
onChange={setTwoFactorCode}
|
|
210
|
+
length={6}
|
|
211
|
+
disabled={loading}
|
|
212
|
+
autoFocus
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
217
|
+
|
|
218
|
+
<div className="flex gap-2">
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={() => {
|
|
222
|
+
setNeeds2FA(false);
|
|
223
|
+
setTwoFactorCode("");
|
|
224
|
+
setError("");
|
|
225
|
+
}}
|
|
226
|
+
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-100 hover:bg-zinc-800"
|
|
227
|
+
>
|
|
228
|
+
Back
|
|
229
|
+
</button>
|
|
230
|
+
<button
|
|
231
|
+
type="submit"
|
|
232
|
+
disabled={loading || twoFactorCode.length !== 6}
|
|
233
|
+
className="flex-1 rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
|
|
234
|
+
>
|
|
235
|
+
{loading ? (
|
|
236
|
+
<span className="flex items-center justify-center gap-1">
|
|
237
|
+
<Loader2 size={14} className="animate-spin" />
|
|
238
|
+
Verifying…
|
|
239
|
+
</span>
|
|
240
|
+
) : (
|
|
241
|
+
"Verify"
|
|
242
|
+
)}
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
</form>
|
|
246
|
+
) : (
|
|
247
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
248
|
+
<div className="space-y-1">
|
|
249
|
+
<label htmlFor="email" className="text-sm font-medium text-zinc-300">
|
|
250
|
+
Email
|
|
251
|
+
</label>
|
|
252
|
+
<input
|
|
253
|
+
id="email"
|
|
254
|
+
name="email"
|
|
255
|
+
type="email"
|
|
256
|
+
required
|
|
257
|
+
autoComplete="email"
|
|
258
|
+
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"
|
|
259
|
+
placeholder="you@example.com"
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div className="space-y-1">
|
|
264
|
+
<label htmlFor="password" className="text-sm font-medium text-zinc-300">
|
|
265
|
+
Password
|
|
266
|
+
</label>
|
|
267
|
+
<PasswordInput
|
|
268
|
+
id="password"
|
|
269
|
+
name="password"
|
|
270
|
+
required
|
|
271
|
+
autoComplete="current-password"
|
|
272
|
+
placeholder="••••••••"
|
|
273
|
+
/>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
277
|
+
|
|
278
|
+
<div className="flex justify-end">
|
|
279
|
+
<Link href="/forgot-password" className="text-xs text-zinc-600 hover:text-zinc-400 transition-colors">
|
|
280
|
+
Forgot password?
|
|
281
|
+
</Link>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<button
|
|
285
|
+
type="submit"
|
|
286
|
+
disabled={loading}
|
|
287
|
+
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
|
288
|
+
>
|
|
289
|
+
{loading ? "Signing in…" : "Sign in"}
|
|
290
|
+
</button>
|
|
291
|
+
</form>
|
|
292
|
+
)}
|
|
293
|
+
|
|
294
|
+
<p className="text-center text-sm text-zinc-500">
|
|
295
|
+
{isCloud ? "Want cloud sync and Pro? " : "Need cloud sync across devices? "}
|
|
296
|
+
<Link href="/register" className="text-zinc-300 underline underline-offset-2 hover:text-white">
|
|
297
|
+
Sign up
|
|
298
|
+
</Link>
|
|
299
|
+
</p>
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
const RULES: { label: string; test: (pw: string) => boolean }[] = [
|
|
6
|
+
{ label: "At least 8 characters", test: (pw) => pw.length >= 8 },
|
|
7
|
+
{ label: "One uppercase letter", test: (pw) => /[A-Z]/.test(pw) },
|
|
8
|
+
{ label: "One lowercase letter", test: (pw) => /[a-z]/.test(pw) },
|
|
9
|
+
{ label: "One number", test: (pw) => /[0-9]/.test(pw) },
|
|
10
|
+
{ label: "One symbol (e.g. !?#$%)", test: (pw) => /[^A-Za-z0-9]/.test(pw) },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function PasswordChecklist({ password }: { password: string }) {
|
|
14
|
+
return (
|
|
15
|
+
<ul className="space-y-1">
|
|
16
|
+
{RULES.map((rule) => {
|
|
17
|
+
const met = rule.test(password);
|
|
18
|
+
return (
|
|
19
|
+
<li key={rule.label} className="flex items-center gap-1.5 text-xs">
|
|
20
|
+
{met ? (
|
|
21
|
+
<Check className="h-3.5 w-3.5 text-emerald-500" />
|
|
22
|
+
) : (
|
|
23
|
+
<X className="h-3.5 w-3.5 text-zinc-700" />
|
|
24
|
+
)}
|
|
25
|
+
<span className={met ? "text-zinc-400" : "text-zinc-600"}>{rule.label}</span>
|
|
26
|
+
</li>
|
|
27
|
+
);
|
|
28
|
+
})}
|
|
29
|
+
</ul>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, forwardRef } from "react";
|
|
4
|
+
import { Eye, EyeOff } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
type Props = Omit<React.InputHTMLAttributes<HTMLInputElement>, "type">;
|
|
7
|
+
|
|
8
|
+
export const PasswordInput = forwardRef<HTMLInputElement, Props>(function PasswordInput(
|
|
9
|
+
{ className, ...props },
|
|
10
|
+
ref
|
|
11
|
+
) {
|
|
12
|
+
const [visible, setVisible] = useState(false);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="relative">
|
|
16
|
+
<input
|
|
17
|
+
{...props}
|
|
18
|
+
ref={ref}
|
|
19
|
+
type={visible ? "text" : "password"}
|
|
20
|
+
className={
|
|
21
|
+
className ??
|
|
22
|
+
"w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 pr-10 text-sm text-zinc-100 placeholder-zinc-500 focus:border-zinc-500 focus:outline-none ring-focus"
|
|
23
|
+
}
|
|
24
|
+
/>
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
onClick={() => setVisible((v) => !v)}
|
|
28
|
+
tabIndex={-1}
|
|
29
|
+
aria-label={visible ? "Hide password" : "Show password"}
|
|
30
|
+
className="absolute inset-y-0 right-0 flex items-center px-3 text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
31
|
+
>
|
|
32
|
+
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { SwitchAccountButton } from "@/components/auth/switch-account-button";
|
|
6
|
+
|
|
7
|
+
const signOut = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("next-auth/react", () => ({
|
|
10
|
+
signOut: (...args: unknown[]) => signOut(...args),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("SwitchAccountButton", () => {
|
|
14
|
+
it("signs the user out to the login page when clicked", async () => {
|
|
15
|
+
const user = userEvent.setup();
|
|
16
|
+
render(<SwitchAccountButton />);
|
|
17
|
+
|
|
18
|
+
await user.click(screen.getByRole("button", { name: "Switch account" }));
|
|
19
|
+
|
|
20
|
+
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/login" });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { signOut } from "next-auth/react";
|
|
4
|
+
|
|
5
|
+
type SwitchAccountButtonProps = {
|
|
6
|
+
className?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function SwitchAccountButton({ className }: SwitchAccountButtonProps) {
|
|
10
|
+
return (
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
onClick={() => signOut({ callbackUrl: "/login" })}
|
|
14
|
+
className={className}
|
|
15
|
+
>
|
|
16
|
+
Switch account
|
|
17
|
+
</button>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
interface TwoFactorInputProps {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
length?: number;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
autoFocus?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function TwoFactorInput({
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
length = 6,
|
|
17
|
+
disabled = false,
|
|
18
|
+
autoFocus = false,
|
|
19
|
+
}: TwoFactorInputProps) {
|
|
20
|
+
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
21
|
+
const [focusedIndex, setFocusedIndex] = useState<number>(autoFocus ? 0 : -1);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (autoFocus) {
|
|
25
|
+
inputRefs.current[0]?.focus();
|
|
26
|
+
}
|
|
27
|
+
}, [autoFocus]);
|
|
28
|
+
|
|
29
|
+
const handleChange = (index: number, e: React.ChangeEvent<HTMLInputElement>) => {
|
|
30
|
+
const newValue = e.target.value;
|
|
31
|
+
|
|
32
|
+
// Only allow digits
|
|
33
|
+
if (!/^\d*$/.test(newValue)) return;
|
|
34
|
+
|
|
35
|
+
const currentValue = value.split("");
|
|
36
|
+
currentValue[index] = newValue.slice(-1); // Take only last character
|
|
37
|
+
const newValueStr = currentValue.join("");
|
|
38
|
+
|
|
39
|
+
onChange(newValueStr);
|
|
40
|
+
|
|
41
|
+
// Auto-focus next input
|
|
42
|
+
if (newValue && index < length - 1) {
|
|
43
|
+
inputRefs.current[index + 1]?.focus();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
48
|
+
// Handle backspace
|
|
49
|
+
if (e.key === "Backspace") {
|
|
50
|
+
if (!value[index] && index > 0) {
|
|
51
|
+
// If current is empty, go back and delete previous
|
|
52
|
+
const currentValue = value.split("");
|
|
53
|
+
currentValue[index - 1] = "";
|
|
54
|
+
onChange(currentValue.join(""));
|
|
55
|
+
inputRefs.current[index - 1]?.focus();
|
|
56
|
+
} else {
|
|
57
|
+
// Delete current
|
|
58
|
+
const currentValue = value.split("");
|
|
59
|
+
currentValue[index] = "";
|
|
60
|
+
onChange(currentValue.join(""));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Handle left arrow
|
|
64
|
+
else if (e.key === "ArrowLeft" && index > 0) {
|
|
65
|
+
inputRefs.current[index - 1]?.focus();
|
|
66
|
+
}
|
|
67
|
+
// Handle right arrow
|
|
68
|
+
else if (e.key === "ArrowRight" && index < length - 1) {
|
|
69
|
+
inputRefs.current[index + 1]?.focus();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
|
|
76
|
+
onChange(pastedData);
|
|
77
|
+
|
|
78
|
+
// Focus the next empty input or the last one
|
|
79
|
+
const nextIndex = Math.min(pastedData.length, length - 1);
|
|
80
|
+
inputRefs.current[nextIndex]?.focus();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleFocus = (index: number) => {
|
|
84
|
+
setFocusedIndex(index);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex gap-2">
|
|
89
|
+
{Array.from({ length }).map((_, index) => (
|
|
90
|
+
<input
|
|
91
|
+
key={index}
|
|
92
|
+
ref={(el) => {
|
|
93
|
+
inputRefs.current[index] = el;
|
|
94
|
+
}}
|
|
95
|
+
type="text"
|
|
96
|
+
inputMode="numeric"
|
|
97
|
+
pattern="[0-9]*"
|
|
98
|
+
maxLength={1}
|
|
99
|
+
value={value[index] || ""}
|
|
100
|
+
onChange={(e) => handleChange(index, e)}
|
|
101
|
+
onKeyDown={(e) => handleKeyDown(index, e)}
|
|
102
|
+
onPaste={handlePaste}
|
|
103
|
+
onFocus={() => handleFocus(index)}
|
|
104
|
+
disabled={disabled}
|
|
105
|
+
className={`w-12 h-14 rounded-lg border-2 text-center text-2xl font-semibold text-zinc-100 focus:outline-none transition-all
|
|
106
|
+
${focusedIndex === index
|
|
107
|
+
? "border-zinc-400 bg-zinc-800"
|
|
108
|
+
: "border-zinc-700 bg-zinc-900"
|
|
109
|
+
}
|
|
110
|
+
${disabled ? "opacity-50 cursor-not-allowed" : ""}
|
|
111
|
+
`}
|
|
112
|
+
/>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|