Completes the high-leverage half of UI5 by migrating the most form-heavy
authenticated screens off the legacy 'input-shell' / inline-style pattern
onto Input, Textarea, Select, and AlertBanner primitives.
Migrated:
- web/src/app/(app)/settings/page.tsx — change-password form, feedback
form, MCP/API-tokens/offline-queue cards. Replaces 'surface-card'
sections with Card components, 'input-shell' inputs/selects/textareas
with Input/Select/Textarea, and inline error/success divs with
AlertBanner.
- web/src/components/CreateNoteModal.tsx — template/workspace/title/body/tags
fields. Select primitive uses options=[{value,label}].
- web/src/components/LinkNoteModal.tsx — search input + relationship-type
select + alert banner for errors.
- web/src/components/ShareDialog.tsx — user-id input, permission select,
collaborator/public-link rows now use AlertBanner (tone='neutral') for
the muted-surface look. Web Share API unsupported message is now a
proper tone='warning' banner.
- web/src/components/PromptTemplateEditor.tsx — full form (name, slug,
description, 3 selects, 2 textareas) migrated.
All existing tests continue to pass without modification because
@testing-library queries (getByLabel, getByPlaceholder, getByText) are
robust against the underlying HTML structure changes.
Verified:
- pnpm --filter @notelett/web run typecheck: passes
- pnpm --filter @notelett/web run test: 96/96 (existing CreateNoteModal,
LinkNoteModal, ShareDialog suites all green)
- pnpm run verify: end-to-end (backend 380/380, web 96/96, mobile 97/97)
- Legacy class matches in web/src dropped from 89 to 69 over the UI5
slice; remaining matches are in UI6/UI7 territory (dashboard, search,
workspaces list, notes detail, chat, palace, NoteEditor).
247 lines
8.8 KiB
TypeScript
247 lines
8.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useTheme } from "@/lib/use-theme";
|
|
import { useAuth } from "@/lib/auth";
|
|
import { AppShell } from "@/components/AppShell";
|
|
import { AlertBanner, Button, Card, Input, Select, Textarea } from "@/components/ui/Primitives";
|
|
import { getFeedbackClient } from "@/lib/feedback-client";
|
|
import { toast } from "@/lib/toast";
|
|
import { NOTES_API_URL, PLATFORM_SERVICE_URL, MCP_SERVER_URL, PRODUCT_ID } from "@/lib/product-config";
|
|
import { getOfflineQueue } from "@/lib/offline-queue";
|
|
|
|
export default function SettingsPage() {
|
|
const { theme, toggle } = useTheme();
|
|
const { user, logout, changePassword, deleteAccount, isLoading, error, success, clearMessages } = useAuth();
|
|
|
|
const [feedbackTitle, setFeedbackTitle] = useState("");
|
|
const [feedbackBody, setFeedbackBody] = useState("");
|
|
const [feedbackType, setFeedbackType] = useState<"bug" | "feature" | "praise" | "other">("bug");
|
|
const [submittingFeedback, setSubmittingFeedback] = useState(false);
|
|
|
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
const [newPassword, setNewPassword] = useState("");
|
|
|
|
async function handleSubmitFeedback() {
|
|
if (!feedbackTitle.trim()) return;
|
|
setSubmittingFeedback(true);
|
|
try {
|
|
await getFeedbackClient().submitWithScreenshot({
|
|
type: feedbackType,
|
|
title: feedbackTitle.trim(),
|
|
body: feedbackBody.trim() || undefined,
|
|
platform: "web",
|
|
});
|
|
toast.success("Feedback submitted");
|
|
setFeedbackTitle("");
|
|
setFeedbackBody("");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed to submit feedback");
|
|
} finally {
|
|
setSubmittingFeedback(false);
|
|
}
|
|
}
|
|
|
|
async function handleChangePassword(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
clearMessages();
|
|
const ok = await changePassword(currentPassword, newPassword);
|
|
if (ok) {
|
|
setCurrentPassword("");
|
|
setNewPassword("");
|
|
toast.success("Password changed");
|
|
}
|
|
}
|
|
|
|
async function handleDeleteAccount() {
|
|
const pw = prompt("Enter your password to confirm account deletion:");
|
|
if (!pw) return;
|
|
const ok = await deleteAccount(pw);
|
|
if (ok) toast.success("Account deleted");
|
|
}
|
|
|
|
return (
|
|
<AppShell
|
|
title="Settings"
|
|
description="Account, preferences, feedback, and session management."
|
|
actions={
|
|
<Button
|
|
onClick={logout}
|
|
variant="secondary"
|
|
size="sm"
|
|
className="border-transparent bg-[var(--nl-danger-muted)] text-[var(--nl-danger)] hover:bg-[var(--nl-danger-muted)]"
|
|
>
|
|
Sign out
|
|
</Button>
|
|
}
|
|
>
|
|
<section className="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
|
|
{/* Profile */}
|
|
<Card padding="md" className="grid gap-3">
|
|
<strong>Profile</strong>
|
|
<div className="text-[color:var(--nl-text-secondary)]">
|
|
{user?.name ?? "—"} · {user?.email ?? "—"} · {user?.role ?? "—"}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Appearance */}
|
|
<Card padding="md" className="flex items-center justify-between">
|
|
<div className="grid gap-2">
|
|
<strong>Appearance</strong>
|
|
<div className="text-[color:var(--nl-text-secondary)]">Switch between dark and light mode</div>
|
|
</div>
|
|
<Button
|
|
onClick={toggle}
|
|
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
{theme === "dark" ? "Light" : "Dark"}
|
|
</Button>
|
|
</Card>
|
|
|
|
{/* Change password */}
|
|
<Card padding="md" className="grid gap-3">
|
|
<strong>Change password</strong>
|
|
{error && (
|
|
<AlertBanner tone="error" role="alert">
|
|
{error}
|
|
</AlertBanner>
|
|
)}
|
|
{success && (
|
|
<AlertBanner tone="success" role="status">
|
|
{success}
|
|
</AlertBanner>
|
|
)}
|
|
<form onSubmit={handleChangePassword} className="grid gap-3">
|
|
<Input
|
|
type="password"
|
|
placeholder="Current password"
|
|
aria-label="Current password"
|
|
value={currentPassword}
|
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
required
|
|
/>
|
|
<Input
|
|
type="password"
|
|
placeholder="New password"
|
|
aria-label="New password"
|
|
minLength={8}
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
required
|
|
/>
|
|
<Button type="submit" disabled={isLoading} loading={isLoading} className="justify-self-start">
|
|
Update password
|
|
</Button>
|
|
</form>
|
|
</Card>
|
|
|
|
{/* Danger zone */}
|
|
<Card padding="md" className="grid gap-3">
|
|
<strong>Danger zone</strong>
|
|
<Button
|
|
onClick={handleDeleteAccount}
|
|
variant="secondary"
|
|
className="justify-self-start border-transparent bg-[var(--nl-danger-muted)] text-[var(--nl-danger)] hover:bg-[var(--nl-danger-muted)]"
|
|
>
|
|
Delete account
|
|
</Button>
|
|
</Card>
|
|
</section>
|
|
|
|
<Card padding="md" className="mt-4 grid gap-3">
|
|
<strong>Connect your agent (MCP)</strong>
|
|
<p className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
|
|
Use your platform access token with the shared MCP server on port 4007. Point tools at the NoteLett backend and product id{" "}
|
|
<code>{PRODUCT_ID}</code>.
|
|
</p>
|
|
<pre className="m-0 overflow-auto whitespace-pre-wrap rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-3 text-[length:var(--nl-fs-sm)]">
|
|
{`Notes API base: ${NOTES_API_URL}
|
|
Platform API: ${PLATFORM_SERVICE_URL}
|
|
MCP API base: ${MCP_SERVER_URL}
|
|
|
|
# Example Cursor / Claude MCP entry (adjust to your installer):
|
|
# "mcpServers": {
|
|
# "notelett": { "url": "${MCP_SERVER_URL}" }
|
|
# }`}
|
|
</pre>
|
|
<p className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
|
|
Deep links for tools: see <code>docs/DEEP_LINKS.md</code> in the repo.
|
|
</p>
|
|
</Card>
|
|
|
|
<Card padding="md" className="mt-4 grid gap-3">
|
|
<strong>API tokens for automation</strong>
|
|
<p className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
|
|
Scoped tokens for MCP or CI are provisioned through the ByteLyst platform when your tenant enables them. This web app does not yet expose
|
|
create/revoke; use the platform admin or CLI when available.
|
|
</p>
|
|
</Card>
|
|
|
|
<Card padding="md" className="mt-4 grid gap-3">
|
|
<strong>Offline queue</strong>
|
|
<p className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
|
|
Failed writes are retried from local storage via <code>@bytelyst/offline-queue</code> (storage key{" "}
|
|
<code>{`${PRODUCT_ID}_offline_queue`}</code>). Reload or return online to flush.
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
className="justify-self-start"
|
|
onClick={() => {
|
|
try {
|
|
getOfflineQueue();
|
|
toast.success("Offline queue is available in this build");
|
|
} catch {
|
|
toast.error("Offline queue unavailable");
|
|
}
|
|
}}
|
|
>
|
|
Verify offline queue
|
|
</Button>
|
|
</Card>
|
|
|
|
{/* Feedback */}
|
|
<Card padding="md" className="mt-4 grid gap-3">
|
|
<strong>Send feedback</strong>
|
|
<div className="grid gap-3 [grid-template-columns:140px_1fr]">
|
|
<Select
|
|
value={feedbackType}
|
|
onChange={(e) => setFeedbackType(e.target.value as typeof feedbackType)}
|
|
aria-label="Feedback type"
|
|
options={[
|
|
{ value: "bug", label: "Bug" },
|
|
{ value: "feature", label: "Feature request" },
|
|
{ value: "praise", label: "Praise" },
|
|
{ value: "other", label: "Other" },
|
|
]}
|
|
/>
|
|
<Input
|
|
placeholder="Title"
|
|
aria-label="Feedback title"
|
|
value={feedbackTitle}
|
|
onChange={(e) => setFeedbackTitle(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Textarea
|
|
placeholder="Details (optional)"
|
|
aria-label="Feedback details"
|
|
rows={3}
|
|
value={feedbackBody}
|
|
onChange={(e) => setFeedbackBody(e.target.value)}
|
|
className="resize-y"
|
|
/>
|
|
<Button
|
|
onClick={handleSubmitFeedback}
|
|
disabled={submittingFeedback || !feedbackTitle.trim()}
|
|
loading={submittingFeedback}
|
|
className="justify-self-start"
|
|
>
|
|
Send feedback
|
|
</Button>
|
|
</Card>
|
|
</AppShell>
|
|
);
|
|
}
|