@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,319 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Copy, Check, Trash2, UserPlus } from "lucide-react";
|
|
6
|
+
import { ConfirmDangerAction } from "@/components/settings/confirm-danger-action";
|
|
7
|
+
|
|
8
|
+
type Member = {
|
|
9
|
+
id: string;
|
|
10
|
+
userId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
role: "OWNER" | "ADMIN" | "MEMBER";
|
|
14
|
+
joinedAt: string;
|
|
15
|
+
isCurrentUser: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type PendingInvite = {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string;
|
|
21
|
+
role: "OWNER" | "ADMIN" | "MEMBER";
|
|
22
|
+
token: string;
|
|
23
|
+
expiresAt: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type McpToken = {
|
|
27
|
+
id: string;
|
|
28
|
+
alias: string | null;
|
|
29
|
+
userName: string;
|
|
30
|
+
userId: string;
|
|
31
|
+
lastUsed: string | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ROLE_LABELS = { OWNER: "Owner", ADMIN: "Admin", MEMBER: "Member" };
|
|
35
|
+
|
|
36
|
+
export function TeamSettings({
|
|
37
|
+
members,
|
|
38
|
+
pendingInvites,
|
|
39
|
+
canManage,
|
|
40
|
+
workspaceId,
|
|
41
|
+
mcpTokens = [],
|
|
42
|
+
}: {
|
|
43
|
+
members: Member[];
|
|
44
|
+
pendingInvites: PendingInvite[];
|
|
45
|
+
canManage: boolean;
|
|
46
|
+
currentUserId: string;
|
|
47
|
+
workspaceId: string;
|
|
48
|
+
mcpTokens?: McpToken[];
|
|
49
|
+
}) {
|
|
50
|
+
const router = useRouter();
|
|
51
|
+
const [inviteEmail, setInviteEmail] = useState("");
|
|
52
|
+
const [inviteRole, setInviteRole] = useState<"ADMIN" | "MEMBER">("MEMBER");
|
|
53
|
+
const [inviting, setInviting] = useState(false);
|
|
54
|
+
const [inviteLink, setInviteLink] = useState("");
|
|
55
|
+
const [copied, setCopied] = useState(false);
|
|
56
|
+
const [error, setError] = useState("");
|
|
57
|
+
const [actionError, setActionError] = useState("");
|
|
58
|
+
const [removeConfirm, setRemoveConfirm] = useState<{ open: boolean; memberId: string }>({ open: false, memberId: "" });
|
|
59
|
+
|
|
60
|
+
async function handleInvite(e: React.FormEvent) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
setInviting(true);
|
|
63
|
+
setError("");
|
|
64
|
+
setInviteLink("");
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch("/api/invites", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json" },
|
|
70
|
+
// workspaceId scopes the invite to the correct workspace for multi-workspace users
|
|
71
|
+
body: JSON.stringify({ email: inviteEmail, role: inviteRole, workspaceId }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
const { link } = await res.json();
|
|
76
|
+
setInviteLink(link);
|
|
77
|
+
setInviteEmail("");
|
|
78
|
+
router.refresh();
|
|
79
|
+
} else {
|
|
80
|
+
const d = await res.json();
|
|
81
|
+
// upgrade_required has a human-readable message separate from the error code
|
|
82
|
+
setError(d.message ?? d.error ?? "Failed to create invite");
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
setError("Something went wrong. Please try again.");
|
|
86
|
+
} finally {
|
|
87
|
+
setInviting(false);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function copyLink(link: string) {
|
|
92
|
+
await navigator.clipboard.writeText(link);
|
|
93
|
+
setCopied(true);
|
|
94
|
+
setTimeout(() => setCopied(false), 2000);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function removeMember(memberId: string) {
|
|
98
|
+
setRemoveConfirm({ open: true, memberId });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function confirmRemoveMember() {
|
|
102
|
+
const { memberId } = removeConfirm;
|
|
103
|
+
setActionError("");
|
|
104
|
+
const res = await fetch(`/api/workspace/members/${memberId}`, { method: "DELETE" });
|
|
105
|
+
if (res.ok) {
|
|
106
|
+
router.refresh();
|
|
107
|
+
} else {
|
|
108
|
+
const d = await res.json().catch(() => ({}));
|
|
109
|
+
setActionError(d.error ?? "Failed to remove member");
|
|
110
|
+
}
|
|
111
|
+
setRemoveConfirm({ open: false, memberId: "" });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function revokeInvite(inviteToken: string) {
|
|
115
|
+
setActionError("");
|
|
116
|
+
// Route uses the token field (JWT slug), not the database id
|
|
117
|
+
const res = await fetch(`/api/invites/${inviteToken}`, { method: "DELETE" });
|
|
118
|
+
if (res.ok) {
|
|
119
|
+
router.refresh();
|
|
120
|
+
} else {
|
|
121
|
+
const d = await res.json().catch(() => ({}));
|
|
122
|
+
setActionError(d.error ?? "Failed to revoke invite");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function revokeToken(tokenId: string) {
|
|
127
|
+
setActionError("");
|
|
128
|
+
const res = await fetch(`/api/mcp/revoke-token/${tokenId}`, { method: "DELETE" });
|
|
129
|
+
if (res.ok) {
|
|
130
|
+
router.refresh();
|
|
131
|
+
} else {
|
|
132
|
+
const d = await res.json().catch(() => ({}));
|
|
133
|
+
setActionError(d.error ?? "Failed to revoke token");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="space-y-8">
|
|
139
|
+
{actionError && (
|
|
140
|
+
<div className="rounded-md border border-red-800/40 bg-red-950/40 px-4 py-3 flex items-start justify-between gap-3">
|
|
141
|
+
<p className="text-sm text-red-300">{actionError}</p>
|
|
142
|
+
<button onClick={() => setActionError("")} className="text-red-500 hover:text-red-300 shrink-0 text-lg leading-none">×</button>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
{/* Members list */}
|
|
146
|
+
<div>
|
|
147
|
+
<h3 className="text-sm font-semibold text-zinc-300 mb-3">
|
|
148
|
+
Members ({members.length})
|
|
149
|
+
</h3>
|
|
150
|
+
<p className="mb-3 text-xs text-zinc-500">
|
|
151
|
+
Removing a member or revoking an active token updates workspace access
|
|
152
|
+
immediately.
|
|
153
|
+
</p>
|
|
154
|
+
<div className="space-y-1 rounded-lg border border-zinc-800 overflow-hidden">
|
|
155
|
+
{members.map((m) => (
|
|
156
|
+
<div
|
|
157
|
+
key={m.id}
|
|
158
|
+
className="flex items-center justify-between px-4 py-3 bg-zinc-900 border-b border-zinc-800 last:border-0"
|
|
159
|
+
>
|
|
160
|
+
<div className="min-w-0">
|
|
161
|
+
<p className="text-sm font-medium text-zinc-200 truncate">
|
|
162
|
+
{m.name} {m.isCurrentUser && <span className="text-zinc-500 font-normal">(you)</span>}
|
|
163
|
+
</p>
|
|
164
|
+
<p className="text-xs text-zinc-500 truncate">{m.email}</p>
|
|
165
|
+
</div>
|
|
166
|
+
<div className="flex items-center gap-3 shrink-0 ml-4">
|
|
167
|
+
<span className="text-xs text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded">
|
|
168
|
+
{ROLE_LABELS[m.role]}
|
|
169
|
+
</span>
|
|
170
|
+
{canManage && !m.isCurrentUser && m.role !== "OWNER" && (
|
|
171
|
+
<button
|
|
172
|
+
onClick={() => removeMember(m.id)}
|
|
173
|
+
className="text-zinc-600 hover:text-red-400 transition-colors"
|
|
174
|
+
title="Remove member"
|
|
175
|
+
>
|
|
176
|
+
<Trash2 size={14} />
|
|
177
|
+
</button>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{/* Invite form */}
|
|
186
|
+
{canManage && (
|
|
187
|
+
<div>
|
|
188
|
+
<h3 className="text-sm font-semibold text-zinc-300 mb-3 flex items-center gap-2">
|
|
189
|
+
<UserPlus size={14} /> Invite member
|
|
190
|
+
</h3>
|
|
191
|
+
<p className="mb-3 text-xs text-zinc-500">
|
|
192
|
+
Invites stay pending until accepted. Revoking an invite immediately
|
|
193
|
+
invalidates the link.
|
|
194
|
+
</p>
|
|
195
|
+
<form onSubmit={handleInvite} className="flex gap-2">
|
|
196
|
+
<input
|
|
197
|
+
type="email"
|
|
198
|
+
value={inviteEmail}
|
|
199
|
+
onChange={(e) => setInviteEmail(e.target.value)}
|
|
200
|
+
required
|
|
201
|
+
placeholder="colleague@example.com"
|
|
202
|
+
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none ring-focus"
|
|
203
|
+
/>
|
|
204
|
+
<select
|
|
205
|
+
value={inviteRole}
|
|
206
|
+
onChange={(e) => setInviteRole(e.target.value as "ADMIN" | "MEMBER")}
|
|
207
|
+
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-2 text-sm text-zinc-300 focus:outline-none ring-focus"
|
|
208
|
+
>
|
|
209
|
+
<option value="MEMBER">Member</option>
|
|
210
|
+
<option value="ADMIN">Admin</option>
|
|
211
|
+
</select>
|
|
212
|
+
<button
|
|
213
|
+
type="submit"
|
|
214
|
+
disabled={inviting}
|
|
215
|
+
className="rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 disabled:opacity-50 shrink-0"
|
|
216
|
+
>
|
|
217
|
+
{inviting ? "…" : "Generate link"}
|
|
218
|
+
</button>
|
|
219
|
+
</form>
|
|
220
|
+
{error && <p className="mt-2 text-sm text-red-400">{error}</p>}
|
|
221
|
+
|
|
222
|
+
{inviteLink && (
|
|
223
|
+
<div className="mt-3 flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2">
|
|
224
|
+
<p className="flex-1 text-xs text-zinc-400 font-mono truncate">{inviteLink}</p>
|
|
225
|
+
<button
|
|
226
|
+
onClick={() => copyLink(inviteLink)}
|
|
227
|
+
className="shrink-0 text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
228
|
+
title="Copy invite link"
|
|
229
|
+
>
|
|
230
|
+
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{/* MCP Tokens */}
|
|
238
|
+
{canManage && mcpTokens.length > 0 && (
|
|
239
|
+
<div>
|
|
240
|
+
<h3 className="text-sm font-semibold text-zinc-300 mb-3">Active agent tokens</h3>
|
|
241
|
+
<div className="space-y-1 rounded-lg border border-zinc-800 overflow-hidden">
|
|
242
|
+
{mcpTokens.map((t) => (
|
|
243
|
+
<div key={t.id} className="flex items-center justify-between px-4 py-3 bg-zinc-900 border-b border-zinc-800 last:border-0">
|
|
244
|
+
<div className="min-w-0">
|
|
245
|
+
<p className="text-sm text-zinc-300 truncate">{t.alias ?? "Unnamed token"}</p>
|
|
246
|
+
<p className="text-xs text-zinc-600">
|
|
247
|
+
{t.userName}
|
|
248
|
+
{t.lastUsed ? ` · last used ${new Date(t.lastUsed).toLocaleDateString()}` : " · never used"}
|
|
249
|
+
</p>
|
|
250
|
+
</div>
|
|
251
|
+
<button
|
|
252
|
+
onClick={() => revokeToken(t.id)}
|
|
253
|
+
className="shrink-0 text-zinc-600 hover:text-red-400 transition-colors"
|
|
254
|
+
title="Revoke token"
|
|
255
|
+
>
|
|
256
|
+
<Trash2 size={14} />
|
|
257
|
+
</button>
|
|
258
|
+
</div>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{/* Pending invites */}
|
|
265
|
+
{pendingInvites.length > 0 && (
|
|
266
|
+
<div>
|
|
267
|
+
<h3 className="text-sm font-semibold text-zinc-300 mb-3">
|
|
268
|
+
Pending invites ({pendingInvites.length})
|
|
269
|
+
</h3>
|
|
270
|
+
<div className="space-y-1 rounded-lg border border-zinc-800 overflow-hidden">
|
|
271
|
+
{pendingInvites.map((inv) => (
|
|
272
|
+
<div
|
|
273
|
+
key={inv.id}
|
|
274
|
+
className="flex items-center justify-between px-4 py-3 bg-zinc-900 border-b border-zinc-800 last:border-0"
|
|
275
|
+
>
|
|
276
|
+
<div className="min-w-0">
|
|
277
|
+
<p className="text-sm text-zinc-300 truncate">{inv.email}</p>
|
|
278
|
+
<p className="text-xs text-zinc-600">
|
|
279
|
+
{ROLE_LABELS[inv.role]} · Expires{" "}
|
|
280
|
+
{new Date(inv.expiresAt).toLocaleDateString()}
|
|
281
|
+
</p>
|
|
282
|
+
</div>
|
|
283
|
+
<div className="flex items-center gap-2 shrink-0 ml-4">
|
|
284
|
+
<button
|
|
285
|
+
onClick={() => copyLink(`${process.env.NEXT_PUBLIC_APP_URL ?? window.location.origin}/invite/${inv.token}`)}
|
|
286
|
+
className="text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
287
|
+
title="Copy link"
|
|
288
|
+
>
|
|
289
|
+
<Copy size={14} />
|
|
290
|
+
</button>
|
|
291
|
+
{canManage && (
|
|
292
|
+
<button
|
|
293
|
+
onClick={() => revokeInvite(inv.token)}
|
|
294
|
+
className="text-zinc-600 hover:text-red-400 transition-colors"
|
|
295
|
+
title="Revoke invite"
|
|
296
|
+
>
|
|
297
|
+
<Trash2 size={14} />
|
|
298
|
+
</button>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
))}
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
|
|
307
|
+
{/* Confirmation dialog */}
|
|
308
|
+
<ConfirmDangerAction
|
|
309
|
+
open={removeConfirm.open}
|
|
310
|
+
title="Remove Member"
|
|
311
|
+
body="Remove this member from the workspace?"
|
|
312
|
+
confirmLabel="Remove"
|
|
313
|
+
cancelLabel="Cancel"
|
|
314
|
+
onConfirm={confirmRemoveMember}
|
|
315
|
+
onClose={() => setRemoveConfirm({ open: false, memberId: "" })}
|
|
316
|
+
/>
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Shield, ShieldCheck, Loader2, Copy, Check, Smartphone, Lock } from "lucide-react";
|
|
5
|
+
import { PasswordInput } from "@/components/auth/password-input";
|
|
6
|
+
import { TwoFactorInput } from "@/components/auth/two-factor-input";
|
|
7
|
+
|
|
8
|
+
export function TwoFactorAuth({ initialEnabled = false }: { initialEnabled?: boolean }) {
|
|
9
|
+
const [enabled, setEnabled] = useState(initialEnabled);
|
|
10
|
+
const [settingUp, setSettingUp] = useState(false);
|
|
11
|
+
const [qrCode, setQrCode] = useState<string | null>(null);
|
|
12
|
+
const [secret, setSecret] = useState<string | null>(null);
|
|
13
|
+
const [manualKey, setManualKey] = useState<string | null>(null);
|
|
14
|
+
const [verifyCode, setVerifyCode] = useState("");
|
|
15
|
+
const [disabling, setDisabling] = useState(false);
|
|
16
|
+
const [disablePassword, setDisablePassword] = useState("");
|
|
17
|
+
const [disableCode, setDisableCode] = useState("");
|
|
18
|
+
const [loading, setLoading] = useState(false);
|
|
19
|
+
const [error, setError] = useState<string | null>(null);
|
|
20
|
+
const [success, setSuccess] = useState<string | null>(null);
|
|
21
|
+
const [copied, setCopied] = useState(false);
|
|
22
|
+
|
|
23
|
+
async function startSetup() {
|
|
24
|
+
setLoading(true);
|
|
25
|
+
setError(null);
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch("/api/auth/2fa", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify({ action: "setup" }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
if (!res.ok) throw new Error(data.error || "Failed to setup 2FA");
|
|
35
|
+
|
|
36
|
+
setQrCode(data.qrCodeURL);
|
|
37
|
+
setSecret(data.secret);
|
|
38
|
+
setManualKey(data.manualEntryKey);
|
|
39
|
+
setSettingUp(true);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
setError(err instanceof Error ? err.message : "Failed to setup 2FA");
|
|
42
|
+
} finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function verifyAndEnable() {
|
|
48
|
+
if (!verifyCode.match(/^\d{6}$/)) {
|
|
49
|
+
setError("Please enter a valid 6-digit code");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setLoading(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch("/api/auth/2fa", {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
body: JSON.stringify({ action: "verify", code: verifyCode }),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
if (!res.ok) throw new Error(data.error || "Invalid code");
|
|
64
|
+
|
|
65
|
+
setEnabled(true);
|
|
66
|
+
setSettingUp(false);
|
|
67
|
+
setSuccess("Two-factor authentication enabled successfully");
|
|
68
|
+
setQrCode(null);
|
|
69
|
+
setSecret(null);
|
|
70
|
+
setManualKey(null);
|
|
71
|
+
setVerifyCode("");
|
|
72
|
+
} catch (err) {
|
|
73
|
+
setError(err instanceof Error ? err.message : "Failed to verify code");
|
|
74
|
+
} finally {
|
|
75
|
+
setLoading(false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function disable2FA() {
|
|
80
|
+
if (!disablePassword) {
|
|
81
|
+
setError("Please enter your password");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setLoading(true);
|
|
86
|
+
setError(null);
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch("/api/auth/2fa", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
action: "disable",
|
|
93
|
+
password: disablePassword,
|
|
94
|
+
code: disableCode || undefined,
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const data = await res.json();
|
|
99
|
+
if (!res.ok) throw new Error(data.error || "Failed to disable 2FA");
|
|
100
|
+
|
|
101
|
+
setEnabled(false);
|
|
102
|
+
setDisabling(false);
|
|
103
|
+
setSuccess("Two-factor authentication disabled");
|
|
104
|
+
setDisablePassword("");
|
|
105
|
+
setDisableCode("");
|
|
106
|
+
} catch (err) {
|
|
107
|
+
setError(err instanceof Error ? err.message : "Failed to disable 2FA");
|
|
108
|
+
} finally {
|
|
109
|
+
setLoading(false);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function copyManualKey() {
|
|
114
|
+
if (manualKey) {
|
|
115
|
+
navigator.clipboard.writeText(manualKey.replace(/\s/g, ""));
|
|
116
|
+
setCopied(true);
|
|
117
|
+
setTimeout(() => setCopied(false), 2000);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Generate QR code URL using Google Charts API
|
|
122
|
+
const qrCodeImageUrl = qrCode
|
|
123
|
+
? `https://chart.googleapis.com/chart?cht=qr&chs=200x200&chld=M|0&chl=${encodeURIComponent(qrCode)}`
|
|
124
|
+
: null;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="space-y-4">
|
|
128
|
+
<div className="flex items-center gap-3">
|
|
129
|
+
{enabled ? (
|
|
130
|
+
<ShieldCheck size={20} className="text-emerald-400" />
|
|
131
|
+
) : (
|
|
132
|
+
<Shield size={20} className="text-zinc-500" />
|
|
133
|
+
)}
|
|
134
|
+
<div>
|
|
135
|
+
<h3 className="text-sm font-semibold text-zinc-300">Two-Factor Authentication</h3>
|
|
136
|
+
<p className="text-xs text-zinc-500">
|
|
137
|
+
{enabled
|
|
138
|
+
? "Your account is protected with an authenticator app"
|
|
139
|
+
: "Add an extra layer of security to your account"}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{success && (
|
|
145
|
+
<div className="rounded-md border border-emerald-800/40 bg-emerald-950/30 px-3 py-2">
|
|
146
|
+
<p className="text-xs text-emerald-400">{success}</p>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{error && (
|
|
151
|
+
<div className="rounded-md border border-red-800/40 bg-red-950/30 px-3 py-2">
|
|
152
|
+
<p className="text-xs text-red-400">{error}</p>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{!enabled && !settingUp && (
|
|
157
|
+
<button
|
|
158
|
+
onClick={startSetup}
|
|
159
|
+
disabled={loading}
|
|
160
|
+
className="flex items-center gap-1.5 rounded-md bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-700 disabled:opacity-50 transition-colors"
|
|
161
|
+
>
|
|
162
|
+
{loading ? <Loader2 size={13} className="animate-spin" /> : <Smartphone size={13} />}
|
|
163
|
+
Set up 2FA
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{settingUp && (
|
|
168
|
+
<div className="space-y-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
|
169
|
+
<div className="flex items-center gap-2">
|
|
170
|
+
<Smartphone size={16} className="text-zinc-400" />
|
|
171
|
+
<h4 className="text-xs font-medium text-zinc-300">Scan QR Code</h4>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<p className="text-xs text-zinc-400">
|
|
175
|
+
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.)
|
|
176
|
+
</p>
|
|
177
|
+
|
|
178
|
+
{qrCodeImageUrl && (
|
|
179
|
+
<div className="flex justify-center">
|
|
180
|
+
<img
|
|
181
|
+
src={qrCodeImageUrl}
|
|
182
|
+
alt="2FA QR Code"
|
|
183
|
+
className="rounded-lg border border-zinc-800"
|
|
184
|
+
width={200}
|
|
185
|
+
height={200}
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{manualKey && (
|
|
191
|
+
<div className="space-y-2">
|
|
192
|
+
<p className="text-xs text-zinc-500">Or enter this key manually:</p>
|
|
193
|
+
<div className="flex items-center gap-2">
|
|
194
|
+
<code className="flex-1 rounded-md bg-zinc-950 px-3 py-2 text-xs text-zinc-300 font-mono">
|
|
195
|
+
{manualKey}
|
|
196
|
+
</code>
|
|
197
|
+
<button
|
|
198
|
+
onClick={copyManualKey}
|
|
199
|
+
className="shrink-0 rounded-md bg-zinc-800 p-2 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-300 transition-colors"
|
|
200
|
+
title="Copy to clipboard"
|
|
201
|
+
>
|
|
202
|
+
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
<div className="border-t border-zinc-800 pt-4 space-y-3">
|
|
209
|
+
<p className="text-xs text-zinc-400">Enter the 6-digit code from your authenticator app to verify:</p>
|
|
210
|
+
<div className="flex items-center gap-2">
|
|
211
|
+
<TwoFactorInput
|
|
212
|
+
value={verifyCode}
|
|
213
|
+
onChange={setVerifyCode}
|
|
214
|
+
length={6}
|
|
215
|
+
disabled={loading}
|
|
216
|
+
autoFocus
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
<div className="flex items-center gap-2">
|
|
220
|
+
<button
|
|
221
|
+
onClick={verifyAndEnable}
|
|
222
|
+
disabled={loading || verifyCode.length !== 6}
|
|
223
|
+
className="flex items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50 transition-colors"
|
|
224
|
+
>
|
|
225
|
+
{loading ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
|
|
226
|
+
Verify & Enable
|
|
227
|
+
</button>
|
|
228
|
+
<button
|
|
229
|
+
onClick={() => setSettingUp(false)}
|
|
230
|
+
disabled={loading}
|
|
231
|
+
className="rounded-md border border-zinc-700 px-3 py-2 text-xs text-zinc-400 hover:bg-zinc-800 transition-colors"
|
|
232
|
+
>
|
|
233
|
+
Cancel
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{enabled && !disabling && (
|
|
241
|
+
<button
|
|
242
|
+
onClick={() => setDisabling(true)}
|
|
243
|
+
className="flex items-center gap-1.5 rounded-md border border-red-800/40 bg-red-950/20 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-950/40 transition-colors"
|
|
244
|
+
>
|
|
245
|
+
<Lock size={13} />
|
|
246
|
+
Disable 2FA
|
|
247
|
+
</button>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{enabled && disabling && (
|
|
251
|
+
<div className="space-y-3 rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
|
252
|
+
<p className="text-xs text-zinc-400">Enter your password {enabled && "and 2FA code"} to disable:</p>
|
|
253
|
+
<div className="space-y-2">
|
|
254
|
+
<PasswordInput
|
|
255
|
+
value={disablePassword}
|
|
256
|
+
onChange={(e) => setDisablePassword(e.target.value)}
|
|
257
|
+
placeholder="Your password"
|
|
258
|
+
/>
|
|
259
|
+
{enabled && (
|
|
260
|
+
<div className="space-y-1">
|
|
261
|
+
<label className="text-xs text-zinc-400">2FA Code</label>
|
|
262
|
+
<TwoFactorInput
|
|
263
|
+
value={disableCode}
|
|
264
|
+
onChange={setDisableCode}
|
|
265
|
+
length={6}
|
|
266
|
+
disabled={loading}
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
<div className="flex items-center gap-2">
|
|
272
|
+
<button
|
|
273
|
+
onClick={disable2FA}
|
|
274
|
+
disabled={loading || !disablePassword}
|
|
275
|
+
className="flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
|
|
276
|
+
>
|
|
277
|
+
{loading ? <Loader2 size={13} className="animate-spin" /> : "Disable 2FA"}
|
|
278
|
+
</button>
|
|
279
|
+
<button
|
|
280
|
+
onClick={() => {
|
|
281
|
+
setDisabling(false);
|
|
282
|
+
setDisablePassword("");
|
|
283
|
+
setDisableCode("");
|
|
284
|
+
setError(null);
|
|
285
|
+
}}
|
|
286
|
+
disabled={loading}
|
|
287
|
+
className="rounded-md border border-zinc-700 px-3 py-1.5 text-xs text-zinc-400 hover:bg-zinc-800 transition-colors"
|
|
288
|
+
>
|
|
289
|
+
Cancel
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|