Compare commits
7 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
f7cbcb793c | |
|
|
4d9f412a1f | |
|
|
a4c20a8e43 | |
|
|
900af9f395 | |
|
|
a849293bd0 | |
|
|
83075c8791 | |
|
|
25f5c4240e |
|
|
@ -0,0 +1,109 @@
|
||||||
|
/* Same sync check as verify-sync.cjs, but on a Project board (verifies the
|
||||||
|
projectId branch of getBoardSnapshot + the provider's scoping). */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9342;
|
||||||
|
const BASE = "http://localhost:3210";
|
||||||
|
|
||||||
|
function httpJson(p) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
|
||||||
|
let d = ""; res.on("data", (c) => (d += c));
|
||||||
|
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||||
|
});
|
||||||
|
req.on("error", reject); req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
function sql(statement) {
|
||||||
|
execFileSync("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-c", statement]);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const devUserId = execFileSync("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-tAc", "select id from \"User\" where email='dev@example.com'"]).toString().trim();
|
||||||
|
|
||||||
|
// Test project with one open to-do (clean slate regardless of prior runs).
|
||||||
|
sql(`delete from "Todo" where id='ptest-todo'; delete from "Group" where id='ptest-grp'; delete from "Category" where id='ptest-cat'; delete from "Project" where id='ptest-proj';`);
|
||||||
|
sql(`insert into "Project" (id, title, "ownerId", "createdAt", "updatedAt") values ('ptest-proj', 'Sync Test Project', '${devUserId}', now(), now());`);
|
||||||
|
sql(`insert into "Category" (id, name, "order", "projectId", "createdAt", "updatedAt") values ('ptest-cat', 'Lane A', 0, 'ptest-proj', now(), now());`);
|
||||||
|
sql(`insert into "Group" (id, title, color, "order", "noteContent", "categoryId", "createdAt", "updatedAt") values ('ptest-grp', 'P group', 'ocean', 0, '', 'ptest-cat', now(), now());`);
|
||||||
|
sql(`insert into "Todo" (id, title, "order", "groupId", "createdAt", "updatedAt") values ('ptest-todo', 'Project milk', 0, 'ptest-grp', now(), now());`);
|
||||||
|
|
||||||
|
const profile = fs.mkdtempSync("/tmp/edge-psync-");
|
||||||
|
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||||
|
let targets = null;
|
||||||
|
for (let i = 0; i < 40; i++) { await sleep(500); try { targets = await httpJson("/json/list"); break; } catch {} }
|
||||||
|
if (!targets) { console.error("no targets"); process.exit(1); }
|
||||||
|
const page = targets.find((t) => t.type === "page");
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise((r) => (ws.onopen = r));
|
||||||
|
let id = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
const send = (method, params = {}) => new Promise((resolve) => {
|
||||||
|
const myId = ++id; pending.set(myId, resolve);
|
||||||
|
ws.send(JSON.stringify({ id: myId, method, params }));
|
||||||
|
});
|
||||||
|
const errors = [];
|
||||||
|
ws.onmessage = (m) => {
|
||||||
|
const msg = JSON.parse(m.data);
|
||||||
|
if (msg.id) { const r = pending.get(msg.id); if (r) { pending.delete(msg.id); r(msg.result ?? msg.error); } return; }
|
||||||
|
if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
|
||||||
|
const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ");
|
||||||
|
if (!/hydrated|React DevTools|HMR|Download the React DevTools/.test(txt)) errors.push(txt.slice(0, 200));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const evalJs = (expression) =>
|
||||||
|
send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })
|
||||||
|
.then((r) => (r?.result ? r.result.value : r));
|
||||||
|
await send("Runtime.enable");
|
||||||
|
|
||||||
|
await send("Page.enable");
|
||||||
|
await send("Page.navigate", { url: `${BASE}/login` });
|
||||||
|
for (let i = 0; i < 60; i++) { await sleep(500); if (await evalJs("document.readyState") === "complete") break; }
|
||||||
|
const login = await evalJs(`(async () => {
|
||||||
|
const c = await (await fetch('/api/auth/csrf')).json();
|
||||||
|
const r = await fetch('/api/auth/callback/credentials', { method: 'POST', redirect: 'manual',
|
||||||
|
body: new URLSearchParams({ csrfToken: c.csrfToken, email: 'dev@example.com', password: 'password123' }) });
|
||||||
|
return r.status; })()`);
|
||||||
|
console.log("login status:", login);
|
||||||
|
|
||||||
|
await send("Page.navigate", { url: `${BASE}/projects/ptest-proj` });
|
||||||
|
let checkboxFound = false;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await sleep(500);
|
||||||
|
checkboxFound = await evalJs(`!!Array.from(document.querySelectorAll('[data-todo-checkbox]'))
|
||||||
|
.some((el) => (el.getAttribute('aria-label') || '').includes('Project milk'))`);
|
||||||
|
if (checkboxFound) break;
|
||||||
|
}
|
||||||
|
if (!checkboxFound) { console.error("FAIL: project board checkbox not found"); process.exit(1); }
|
||||||
|
console.log("Project milk before:", await evalJs(`Array.from(document.querySelectorAll('[data-todo-checkbox]'))
|
||||||
|
.find((el) => (el.getAttribute('aria-label') || '').includes('Project milk')).getAttribute('aria-checked')`));
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
console.log("simulating phone: completing the project's to-do in the DB...");
|
||||||
|
sql(`update "Todo" set "completed"=true, "completedAt"=now() where id='ptest-todo';`);
|
||||||
|
let done = false;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await sleep(1000);
|
||||||
|
const state = await evalJs(`(() => {
|
||||||
|
const el = Array.from(document.querySelectorAll('[data-todo-checkbox]'))
|
||||||
|
.find((el) => (el.getAttribute('aria-label') || '').includes('Project milk'));
|
||||||
|
if (el) return el.getAttribute('aria-checked');
|
||||||
|
return document.querySelector('[aria-label="Expand P group"]') ? "collapsed-done" : "missing";
|
||||||
|
})()`);
|
||||||
|
if (state === "true" || state === "collapsed-done") { done = true; break; }
|
||||||
|
}
|
||||||
|
const elapsed = Math.round((Date.now() - t0) / 1000);
|
||||||
|
console.log(`project to-do after (${elapsed}s): ${done ? "synced" : "NOT synced"}`);
|
||||||
|
|
||||||
|
sql(`delete from "Todo" where id='ptest-todo'; delete from "Group" where id='ptest-grp'; delete from "Category" where id='ptest-cat'; delete from "Project" where id='ptest-proj';`);
|
||||||
|
console.log("console errors:", errors.length ? errors : "none");
|
||||||
|
const ok = done && errors.length === 0;
|
||||||
|
console.log(ok ? "ALL PASS" : "FAILURES PRESENT");
|
||||||
|
ws.close(); proc.kill();
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1); });
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
/* Verifies cross-device board sync: a "second device" (direct DB write)
|
||||||
|
changes a to-do, and the open desktop tab must pick it up via the 30s
|
||||||
|
poll -- and immediately when the tab regains visibility. */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9341;
|
||||||
|
const BASE = "http://localhost:3210";
|
||||||
|
|
||||||
|
function httpJson(p) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
|
||||||
|
let d = ""; res.on("data", (c) => (d += c));
|
||||||
|
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||||
|
});
|
||||||
|
req.on("error", reject); req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function dbTodo(title, completed) {
|
||||||
|
// Simulates the phone: a change that lands in the DB, not this tab.
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const { execFile } = require("node:child_process");
|
||||||
|
const sql = `UPDATE "Todo" SET "completed"=${completed}, "completedAt"=${completed ? "now()" : "NULL"} WHERE title='${title}' AND "completed"=${!completed};`;
|
||||||
|
execFile("docker", ["exec", "organize-polltest-db", "psql", "-U", "organize", "-d", "organize", "-c", sql], (err, out, errOut) => {
|
||||||
|
resolve({ ok: !err, out, errOut });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const profile = fs.mkdtempSync("/tmp/edge-sync-");
|
||||||
|
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||||
|
let targets = null;
|
||||||
|
for (let i = 0; i < 40; i++) { await sleep(500); try { targets = await httpJson("/json/list"); break; } catch {} }
|
||||||
|
if (!targets) { console.error("no targets"); process.exit(1); }
|
||||||
|
const page = targets.find((t) => t.type === "page");
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise((r) => (ws.onopen = r));
|
||||||
|
let id = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
const send = (method, params = {}) => new Promise((resolve) => {
|
||||||
|
const myId = ++id; pending.set(myId, resolve);
|
||||||
|
ws.send(JSON.stringify({ id: myId, method, params }));
|
||||||
|
});
|
||||||
|
const consoleErrors = [];
|
||||||
|
ws.onmessage = (m) => {
|
||||||
|
const msg = JSON.parse(m.data);
|
||||||
|
if (msg.id) { const r = pending.get(msg.id); if (r) { pending.delete(msg.id); r(msg.result ?? msg.error); } return; }
|
||||||
|
if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
|
||||||
|
const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ");
|
||||||
|
if (!/hydrated|React DevTools|HMR|Download the React DevTools/.test(txt)) consoleErrors.push(txt.slice(0, 200));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const evalJs = (expression) =>
|
||||||
|
send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })
|
||||||
|
.then((r) => (r?.result ? r.result.value : r));
|
||||||
|
const goto = async (url) => {
|
||||||
|
await send("Page.enable");
|
||||||
|
await send("Page.navigate", { url });
|
||||||
|
// Wait for load to settle.
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await sleep(500);
|
||||||
|
const s = await evalJs("document.readyState");
|
||||||
|
if (s === "complete") return;
|
||||||
|
}
|
||||||
|
throw new Error("page did not load: " + url);
|
||||||
|
};
|
||||||
|
const checkboxState = (title) =>
|
||||||
|
evalJs(`(() => {
|
||||||
|
const el = Array.from(document.querySelectorAll('[data-todo-checkbox]'))
|
||||||
|
.find((el) => (el.getAttribute('aria-label') || '').includes(${JSON.stringify(title)}));
|
||||||
|
return el ? el.getAttribute('aria-checked') : "missing";
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
await send("Runtime.enable");
|
||||||
|
|
||||||
|
// --- Login ---
|
||||||
|
await goto(`${BASE}/login`);
|
||||||
|
const login = await evalJs(`(async () => {
|
||||||
|
const c = await (await fetch('/api/auth/csrf')).json();
|
||||||
|
const r = await fetch('/api/auth/callback/credentials', {
|
||||||
|
method: 'POST', redirect: 'manual',
|
||||||
|
body: new URLSearchParams({ csrfToken: c.csrfToken, email: 'dev@example.com', password: 'password123' }),
|
||||||
|
});
|
||||||
|
return r.status;
|
||||||
|
})()`);
|
||||||
|
console.log("login status:", login);
|
||||||
|
|
||||||
|
// --- Reset test data before the board loads (idempotent) ---
|
||||||
|
await dbTodo("Milk", false);
|
||||||
|
await dbTodo("Eggs", false);
|
||||||
|
|
||||||
|
// --- Open the Home board ---
|
||||||
|
await goto(`${BASE}/`);
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await sleep(500);
|
||||||
|
if (await evalJs(`!!document.querySelector('[data-todo-checkbox]')`)) break;
|
||||||
|
}
|
||||||
|
console.log("Milk before:", await checkboxState('Milk'));
|
||||||
|
if ((await checkboxState("Milk")) === "missing") { console.error("FAIL: Milk checkbox not found"); process.exit(1); }
|
||||||
|
|
||||||
|
// --- Test 1: 30s poll picks up a remote change ---
|
||||||
|
const t0 = Date.now();
|
||||||
|
console.log("simulating phone: checking off 'Milk' in the DB...");
|
||||||
|
await dbTodo("Milk", true);
|
||||||
|
let saw = "missing";
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await sleep(1000);
|
||||||
|
saw = await checkboxState("Milk");
|
||||||
|
if (saw === "true") break;
|
||||||
|
}
|
||||||
|
const elapsed = Math.round((Date.now() - t0) / 1000);
|
||||||
|
console.log(`Milk after (${elapsed}s):`, saw);
|
||||||
|
const test1 = saw === "true" && elapsed <= 45;
|
||||||
|
console.log(test1 ? "PASS: 30s poll applied the remote change" : "FAIL: poll did not apply the remote change");
|
||||||
|
|
||||||
|
// --- Test 2: hidden tab skips, visibilitychange catches up ---
|
||||||
|
await evalJs(`Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })`);
|
||||||
|
// Let a poll tick fire while "hidden" (interval is 30s; wait 32s).
|
||||||
|
await sleep(32000);
|
||||||
|
console.log("simulating phone: checking off 'Eggs' in the DB (tab hidden)...");
|
||||||
|
await dbTodo("Eggs", true);
|
||||||
|
await sleep(2000);
|
||||||
|
const whileHidden = await checkboxState("Eggs");
|
||||||
|
console.log("Eggs while still hidden:", whileHidden);
|
||||||
|
await evalJs(`Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); document.dispatchEvent(new Event('visibilitychange'))`);
|
||||||
|
// When the sync lands, 'Groceries' becomes fully completed and the card
|
||||||
|
// collapses (its checkboxes disappear from the DOM, replaced by an
|
||||||
|
// "Expand Groceries" disclosure) -- that is exactly how a fully-checked
|
||||||
|
// group renders, so assert the collapsed card rather than the checkbox.
|
||||||
|
let settled = false;
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await sleep(1000);
|
||||||
|
const state = await checkboxState("Eggs");
|
||||||
|
const collapsed = await evalJs(`!!document.querySelector('[aria-label="Expand Groceries"]')`);
|
||||||
|
if (state === "missing" && collapsed) { settled = true; break; }
|
||||||
|
if (state === "true") { settled = true; break; }
|
||||||
|
}
|
||||||
|
const test2 = settled && whileHidden === "false";
|
||||||
|
console.log(`Eggs group after tab shown: ${settled ? "synced (card collapsed as fully-complete)" : "NOT synced"}`);
|
||||||
|
console.log(test2 ? "PASS: visibilitychange triggered immediate catch-up" : "FAIL: visibility catch-up");
|
||||||
|
|
||||||
|
console.log("console errors:", consoleErrors.length ? consoleErrors : "none");
|
||||||
|
const ok = test1 && test2 && consoleErrors.length === 0;
|
||||||
|
console.log(ok ? "ALL PASS" : "FAILURES PRESENT");
|
||||||
|
ws.close(); proc.kill();
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1); });
|
||||||
22
README.md
22
README.md
|
|
@ -19,6 +19,10 @@ given instance automatically becomes its administrator.
|
||||||
- Per-Group markdown notes and a to-do list with a completion progress
|
- Per-Group markdown notes and a to-do list with a completion progress
|
||||||
indicator
|
indicator
|
||||||
- Light/dark theme (follows system by default)
|
- Light/dark theme (follows system by default)
|
||||||
|
- **Per-account theme**: the theme you pick (theme menu, bottom-left) is
|
||||||
|
saved on that account's profile, so each account keeps its own look —
|
||||||
|
after an account toggle you land in the other account's theme. Applies
|
||||||
|
from the first frame of each page load (no flash)
|
||||||
- Installable PWA (add-to-home-screen); requires a live connection to the
|
- Installable PWA (add-to-home-screen); requires a live connection to the
|
||||||
server for its database, so there's no offline mode
|
server for its database, so there's no offline mode
|
||||||
- Credentials-based accounts (email + password), with an Admin page to:
|
- Credentials-based accounts (email + password), with an Admin page to:
|
||||||
|
|
@ -26,6 +30,24 @@ given instance automatically becomes its administrator.
|
||||||
password, or delete their account
|
password, or delete their account
|
||||||
- control how the site handles new sign-ups (see [Sign-up modes](#sign-up-modes)
|
- control how the site handles new sign-ups (see [Sign-up modes](#sign-up-modes)
|
||||||
below)
|
below)
|
||||||
|
- **Profile page** (`/profile`): set a first/last name and a profile photo,
|
||||||
|
shown in the menu on the left (the photo replaces the initial-letter
|
||||||
|
avatar when set). The account's default theme lives on the profile too —
|
||||||
|
pick it from the theme menu (bottom-left of the sidebar); it persists
|
||||||
|
per account, not per browser
|
||||||
|
- **Account linking** (Profile page): link multiple accounts on the same
|
||||||
|
server so one person can juggle more than one identity.
|
||||||
|
- *Request Account Link* — ask to link by the other account's email;
|
||||||
|
the request shows "awaiting confirmation" until they respond
|
||||||
|
- *Requested Account Link* — the recipient's list of pending requests,
|
||||||
|
with **Create Account Link**, **Deny Account Link**, or
|
||||||
|
**Deny and Block Account Link** (blocks that account from requesting
|
||||||
|
again; blocks can be lifted from the *Blocked Account Link Requests*
|
||||||
|
section)
|
||||||
|
- *Linked Accounts* — the other side of each confirmed link, with
|
||||||
|
**Toggle** (signs this browser out and back in as that account — it
|
||||||
|
never asks for that account's password) and **Remove Link** (either
|
||||||
|
linked account can do it)
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,22 +23,36 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
// board (categories/groups/todos) is fetched separately by its own page.
|
// board (categories/groups/todos) is fetched separately by its own page.
|
||||||
// `theme` is stored as free text; the set of valid names is enforced
|
// `theme` is stored as free text; the set of valid names is enforced
|
||||||
// client-side (ProjectThemeSchema) so a plain assertion is safe here.
|
// client-side (ProjectThemeSchema) so a plain assertion is safe here.
|
||||||
const projects: ProjectDTO[] = (
|
const [projects, profile] = await Promise.all([
|
||||||
await prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where: { ownerId: session.user.id },
|
where: { ownerId: session.user.id },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
select: { id: true, title: true, theme: true },
|
select: { id: true, title: true, theme: true },
|
||||||
})
|
}),
|
||||||
).map((p) => ({
|
// The user's profile for the sidebar's bottom user block (name + photo,
|
||||||
|
// both optional); the /profile page fetches the same row itself.
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { firstName: true, lastName: true, avatar: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const projectList: ProjectDTO[] = projects.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
theme: p.theme as ThemeName | null,
|
theme: p.theme as ThemeName | null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const userName =
|
||||||
|
[profile?.firstName, profile?.lastName]
|
||||||
|
.filter((part): part is string => Boolean(part && part.trim()))
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.join(" ") || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideNavProvider>
|
<SideNavProvider>
|
||||||
<ScheduledPanelProvider>
|
<ScheduledPanelProvider>
|
||||||
<ProjectsProvider initialProjects={projects}>
|
<ProjectsProvider initialProjects={projectList}>
|
||||||
<BoardViewProvider>
|
<BoardViewProvider>
|
||||||
<HoldOnCompleteProvider>
|
<HoldOnCompleteProvider>
|
||||||
{/* Responsive shell:
|
{/* Responsive shell:
|
||||||
|
|
@ -51,7 +65,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
toolbar instead of the stale 100vh. */}
|
toolbar instead of the stale 100vh. */}
|
||||||
<div className="flex h-dvh flex-col md:h-screen md:min-w-[860px] md:flex-row md:overflow-x-auto">
|
<div className="flex h-dvh flex-col md:h-screen md:min-w-[860px] md:flex-row md:overflow-x-auto">
|
||||||
<MobileTopBar />
|
<MobileTopBar />
|
||||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
<SideNav
|
||||||
|
userEmail={session.user.email ?? ""}
|
||||||
|
userName={userName}
|
||||||
|
avatar={profile?.avatar ?? null}
|
||||||
|
role={session.user.role}
|
||||||
|
/>
|
||||||
<main className="min-h-0 flex-1 overflow-auto">{children}</main>
|
<main className="min-h-0 flex-1 overflow-auto">{children}</main>
|
||||||
<ScheduledPanel />
|
<ScheduledPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type {
|
||||||
|
BlockedRequesterDTO,
|
||||||
|
LinkedAccountDTO,
|
||||||
|
PendingLinkRequestDTO,
|
||||||
|
ProfileDTO,
|
||||||
|
} from "@/types/profile";
|
||||||
|
import { ProfileForm } from "@/components/profile/profile-form";
|
||||||
|
import { RequestLinkForm } from "@/components/profile/request-link-form";
|
||||||
|
import { PendingLinkRequests } from "@/components/profile/pending-link-requests";
|
||||||
|
import { LinkedAccounts } from "@/components/profile/linked-accounts";
|
||||||
|
import { BlockedLinkRequests } from "@/components/profile/blocked-link-requests";
|
||||||
|
|
||||||
|
const LINK_ACCOUNT_SELECT = {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatar: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type LinkAccountRow = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Display name for another account: profile first/last name when set,
|
||||||
|
* falling back to the legacy sign-up name. */
|
||||||
|
function identity(row: LinkAccountRow) {
|
||||||
|
const full = [row.firstName, row.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: full || row.name?.trim() || null,
|
||||||
|
avatar: row.avatar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateLabel(date: Date) {
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const session = await auth();
|
||||||
|
// Defense in depth: proxy.ts already redirects unauthenticated requests,
|
||||||
|
// but every protected data boundary should check for itself too.
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const [user, pendingRequests, links, blocks] = await Promise.all([
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { name: true, firstName: true, lastName: true, avatar: true },
|
||||||
|
}),
|
||||||
|
// Requests addressed to this account -- the ones it can act on.
|
||||||
|
prisma.accountLinkRequest.findMany({
|
||||||
|
where: { toUserId: userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { fromUser: { select: LINK_ACCOUNT_SELECT } },
|
||||||
|
}),
|
||||||
|
// Confirmed links in either direction; the other side of the pair is
|
||||||
|
// whichever isn't this account.
|
||||||
|
prisma.accountLink.findMany({
|
||||||
|
where: { OR: [{ userId }, { linkedUserId: userId }] },
|
||||||
|
include: {
|
||||||
|
user: { select: LINK_ACCOUNT_SELECT },
|
||||||
|
linkedUser: { select: LINK_ACCOUNT_SELECT },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Blocks this account issued: who is barred from requesting it.
|
||||||
|
prisma.accountLinkBlock.findMany({
|
||||||
|
where: { toUserId: userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { fromUser: { select: LINK_ACCOUNT_SELECT } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
const profile: ProfileDTO = {
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
avatar: user.avatar,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending: PendingLinkRequestDTO[] = pendingRequests.map((request) => ({
|
||||||
|
requestId: request.id,
|
||||||
|
requestedAtLabel: dateLabel(request.createdAt),
|
||||||
|
...identity(request.fromUser),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const linked: LinkedAccountDTO[] = links.map((link) => ({
|
||||||
|
linkId: link.id,
|
||||||
|
...identity(link.userId === userId ? link.linkedUser : link.user),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const blocked: BlockedRequesterDTO[] = blocks.map((block) => ({
|
||||||
|
blockId: block.id,
|
||||||
|
blockedAtLabel: dateLabel(block.createdAt),
|
||||||
|
...identity(block.fromUser),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-4 p-4">
|
||||||
|
<div className="px-1">
|
||||||
|
<h1 className="font-heading text-xl font-bold tracking-tight">Profile</h1>
|
||||||
|
<p className="mt-0.5 text-[13px] text-muted-foreground">
|
||||||
|
Your name and photo, shown in the menu on the left — and your
|
||||||
|
links to other accounts on this server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProfileForm initial={profile} />
|
||||||
|
|
||||||
|
<div className="flex max-w-xl flex-col gap-4">
|
||||||
|
<RequestLinkForm />
|
||||||
|
{pending.length > 0 && <PendingLinkRequests requests={pending} />}
|
||||||
|
{linked.length > 0 && <LinkedAccounts accounts={linked} />}
|
||||||
|
{blocked.length > 0 && <BlockedLinkRequests blocks={blocked} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,9 @@ import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { RegisterServiceWorker } from "@/components/pwa/register-sw";
|
import { RegisterServiceWorker } from "@/components/pwa/register-sw";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { themeInitScript, type RawTheme } from "@/lib/themes";
|
||||||
|
|
||||||
/** UI type: Inter -- a neutral, highly legible humanist sans that reads
|
/** UI type: Inter -- a neutral, highly legible humanist sans that reads
|
||||||
* cleanly at small sizes (to-do lists, tables, nav). */
|
* cleanly at small sizes (to-do lists, tables, nav). */
|
||||||
|
|
@ -94,7 +97,28 @@ export const viewport: Viewport = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
// Per-user global theme (stored on the profile row; set from the theme
|
||||||
|
// menu, see saveUserTheme in lib/actions/profile.ts). When signed in it
|
||||||
|
// is authoritative: the no-FOUC script below applies it before first
|
||||||
|
// paint and the ThemeProvider starts from it and persists changes back
|
||||||
|
// -- so each account in a linked set keeps its own look in the same
|
||||||
|
// browser across account toggles (a toggle is a full navigation, and
|
||||||
|
// this layout re-renders for the new user). Anonymous visitors keep the
|
||||||
|
// old localStorage-based behavior.
|
||||||
|
const session = await auth();
|
||||||
|
let userTheme: RawTheme | undefined;
|
||||||
|
if (session?.user) {
|
||||||
|
// Stored as free text; valid values are enforced by ThemeSchema
|
||||||
|
// (lib/validation/profile.ts) at write time, so the assertion is safe
|
||||||
|
// -- same pattern as Project.theme.
|
||||||
|
const profile = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { theme: true },
|
||||||
|
});
|
||||||
|
userTheme = (profile?.theme as RawTheme | null) ?? "system";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
|
|
@ -110,10 +134,13 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
their own fonts, backgrounds, and animations.
|
their own fonts, backgrounds, and animations.
|
||||||
"system" resolves to Default/Dark by OS preference.
|
"system" resolves to Default/Dark by OS preference.
|
||||||
No-FOUC theme restore: runs before first paint, applies the
|
No-FOUC theme restore: runs before first paint, applies the
|
||||||
stored preference to <html> (class + color-scheme). */}
|
right class + color-scheme to <html>. Signed-in users get
|
||||||
|
their profile's stored theme (per account, so it survives
|
||||||
|
account toggles); anonymous visitors get the browser's
|
||||||
|
localStorage preference -- see themeInitScript in lib/themes.ts. */}
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `!(function(){try{var r=document.documentElement,c=['theme-default','theme-sunset','theme-meadow','theme-honey','theme-rose','theme-lavender','theme-slate','theme-blueprint','theme-vaporwave','theme-notebook','dark','ocean','theme-pine','theme-plum','theme-midnight','theme-ember','theme-rosewood','theme-cyberpunk','theme-starfield','light'];for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}var v=null;try{v=localStorage.getItem('theme');}catch(e){}var m={default:'theme-default',sunset:'theme-sunset',meadow:'theme-meadow',honey:'theme-honey',rose:'theme-rose',lavender:'theme-lavender',slate:'theme-slate',blueprint:'theme-blueprint',vaporwave:'theme-vaporwave',notebook:'theme-notebook',dark:'dark',ocean:'ocean',pine:'theme-pine',plum:'theme-plum',midnight:'theme-midnight',ember:'theme-ember',rosewood:'theme-rosewood',cyberpunk:'theme-cyberpunk',starfield:'theme-starfield'};var d=['dark','ocean','pine','plum','midnight','ember','rosewood','cyberpunk','starfield'];if(v==='system'||!v){v=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'default';}if(m[v]){r.classList.add(m[v]);r.style.colorScheme=d.indexOf(v)>=0?'dark':'light';}}catch(e){}})();`,
|
__html: themeInitScript(userTheme),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Vaporwave scenery (see app/globals.css): the outrun floor grid,
|
{/* Vaporwave scenery (see app/globals.css): the outrun floor grid,
|
||||||
|
|
@ -146,7 +173,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
<svg className="vw-palm vw-palm--right-small" aria-hidden>
|
<svg className="vw-palm vw-palm--right-small" aria-hidden>
|
||||||
<use href="#vw-palm" />
|
<use href="#vw-palm" />
|
||||||
</svg>
|
</svg>
|
||||||
<ThemeProvider defaultTheme="system">
|
<ThemeProvider userTheme={userTheme}>
|
||||||
<TooltipProvider delay={200}>
|
<TooltipProvider delay={200}>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
|
||||||
47
auth.ts
47
auth.ts
|
|
@ -5,7 +5,7 @@ import bcrypt from "bcryptjs";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { LoginSchema } from "@/lib/validation/auth";
|
import { LoginSchema } from "@/lib/validation/auth";
|
||||||
import { Role } from "@/lib/generated/prisma/enums";
|
import { Role } from "@/lib/generated/prisma/enums";
|
||||||
import { PendingAccountSignin } from "@/lib/auth-errors";
|
import { PendingAccountSignin, SwitchAccountSignin } from "@/lib/auth-errors";
|
||||||
|
|
||||||
// Credentials-only, JWT sessions, no database adapter: with a single
|
// Credentials-only, JWT sessions, no database adapter: with a single
|
||||||
// Credentials provider and no OAuth, there's nothing for a DB-backed
|
// Credentials provider and no OAuth, there's nothing for a DB-backed
|
||||||
|
|
@ -20,7 +20,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
signIn: "/login",
|
signIn: "/login",
|
||||||
},
|
},
|
||||||
providers: [
|
providers: [
|
||||||
|
// Explicit id: with a second Credentials provider ("account-switch"
|
||||||
|
// below) the providers must be disambiguated by id, and existing
|
||||||
|
// signIn("credentials", ...) calls keep working against this one.
|
||||||
Credentials({
|
Credentials({
|
||||||
|
id: "credentials",
|
||||||
credentials: {
|
credentials: {
|
||||||
email: {},
|
email: {},
|
||||||
password: {},
|
password: {},
|
||||||
|
|
@ -40,6 +44,47 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
// email confirmation) and can't log in yet -- see lib/settings.ts.
|
// email confirmation) and can't log in yet -- see lib/settings.ts.
|
||||||
if (user.role === Role.PENDING) throw new PendingAccountSignin();
|
if (user.role === Role.PENDING) throw new PendingAccountSignin();
|
||||||
|
|
||||||
|
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Signs a user in as one of the accounts their own account is linked
|
||||||
|
// to -- the Profile page "Toggle" button -- without knowing that
|
||||||
|
// account's password. The only credential accepted is a one-time
|
||||||
|
// token minted by toggleAccount (lib/actions/account-links.ts)
|
||||||
|
// *after* verifying the link exists, so holding a token is what
|
||||||
|
// proves eligibility; this provider just exchanges it for a session.
|
||||||
|
// The token is consumed here (usedAt set) so a replayed
|
||||||
|
// /api/auth/callback/account-switch request can never switch twice.
|
||||||
|
Credentials({
|
||||||
|
id: "account-switch",
|
||||||
|
name: "AccountSwitch",
|
||||||
|
credentials: {
|
||||||
|
token: {},
|
||||||
|
},
|
||||||
|
async authorize(rawCredentials) {
|
||||||
|
const token = typeof rawCredentials?.token === "string" ? rawCredentials.token : "";
|
||||||
|
if (!token) throw new SwitchAccountSignin();
|
||||||
|
|
||||||
|
const row = await prisma.accountSwitchToken.findUnique({ where: { token } });
|
||||||
|
if (!row || row.usedAt || row.expiresAt <= new Date()) {
|
||||||
|
throw new SwitchAccountSignin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically claim the token. If a concurrent request already did,
|
||||||
|
// count is 0 and this attempt fails even though the read above passed.
|
||||||
|
const claimed = await prisma.accountSwitchToken.updateMany({
|
||||||
|
where: { id: row.id, usedAt: null },
|
||||||
|
data: { usedAt: new Date() },
|
||||||
|
});
|
||||||
|
if (claimed.count === 0) throw new SwitchAccountSignin();
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: row.targetUserId } });
|
||||||
|
if (!user) throw new SwitchAccountSignin();
|
||||||
|
|
||||||
|
// Same rule as the credentials provider: PENDING accounts can't
|
||||||
|
// hold a session at all.
|
||||||
|
if (user.role === Role.PENDING) throw new SwitchAccountSignin();
|
||||||
|
|
||||||
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||||
import { ColorSwatchPicker } from "@/components/board/color-swatch-picker";
|
import { ColorSwatchPicker } from "@/components/board/color-swatch-picker";
|
||||||
import { useBoard } from "@/components/board/board-context";
|
import { useBoard } from "@/components/board/board-context";
|
||||||
import { useBoardView } from "@/components/board/board-view-provider";
|
import { useBoardView } from "@/components/board/board-view-provider";
|
||||||
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete";
|
||||||
import { DEFAULT_GROUP_COLOR_KEY, type GroupColorKey } from "@/lib/colors";
|
import { DEFAULT_GROUP_COLOR_KEY, type GroupColorKey } from "@/lib/colors";
|
||||||
|
|
||||||
const TITLE_MAX = 20;
|
const TITLE_MAX = 20;
|
||||||
// Compact hides any group with nothing open in it -- without a hold, a
|
|
||||||
// brand-new (necessarily empty) group would never appear at all. Longer
|
|
||||||
// than the usual 6s grace period since there's no accidental click to
|
|
||||||
// forgive here; this is purely "give it a moment to be noticed / add a
|
|
||||||
// to-do to it before it disappears".
|
|
||||||
const NEW_GROUP_HOLD_MS = 15_000;
|
|
||||||
|
|
||||||
export function AddGroupPopover({ categoryId }: { categoryId: string }) {
|
export function AddGroupPopover({ categoryId }: { categoryId: string }) {
|
||||||
const { addGroup } = useBoard();
|
const { addGroup } = useBoard();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback } from "react";
|
import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board";
|
import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board";
|
||||||
|
|
@ -25,6 +25,13 @@ import {
|
||||||
deleteTodo as deleteTodoAction,
|
deleteTodo as deleteTodoAction,
|
||||||
} from "@/lib/actions/todos";
|
} from "@/lib/actions/todos";
|
||||||
import { updateGroupNote } from "@/lib/actions/notes";
|
import { updateGroupNote } from "@/lib/actions/notes";
|
||||||
|
import { getBoardSnapshot } from "@/lib/actions/board";
|
||||||
|
|
||||||
|
// How often a visible tab checks for changes made on another device (see
|
||||||
|
// the sync effect in BoardProvider). Deliberately not real time: a phone
|
||||||
|
// tap lands here within a tick at most -- and immediately if the tab only
|
||||||
|
// just came to the foreground.
|
||||||
|
const POLL_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
interface BoardContextValue {
|
interface BoardContextValue {
|
||||||
categories: CategoryDTO[];
|
categories: CategoryDTO[];
|
||||||
|
|
@ -75,6 +82,10 @@ interface BoardContextValue {
|
||||||
completed: boolean
|
completed: boolean
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
removeTodo: (todoId: string, groupId: string, categoryId: string) => Promise<void>;
|
removeTodo: (todoId: string, groupId: string, categoryId: string) => Promise<void>;
|
||||||
|
// True while a dnd-kit drag is live -- a state replacement mid-drag would
|
||||||
|
// swap the SortableContext items out from under it, so remote sync
|
||||||
|
// defers until the drag ends.
|
||||||
|
suspendRemoteSync: (suspended: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BoardContext = createContext<BoardContextValue | null>(null);
|
const BoardContext = createContext<BoardContextValue | null>(null);
|
||||||
|
|
@ -96,6 +107,94 @@ export function BoardProvider({
|
||||||
}) {
|
}) {
|
||||||
const [categories, setCategories] = useState(initialCategories);
|
const [categories, setCategories] = useState(initialCategories);
|
||||||
|
|
||||||
|
// -- Cross-device sync ---------------------------------------------------
|
||||||
|
// The board is seeded once from the server and otherwise only changed by
|
||||||
|
// this tab's own optimistic mutations, so edits made on a second device
|
||||||
|
// (checking off a to-do on a phone, renaming a lane, ...) never reach
|
||||||
|
// this tab on their own. The poll below closes that gap: fetch a fresh
|
||||||
|
// snapshot and, if it differs from local state and no local write is
|
||||||
|
// pending or just landed, apply it. The refs are the guards that keep a
|
||||||
|
// poll from ever clobbering local work.
|
||||||
|
// Always mirrors `categories` for the diff below (updated by the effect
|
||||||
|
// under it rather than every setCategories call site).
|
||||||
|
const categoriesRef = useRef(categories);
|
||||||
|
// Local mutations in flight (optimistic state ahead of the server).
|
||||||
|
const inflightRef = useRef(0);
|
||||||
|
// True while a drag is live (set from Board's drag start/end).
|
||||||
|
const remoteSyncSuspendedRef = useRef(false);
|
||||||
|
// A poll is already running.
|
||||||
|
const pollingRef = useRef(false);
|
||||||
|
// When the most recent local mutation settled (success or rollback).
|
||||||
|
const lastLocalWriteAtRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
categoriesRef.current = categories;
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
// Marks a server action as "local work in progress" for the poll's
|
||||||
|
// duration, and stamps when it settled. Every mutation below goes
|
||||||
|
// through this so the sync effect can tell "state we are about to lose"
|
||||||
|
// from "state we will happily overwrite with the server's copy".
|
||||||
|
const track = useCallback(
|
||||||
|
<T,>(promise: Promise<T>): Promise<T> => {
|
||||||
|
inflightRef.current += 1;
|
||||||
|
return promise.finally(() => {
|
||||||
|
inflightRef.current -= 1;
|
||||||
|
lastLocalWriteAtRef.current = Date.now();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const suspendRemoteSync = useCallback((suspended: boolean) => {
|
||||||
|
remoteSyncSuspendedRef.current = suspended;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
async function refreshBoard() {
|
||||||
|
if (disposed || pollingRef.current) return;
|
||||||
|
if (remoteSyncSuspendedRef.current || inflightRef.current > 0) return;
|
||||||
|
// No point fetching while the tab is hidden; the visibilitychange
|
||||||
|
// handler below catches up the moment it is shown again.
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
pollingRef.current = true;
|
||||||
|
try {
|
||||||
|
const board = await getBoardSnapshot(projectId ?? null);
|
||||||
|
if (disposed) return;
|
||||||
|
// Re-check the guards at apply time, not just at start: a mutation
|
||||||
|
// (or drag) that began while the fetch was in flight makes this
|
||||||
|
// snapshot stale relative to local state. A write that settled in
|
||||||
|
// the last couple of seconds may post-date the snapshot's read on
|
||||||
|
// the server too, so defer it -- the next tick picks everything up.
|
||||||
|
if (remoteSyncSuspendedRef.current || inflightRef.current > 0) return;
|
||||||
|
if (Date.now() - lastLocalWriteAtRef.current < 2000) return;
|
||||||
|
// The common case is "nothing changed" -- skip the setState so the
|
||||||
|
// whole board doesn't re-render every tick.
|
||||||
|
if (JSON.stringify(board) === JSON.stringify(categoriesRef.current)) return;
|
||||||
|
setCategories(board);
|
||||||
|
} catch {
|
||||||
|
// Offline or a server hiccup -- the next tick retries. (If the
|
||||||
|
// session itself is gone, the page's own navigation handles it.)
|
||||||
|
} finally {
|
||||||
|
pollingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === "visible") void refreshBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
const timer = window.setInterval(() => void refreshBoard(), POLL_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
const updateCategory = useCallback(
|
const updateCategory = useCallback(
|
||||||
(categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => {
|
(categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => {
|
||||||
setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c)));
|
setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c)));
|
||||||
|
|
@ -116,13 +215,13 @@ export function BoardProvider({
|
||||||
const addCategory = useCallback(
|
const addCategory = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
try {
|
try {
|
||||||
const category = await createCategory(name, projectId);
|
const category = await track(createCategory(name, projectId));
|
||||||
setCategories((prev) => [...prev, category]);
|
setCategories((prev) => [...prev, category]);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Couldn't create category. Try again.");
|
toast.error("Couldn't create category. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId]
|
[projectId, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeCategory = useCallback(async (categoryId: string) => {
|
const removeCategory = useCallback(async (categoryId: string) => {
|
||||||
|
|
@ -132,7 +231,7 @@ export function BoardProvider({
|
||||||
return prev.filter((c) => c.id !== categoryId);
|
return prev.filter((c) => c.id !== categoryId);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await deleteCategoryAction(categoryId);
|
const result = await track(deleteCategoryAction(categoryId));
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
|
|
@ -141,7 +240,7 @@ export function BoardProvider({
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't delete category. Try again.");
|
toast.error("Couldn't delete category. Try again.");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [track]);
|
||||||
|
|
||||||
const reorderLanes = useCallback(
|
const reorderLanes = useCallback(
|
||||||
async (orderedIds: string[]) => {
|
async (orderedIds: string[]) => {
|
||||||
|
|
@ -152,36 +251,36 @@ export function BoardProvider({
|
||||||
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
|
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await reorderCategories(orderedIds, projectId);
|
await track(reorderCategories(orderedIds, projectId));
|
||||||
} catch {
|
} catch {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't reorder categories. Try again.");
|
toast.error("Couldn't reorder categories. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId]
|
[projectId, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
|
const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
|
||||||
try {
|
try {
|
||||||
const group = await createGroup(categoryId, title, color);
|
const group = await track(createGroup(categoryId, title, color));
|
||||||
updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] }));
|
updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] }));
|
||||||
return group;
|
return group;
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Couldn't create group. Try again.");
|
toast.error("Couldn't create group. Try again.");
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}, [updateCategory]);
|
}, [updateCategory, track]);
|
||||||
|
|
||||||
const editGroup = useCallback(
|
const editGroup = useCallback(
|
||||||
async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => {
|
async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => {
|
||||||
try {
|
try {
|
||||||
await updateGroupAction(groupId, data);
|
await track(updateGroupAction(groupId, data));
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data }));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Couldn't update group. Try again.");
|
toast.error("Couldn't update group. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateGroupInState]
|
[updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeGroup = useCallback(async (groupId: string, categoryId: string) => {
|
const removeGroup = useCallback(async (groupId: string, categoryId: string) => {
|
||||||
|
|
@ -193,12 +292,12 @@ export function BoardProvider({
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await deleteGroupAction(groupId);
|
await track(deleteGroupAction(groupId));
|
||||||
} catch {
|
} catch {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't delete group. Try again.");
|
toast.error("Couldn't delete group. Try again.");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [track]);
|
||||||
|
|
||||||
const archiveGroup = useCallback(async (groupId: string, categoryId: string) => {
|
const archiveGroup = useCallback(async (groupId: string, categoryId: string) => {
|
||||||
let prevState: CategoryDTO[] = [];
|
let prevState: CategoryDTO[] = [];
|
||||||
|
|
@ -209,12 +308,12 @@ export function BoardProvider({
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await archiveGroupAction(groupId);
|
await track(archiveGroupAction(groupId));
|
||||||
} catch {
|
} catch {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't archive group. Try again.");
|
toast.error("Couldn't archive group. Try again.");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [track]);
|
||||||
|
|
||||||
const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => {
|
const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => {
|
||||||
let prevState: CategoryDTO[] = [];
|
let prevState: CategoryDTO[] = [];
|
||||||
|
|
@ -227,12 +326,12 @@ export function BoardProvider({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await reorderGroupsInCategory(categoryId, orderedIds);
|
await track(reorderGroupsInCategory(categoryId, orderedIds));
|
||||||
} catch {
|
} catch {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't reorder groups. Try again.");
|
toast.error("Couldn't reorder groups. Try again.");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [track]);
|
||||||
|
|
||||||
const moveGroup = useCallback(
|
const moveGroup = useCallback(
|
||||||
async (
|
async (
|
||||||
|
|
@ -273,19 +372,19 @@ export function BoardProvider({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource);
|
await track(moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource));
|
||||||
} catch {
|
} catch {
|
||||||
setCategories(prevState);
|
setCategories(prevState);
|
||||||
toast.error("Couldn't move group. Try again.");
|
toast.error("Couldn't move group. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveNote = useCallback(
|
const saveNote = useCallback(
|
||||||
async (groupId: string, categoryId: string, noteContent: string) => {
|
async (groupId: string, categoryId: string, noteContent: string) => {
|
||||||
try {
|
try {
|
||||||
await updateGroupNote(groupId, noteContent);
|
await track(updateGroupNote(groupId, noteContent));
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent }));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -293,13 +392,13 @@ export function BoardProvider({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateGroupInState]
|
[updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addTodo = useCallback(
|
const addTodo = useCallback(
|
||||||
async (groupId: string, categoryId: string, title: string, details?: string) => {
|
async (groupId: string, categoryId: string, title: string, details?: string) => {
|
||||||
try {
|
try {
|
||||||
const todo = await createTodo(groupId, title, details);
|
const todo = await track(createTodo(groupId, title, details));
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] }));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -307,13 +406,13 @@ export function BoardProvider({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateGroupInState]
|
[updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addTodos = useCallback(
|
const addTodos = useCallback(
|
||||||
async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => {
|
async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => {
|
||||||
try {
|
try {
|
||||||
const created = await createTodos(groupId, todos);
|
const created = await track(createTodos(groupId, todos));
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] }));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -321,7 +420,7 @@ export function BoardProvider({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateGroupInState]
|
[updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const editTodo = useCallback(
|
const editTodo = useCallback(
|
||||||
|
|
@ -332,7 +431,7 @@ export function BoardProvider({
|
||||||
data: { title?: string; details?: string | null }
|
data: { title?: string; details?: string | null }
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await updateTodoAction(todoId, data);
|
await track(updateTodoAction(todoId, data));
|
||||||
// Approximates the server's own `updatedAt` (set by the same write,
|
// Approximates the server's own `updatedAt` (set by the same write,
|
||||||
// a moment later) closely enough for display purposes, without
|
// a moment later) closely enough for display purposes, without
|
||||||
// waiting on a round trip just to read it back.
|
// waiting on a round trip just to read it back.
|
||||||
|
|
@ -347,7 +446,7 @@ export function BoardProvider({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateGroupInState]
|
[updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleTodoDone = useCallback(
|
const toggleTodoDone = useCallback(
|
||||||
|
|
@ -370,13 +469,13 @@ export function BoardProvider({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await toggleTodoAction(todoId, completed);
|
await track(toggleTodoAction(todoId, completed));
|
||||||
} catch {
|
} catch {
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
|
||||||
toast.error("Couldn't update to-do. Try again.");
|
toast.error("Couldn't update to-do. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateCategory, updateGroupInState]
|
[updateCategory, updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeTodo = useCallback(
|
const removeTodo = useCallback(
|
||||||
|
|
@ -391,13 +490,13 @@ export function BoardProvider({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await deleteTodoAction(todoId);
|
await track(deleteTodoAction(todoId));
|
||||||
} catch {
|
} catch {
|
||||||
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
|
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
|
||||||
toast.error("Couldn't delete to-do. Try again.");
|
toast.error("Couldn't delete to-do. Try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateCategory, updateGroupInState]
|
[updateCategory, updateGroupInState, track]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -420,6 +519,7 @@ export function BoardProvider({
|
||||||
editTodo,
|
editTodo,
|
||||||
toggleTodoDone,
|
toggleTodoDone,
|
||||||
removeTodo,
|
removeTodo,
|
||||||
|
suspendRemoteSync,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,7 +32,7 @@ import { getBrighterColor, getComplementaryColor } from "@/lib/colors";
|
||||||
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
||||||
import { useBoard } from "@/components/board/board-context";
|
import { useBoard } from "@/components/board/board-context";
|
||||||
import { useBoardView } from "@/components/board/board-view-provider";
|
import { useBoardView } from "@/components/board/board-view-provider";
|
||||||
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete";
|
||||||
import { NotesDialog } from "@/components/board/notes-dialog";
|
import { NotesDialog } from "@/components/board/notes-dialog";
|
||||||
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
||||||
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
||||||
|
|
@ -89,7 +89,7 @@ function AddTodoMenu({
|
||||||
export function GroupCard({ group }: { group: GroupDTO }) {
|
export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
||||||
const { view } = useBoardView();
|
const { view } = useBoardView();
|
||||||
const { holds, beginHold, cancelHold } = useHoldOnComplete();
|
const { holds, beginHold, cancelHold, pauseHold } = useHoldOnComplete();
|
||||||
const compact = view === "compact";
|
const compact = view === "compact";
|
||||||
// True for any theme whose surfaces are dark -- Dark and Ocean both use
|
// True for any theme whose surfaces are dark -- Dark and Ocean both use
|
||||||
// their `dark` color variant (see lib/colors.ts); light themes use `light`.
|
// their `dark` color variant (see lib/colors.ts); light themes use `light`.
|
||||||
|
|
@ -101,6 +101,39 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
||||||
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
||||||
const [expandedWhileComplete, setExpandedWhileComplete] = useState(false);
|
const [expandedWhileComplete, setExpandedWhileComplete] = useState(false);
|
||||||
|
// True while one of this card's "add to-do" windows is open and we paused
|
||||||
|
// the group's own hold to keep the card (and the window mounted inside it)
|
||||||
|
// from unmounting mid-form -- a brand-new empty group in compact view is
|
||||||
|
// the case that needs it: its creation hold would otherwise expire while
|
||||||
|
// the user is still typing and close the window right out from under them.
|
||||||
|
const addTodoHoldPausedRef = useRef(false);
|
||||||
|
|
||||||
|
// Opens or closes one of this card's "add to-do" windows (the plain
|
||||||
|
// dialog or the AI dialog). While at least one is open, a group that is
|
||||||
|
// on screen only because of its own hold (i.e. still empty) gets that
|
||||||
|
// hold frozen, so the card -- and the window mounted inside it -- can't
|
||||||
|
// be unmounted mid-form. When the last window closes, a still-empty
|
||||||
|
// group gets a fresh grace period to be noticed or filled before finally
|
||||||
|
// fading out; a group the window did fill drops the paused hold entirely
|
||||||
|
// (it stays visible for its own open work, and a leftover hold entry
|
||||||
|
// would otherwise pin it to compact view forever).
|
||||||
|
function handleAddTodoWindow(
|
||||||
|
next: boolean,
|
||||||
|
setter: (open: boolean) => void,
|
||||||
|
otherOpen: boolean
|
||||||
|
) {
|
||||||
|
setter(next);
|
||||||
|
if (next) {
|
||||||
|
if (!otherOpen && compact && holds.has(group.id)) {
|
||||||
|
addTodoHoldPausedRef.current = true;
|
||||||
|
pauseHold(group.id);
|
||||||
|
}
|
||||||
|
} else if (!otherOpen && addTodoHoldPausedRef.current) {
|
||||||
|
addTodoHoldPausedRef.current = false;
|
||||||
|
if (group.todos.some((t) => !t.completed)) cancelHold(group.id);
|
||||||
|
else beginHold(group.id, NEW_GROUP_HOLD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
|
|
@ -341,8 +374,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
<AddTodoMenu
|
<AddTodoMenu
|
||||||
group={group}
|
group={group}
|
||||||
aiConfigured={aiConfigured}
|
aiConfigured={aiConfigured}
|
||||||
onAddClick={() => setTodoCreateOpen(true)}
|
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
|
||||||
onAiClick={() => setTodoAiOpen(true)}
|
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -362,8 +395,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
<div className="mt-1 flex justify-end">
|
<div className="mt-1 flex justify-end">
|
||||||
<AddTodoMenu
|
<AddTodoMenu
|
||||||
group={group}
|
group={group}
|
||||||
onAddClick={() => setTodoCreateOpen(true)}
|
onAddClick={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
|
||||||
onAiClick={() => setTodoAiOpen(true)}
|
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
|
||||||
aiConfigured={aiConfigured}
|
aiConfigured={aiConfigured}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -447,13 +480,17 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
|
|
||||||
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
|
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
|
||||||
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
|
<TodoAiDialog
|
||||||
|
group={group}
|
||||||
|
open={todoAiOpen}
|
||||||
|
onOpenChange={(next) => handleAddTodoWindow(next, setTodoAiOpen, todoCreateOpen)}
|
||||||
|
/>
|
||||||
|
|
||||||
<TodoCreateDialog
|
<TodoCreateDialog
|
||||||
groupId={group.id}
|
groupId={group.id}
|
||||||
categoryId={group.categoryId}
|
categoryId={group.categoryId}
|
||||||
open={todoCreateOpen}
|
open={todoCreateOpen}
|
||||||
onOpenChange={setTodoCreateOpen}
|
onOpenChange={(next) => handleAddTodoWindow(next, setTodoCreateOpen, todoAiOpen)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
|
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ function Board({
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
aiConfigured: boolean;
|
aiConfigured: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
|
const { categories, reorderLanes, reorderGroups, moveGroup, suspendRemoteSync } = useBoard();
|
||||||
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
|
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
|
||||||
const [quickAddOpen, setQuickAddOpen] = useState(false);
|
const [quickAddOpen, setQuickAddOpen] = useState(false);
|
||||||
const pagerRef = useRef<HTMLDivElement>(null);
|
const pagerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -67,6 +67,11 @@ function Board({
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragStart(event: DragStartEvent) {
|
function handleDragStart(event: DragStartEvent) {
|
||||||
|
// Hold cross-device sync off for the drag's duration: replacing the
|
||||||
|
// board state mid-drag would swap the SortableContext items out from
|
||||||
|
// under dnd-kit. Drag ends (including cancelled ones) always fire
|
||||||
|
// handleDragEnd, which resumes it.
|
||||||
|
suspendRemoteSync(true);
|
||||||
const data = event.active.data.current;
|
const data = event.active.data.current;
|
||||||
if (data?.type === "group") {
|
if (data?.type === "group") {
|
||||||
const category = findCategory(data.categoryId as string);
|
const category = findCategory(data.categoryId as string);
|
||||||
|
|
@ -76,6 +81,7 @@ function Board({
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragEnd(event: DragEndEvent) {
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
suspendRemoteSync(false);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setDraggedGroup(null);
|
setDraggedGroup(null);
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,20 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
||||||
// Ids are opaque strings, so unrelated features (a to-do id, a group id, a
|
// Ids are opaque strings, so unrelated features (a to-do id, a group id, a
|
||||||
// scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can
|
// scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can
|
||||||
// safely share one instance without knowing about each other.
|
// safely share one instance without knowing about each other.
|
||||||
|
//
|
||||||
|
// A hold can also be frozen mid-flight (pauseHold) and later resumed
|
||||||
|
// (beginHold) or dropped (cancelHold) -- e.g. to keep a brand-new group on
|
||||||
|
// screen for as long as its "add to-do" window is still open, however long
|
||||||
|
// that takes.
|
||||||
const HOLD_MS = 6_000;
|
const HOLD_MS = 6_000;
|
||||||
const FADE_MS = 1_000;
|
const FADE_MS = 1_000;
|
||||||
const COLLAPSE_MS = 200;
|
const COLLAPSE_MS = 200;
|
||||||
|
// The one-off grace period a brand-new (necessarily empty) group gets in
|
||||||
|
// compact view -- without it, compact's "hide empty groups" rule would drop
|
||||||
|
// it before it was ever seen, so this is its "moment to be noticed / have a
|
||||||
|
// to-do added" window. Longer than the usual hold since there's no
|
||||||
|
// accidental click to forgive here.
|
||||||
|
export const NEW_GROUP_HOLD_MS = 15_000;
|
||||||
|
|
||||||
export type HoldPhase = "visible" | "fading" | "collapsing";
|
export type HoldPhase = "visible" | "fading" | "collapsing";
|
||||||
|
|
||||||
|
|
@ -39,6 +50,12 @@ interface HoldOnCompleteContextValue {
|
||||||
// override just the "visible" stage's length.
|
// override just the "visible" stage's length.
|
||||||
beginHold: (id: string, holdMs?: number) => void;
|
beginHold: (id: string, holdMs?: number) => void;
|
||||||
cancelHold: (id: string) => void;
|
cancelHold: (id: string) => void;
|
||||||
|
// Freeze a live hold in place: clears its pending stage timers and
|
||||||
|
// (re)sets it to full "visible" with nothing scheduled after, so the item
|
||||||
|
// stays on screen until beginHold (resume the staged fade-out) or
|
||||||
|
// cancelHold (drop it) is called for it again. No-op if the id has no
|
||||||
|
// live hold.
|
||||||
|
pauseHold: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HoldOnCompleteContext = createContext<HoldOnCompleteContextValue | null>(null);
|
const HoldOnCompleteContext = createContext<HoldOnCompleteContextValue | null>(null);
|
||||||
|
|
@ -86,6 +103,25 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
|
||||||
[clearTimers, setPhase]
|
[clearTimers, setPhase]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Freeze an existing hold: the item snaps back (and stays) at full
|
||||||
|
// "visible" opacity with no stage timers pending, so it lingers on screen
|
||||||
|
// indefinitely -- call beginHold to resume the staged fade-out, or
|
||||||
|
// cancelHold to drop the hold outright.
|
||||||
|
const pauseHold = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
clearTimers(id);
|
||||||
|
setHolds((prev) => {
|
||||||
|
// Pausing is only meaningful for an id that's already mid-hold --
|
||||||
|
// don't invent a hold for one that isn't.
|
||||||
|
if (!prev.has(id)) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(id, "visible");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[clearTimers]
|
||||||
|
);
|
||||||
|
|
||||||
// Belt-and-suspenders: drop any still-pending timers on unmount so they
|
// Belt-and-suspenders: drop any still-pending timers on unmount so they
|
||||||
// don't fire setState against a gone provider.
|
// don't fire setState against a gone provider.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -96,7 +132,7 @@ export function HoldOnCompleteProvider({ children }: { children: React.ReactNode
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold }}>
|
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold, pauseHold }}>
|
||||||
{children}
|
{children}
|
||||||
</HoldOnCompleteContext.Provider>
|
</HoldOnCompleteContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
|
import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
|
||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||||
|
|
||||||
|
|
@ -14,6 +15,42 @@ import { ThemeToggle } from "@/components/theme/theme-toggle";
|
||||||
import { logout } from "@/lib/actions/auth";
|
import { logout } from "@/lib/actions/auth";
|
||||||
import { Role } from "@/lib/generated/prisma/enums";
|
import { Role } from "@/lib/generated/prisma/enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in user's circle at the bottom of the nav: their photo when
|
||||||
|
* they've set one, otherwise the letter fallback -- and a link to the
|
||||||
|
* Profile page either way.
|
||||||
|
*/
|
||||||
|
function UserAvatar({
|
||||||
|
userName,
|
||||||
|
userEmail,
|
||||||
|
avatar,
|
||||||
|
sizeClass,
|
||||||
|
}: {
|
||||||
|
userName: string | null;
|
||||||
|
userEmail: string;
|
||||||
|
avatar: string | null;
|
||||||
|
sizeClass: string;
|
||||||
|
}) {
|
||||||
|
if (avatar) {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatars, no image-optimization pipeline in this self-hosted app
|
||||||
|
<img
|
||||||
|
src={avatar}
|
||||||
|
alt={userName ?? userEmail}
|
||||||
|
className={`${sizeClass} shrink-0 rounded-full object-cover`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const letter = (userName?.trim()[0] ?? userEmail[0] ?? "?").slice(0, 1);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${sizeClass} flex shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase`}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The actual nav content, shared by the desktop aside and the mobile
|
* The actual nav content, shared by the desktop aside and the mobile
|
||||||
* hamburger drawer so the two never drift apart. `collapsed` is always
|
* hamburger drawer so the two never drift apart. `collapsed` is always
|
||||||
|
|
@ -22,11 +59,15 @@ import { Role } from "@/lib/generated/prisma/enums";
|
||||||
function SideNavContent({
|
function SideNavContent({
|
||||||
collapsed,
|
collapsed,
|
||||||
userEmail,
|
userEmail,
|
||||||
|
userName,
|
||||||
|
avatar,
|
||||||
role,
|
role,
|
||||||
themeMenu,
|
themeMenu,
|
||||||
}: {
|
}: {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
|
userName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
role: Role;
|
role: Role;
|
||||||
// Where the theme picker menu opens from its trigger -- right of the
|
// Where the theme picker menu opens from its trigger -- right of the
|
||||||
// trigger in the desktop sidebar, below it in the mobile drawer (a
|
// trigger in the desktop sidebar, below it in the mobile drawer (a
|
||||||
|
|
@ -34,6 +75,9 @@ function SideNavContent({
|
||||||
themeMenu: { side: "right" | "bottom"; align: "start" | "end" };
|
themeMenu: { side: "right" | "bottom"; align: "start" | "end" };
|
||||||
}) {
|
}) {
|
||||||
const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : [];
|
const adminItem = role === Role.ADMIN ? [ADMIN_NAV_ITEM] : [];
|
||||||
|
// The user's display label: their name when set, otherwise the email
|
||||||
|
// (same as before profiles existed).
|
||||||
|
const userLabel = userName || userEmail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -85,11 +129,38 @@ function SideNavContent({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}>
|
<div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}>
|
||||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase">
|
{collapsed ? (
|
||||||
{(userEmail[0] ?? "?").slice(0, 1)}
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Link href="/profile" aria-label={`View profile (${userLabel})`} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
userName={userName}
|
||||||
|
userEmail={userEmail}
|
||||||
|
avatar={avatar}
|
||||||
|
sizeClass="size-7"
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{userLabel}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2"
|
||||||
|
aria-label="View profile"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
userName={userName}
|
||||||
|
userEmail={userEmail}
|
||||||
|
avatar={avatar}
|
||||||
|
sizeClass="size-7"
|
||||||
|
/>
|
||||||
|
<span className="truncate text-[13px] font-medium text-muted-foreground">
|
||||||
|
{userLabel}
|
||||||
</span>
|
</span>
|
||||||
{!collapsed && (
|
</Link>
|
||||||
<span className="truncate text-[13px] font-medium text-muted-foreground">{userEmail}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -100,7 +171,17 @@ function SideNavContent({
|
||||||
* Mobile (< md) hamburger drawer: the full side nav in a left sheet.
|
* Mobile (< md) hamburger drawer: the full side nav in a left sheet.
|
||||||
* Tapping any link closes it; the backdrop and Escape close it too.
|
* Tapping any link closes it; the backdrop and Escape close it too.
|
||||||
*/
|
*/
|
||||||
function MobileNavDrawer({ userEmail, role }: { userEmail: string; role: Role }) {
|
function MobileNavDrawer({
|
||||||
|
userEmail,
|
||||||
|
userName,
|
||||||
|
avatar,
|
||||||
|
role,
|
||||||
|
}: {
|
||||||
|
userEmail: string;
|
||||||
|
userName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
role: Role;
|
||||||
|
}) {
|
||||||
const { mobileOpen, setMobileOpen } = useSideNav();
|
const { mobileOpen, setMobileOpen } = useSideNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -122,14 +203,31 @@ function MobileNavDrawer({ userEmail, role }: { userEmail: string; role: Role })
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DialogPrimitive.Title className="sr-only">Menu</DialogPrimitive.Title>
|
<DialogPrimitive.Title className="sr-only">Menu</DialogPrimitive.Title>
|
||||||
<SideNavContent collapsed={false} userEmail={userEmail} role={role} themeMenu={{ side: "bottom", align: "end" }} />
|
<SideNavContent
|
||||||
|
collapsed={false}
|
||||||
|
userEmail={userEmail}
|
||||||
|
userName={userName}
|
||||||
|
avatar={avatar}
|
||||||
|
role={role}
|
||||||
|
themeMenu={{ side: "bottom", align: "end" }}
|
||||||
|
/>
|
||||||
</DialogPrimitive.Popup>
|
</DialogPrimitive.Popup>
|
||||||
</DialogPrimitive.Portal>
|
</DialogPrimitive.Portal>
|
||||||
</DialogPrimitive.Root>
|
</DialogPrimitive.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SideNav({ userEmail, role }: { userEmail: string; role: Role }) {
|
export function SideNav({
|
||||||
|
userEmail,
|
||||||
|
userName,
|
||||||
|
avatar,
|
||||||
|
role,
|
||||||
|
}: {
|
||||||
|
userEmail: string;
|
||||||
|
userName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
role: Role;
|
||||||
|
}) {
|
||||||
const { collapsed, toggle } = useSideNav();
|
const { collapsed, toggle } = useSideNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -140,7 +238,14 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
|
||||||
collapsed ? "w-16" : "w-60"
|
collapsed ? "w-16" : "w-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SideNavContent collapsed={collapsed} userEmail={userEmail} role={role} themeMenu={{ side: "right", align: "start" }} />
|
<SideNavContent
|
||||||
|
collapsed={collapsed}
|
||||||
|
userEmail={userEmail}
|
||||||
|
userName={userName}
|
||||||
|
avatar={avatar}
|
||||||
|
role={role}
|
||||||
|
themeMenu={{ side: "right", align: "start" }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={cn("flex border-t px-2 py-1.5", collapsed ? "justify-center" : "justify-end")}>
|
<div className={cn("flex border-t px-2 py-1.5", collapsed ? "justify-center" : "justify-end")}>
|
||||||
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
|
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
|
||||||
|
|
@ -149,7 +254,7 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<MobileNavDrawer userEmail={userEmail} role={role} />
|
<MobileNavDrawer userEmail={userEmail} userName={userName} avatar={avatar} role={role} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { LinkAccountIdentity } from "@/types/profile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photo-or-initial circle for another account (Linked Accounts, pending
|
||||||
|
* requests, blocked requests). Same look as the signed-in user's avatar in
|
||||||
|
* the side nav so the two read as the same kind of thing.
|
||||||
|
*/
|
||||||
|
export function AccountAvatar({
|
||||||
|
account,
|
||||||
|
sizeClass,
|
||||||
|
}: {
|
||||||
|
account: LinkAccountIdentity;
|
||||||
|
sizeClass: string;
|
||||||
|
}) {
|
||||||
|
if (account.avatar) {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatars, no image-optimization pipeline in this self-hosted app
|
||||||
|
<img
|
||||||
|
src={account.avatar}
|
||||||
|
alt={account.name ?? account.email}
|
||||||
|
className={`${sizeClass} shrink-0 rounded-full object-cover`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const letter = (account.name?.trim()[0] ?? account.email[0] ?? "?").toUpperCase();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${sizeClass} flex shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase`}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { AccountAvatar } from "@/components/profile/account-avatar";
|
||||||
|
import { unblockAccountLinkRequests } from "@/lib/actions/account-links";
|
||||||
|
import type { BlockedRequesterDTO } from "@/types/profile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accounts this account blocked from sending link requests (via "Deny and
|
||||||
|
* Block Account Link"). Lifting a block lets that account request again --
|
||||||
|
* the block is permanent for them otherwise, so it must be reversible
|
||||||
|
* here.
|
||||||
|
*/
|
||||||
|
export function BlockedLinkRequests({ blocks }: { blocks: BlockedRequesterDTO[] }) {
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleAllow(block: BlockedRequesterDTO) {
|
||||||
|
if (busyId) return;
|
||||||
|
setBusyId(block.blockId);
|
||||||
|
try {
|
||||||
|
const result = await unblockAccountLinkRequests(block.blockId);
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`${block.email} can send link requests to you again.`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Couldn't allow requests from that account. Try again.");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Blocked Account Link Requests</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
These accounts can't send link requests to this one.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{blocks.map((block) => {
|
||||||
|
const busy = busyId === block.blockId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={block.blockId}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<AccountAvatar account={block} sizeClass="size-9" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{block.email}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{block.name ? `${block.name} · ` : ""}
|
||||||
|
blocked {block.blockedAtLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAllow(block)}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Allow requests
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ArrowLeftRight, Loader2, Unlink } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
||||||
|
import { AccountAvatar } from "@/components/profile/account-avatar";
|
||||||
|
import { removeAccountLink, toggleAccount } from "@/lib/actions/account-links";
|
||||||
|
import type { LinkedAccountDTO } from "@/types/profile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Linked Accounts": the other accounts this one is linked to.
|
||||||
|
* - "Toggle": signs this browser out and back in as that account (the
|
||||||
|
* server verifies the link and mints a one-time switch token, so the
|
||||||
|
* other account's password is never needed).
|
||||||
|
* - "Remove Link": either account in the pair can do it; confirmed first.
|
||||||
|
*/
|
||||||
|
export function LinkedAccounts({ accounts }: { accounts: LinkedAccountDTO[] }) {
|
||||||
|
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||||
|
const [removing, setRemoving] = useState<LinkedAccountDTO | null>(null);
|
||||||
|
const [removingBusy, setRemovingBusy] = useState(false);
|
||||||
|
|
||||||
|
async function handleToggle(account: LinkedAccountDTO) {
|
||||||
|
if (togglingId) return;
|
||||||
|
setTogglingId(account.id);
|
||||||
|
try {
|
||||||
|
const result = await toggleAccount(account.id);
|
||||||
|
if (result?.url) {
|
||||||
|
// The action already signed this browser in as the linked account;
|
||||||
|
// navigate so the app shell re-renders for them.
|
||||||
|
window.location.href = result.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error(result?.error ?? "Couldn't switch to that account.");
|
||||||
|
} catch {
|
||||||
|
toast.error("Couldn't switch to that account. Try again.");
|
||||||
|
} finally {
|
||||||
|
setTogglingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove() {
|
||||||
|
if (!removing) return;
|
||||||
|
const account = removing;
|
||||||
|
setRemoving(null);
|
||||||
|
setRemovingBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await removeAccountLink(account.linkId);
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`Link with ${account.email} removed.`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Couldn't remove the link. Try again.");
|
||||||
|
} finally {
|
||||||
|
setRemovingBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Linked Accounts</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Accounts this one is linked to. Toggle signs you in as that
|
||||||
|
account; Remove Link works from either side.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<div
|
||||||
|
key={account.linkId}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<AccountAvatar account={account} sizeClass="size-9" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{account.email}</p>
|
||||||
|
{account.name && (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{account.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleToggle(account)}
|
||||||
|
disabled={togglingId !== null}
|
||||||
|
>
|
||||||
|
{togglingId === account.id ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowLeftRight className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{togglingId === account.id ? "Switching…" : "Toggle"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={togglingId !== null}
|
||||||
|
onClick={() => setRemoving(account)}
|
||||||
|
>
|
||||||
|
<Unlink className="size-3.5" />
|
||||||
|
Remove Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={removing !== null}
|
||||||
|
onOpenChange={(open) => !removingBusy && !open && setRemoving(null)}
|
||||||
|
title={`Remove link with ${removing?.email}?`}
|
||||||
|
description="Either linked account can remove the link. Removing it doesn't block anything -- the other account can still send a new link request, which you can accept again."
|
||||||
|
confirmLabel="Remove Link"
|
||||||
|
onConfirm={handleRemove}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Check, Loader2, ShieldBan, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
||||||
|
import { AccountAvatar } from "@/components/profile/account-avatar";
|
||||||
|
import {
|
||||||
|
createAccountLink,
|
||||||
|
denyAccountLink,
|
||||||
|
denyAndBlockAccountLink,
|
||||||
|
} from "@/lib/actions/account-links";
|
||||||
|
import type { PendingLinkRequestDTO } from "@/types/profile";
|
||||||
|
|
||||||
|
type DenyIntent = { request: PendingLinkRequestDTO; block: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Requested Account Link" section: link requests this account received.
|
||||||
|
* Only the recipient acts on these -- the requester just sees
|
||||||
|
* "awaiting confirmation" on their own Profile page.
|
||||||
|
*/
|
||||||
|
export function PendingLinkRequests({ requests }: { requests: PendingLinkRequestDTO[] }) {
|
||||||
|
// Which request row (if any) is currently processing a button press.
|
||||||
|
const [busyRequestId, setBusyRequestId] = useState<string | null>(null);
|
||||||
|
const [denyIntent, setDenyIntent] = useState<DenyIntent | null>(null);
|
||||||
|
|
||||||
|
/** Runs a request action, toasting its error (if any) and reporting
|
||||||
|
* success so the caller can toast a specific confirmation. */
|
||||||
|
async function run(requestId: string, action: () => Promise<{ error?: string } | undefined>) {
|
||||||
|
setBusyRequestId(requestId);
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
const result = await action();
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("That didn't work. Try again.");
|
||||||
|
failed = true;
|
||||||
|
} finally {
|
||||||
|
setBusyRequestId(null);
|
||||||
|
}
|
||||||
|
return !failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmLink(request: PendingLinkRequestDTO) {
|
||||||
|
const ok = await run(request.requestId, () => createAccountLink(request.requestId));
|
||||||
|
if (ok) toast.success("Account link created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeny() {
|
||||||
|
if (!denyIntent) return;
|
||||||
|
const { request, block } = denyIntent;
|
||||||
|
setDenyIntent(null);
|
||||||
|
const ok = await run(
|
||||||
|
request.requestId,
|
||||||
|
block
|
||||||
|
? () => denyAndBlockAccountLink(request.requestId)
|
||||||
|
: () => denyAccountLink(request.requestId)
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
toast.success(
|
||||||
|
block
|
||||||
|
? "Request denied — that account can't send link requests to you anymore."
|
||||||
|
: "Request denied."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Requested Account Link</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
These accounts asked to link with this one. Linking lets each
|
||||||
|
account toggle the other in.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{requests.map((request) => {
|
||||||
|
const busy = busyRequestId === request.requestId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={request.requestId}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<AccountAvatar account={request} sizeClass="size-9" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{request.email}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{request.name ? `${request.name} · ` : ""}
|
||||||
|
requested {request.requestedAtLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleConfirmLink(request)}
|
||||||
|
disabled={busyRequestId !== null}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Create Account Link
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busyRequestId !== null}
|
||||||
|
onClick={() => setDenyIntent({ request, block: false })}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Deny Account Link
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={busyRequestId !== null}
|
||||||
|
onClick={() => setDenyIntent({ request, block: true })}
|
||||||
|
>
|
||||||
|
<ShieldBan className="size-3.5" />
|
||||||
|
Deny and Block Account Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={denyIntent !== null}
|
||||||
|
onOpenChange={(open) => !open && setDenyIntent(null)}
|
||||||
|
title={denyIntent?.block ? "Deny and block this account?" : "Deny this request?"}
|
||||||
|
description={
|
||||||
|
denyIntent?.block
|
||||||
|
? `Deny the link request from ${denyIntent?.request?.email} and block that account from sending any more link requests to this one. You can lift the block later from the Blocked Account Link Requests section.`
|
||||||
|
: `Deny the link request from ${denyIntent?.request?.email}. They can request again later unless you block them.`
|
||||||
|
}
|
||||||
|
confirmLabel={denyIntent?.block ? "Deny and Block" : "Deny"}
|
||||||
|
onConfirm={handleDeny}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Camera, Loader2, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
removeAvatar as removeAvatarAction,
|
||||||
|
updateProfileNames,
|
||||||
|
uploadAvatar as uploadAvatarAction,
|
||||||
|
} from "@/lib/actions/profile";
|
||||||
|
import type { ProfileDTO } from "@/types/profile";
|
||||||
|
|
||||||
|
// Kept in sync with lib/actions/profile.ts (the server re-checks both).
|
||||||
|
const MAX_AVATAR_BYTES = 10 * 1024 * 1024;
|
||||||
|
const ACCEPTED_AVATAR_TYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"image/gif",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ProfileForm({ initial }: { initial: ProfileDTO }) {
|
||||||
|
const [firstName, setFirstName] = useState(initial.firstName ?? "");
|
||||||
|
const [lastName, setLastName] = useState(initial.lastName ?? "");
|
||||||
|
// What's currently saved in the DB -- the "Save" button is only enabled
|
||||||
|
// while the inputs differ from this.
|
||||||
|
const [savedNames, setSavedNames] = useState({
|
||||||
|
firstName: initial.firstName ?? "",
|
||||||
|
lastName: initial.lastName ?? "",
|
||||||
|
});
|
||||||
|
const [avatar, setAvatar] = useState<string | null>(initial.avatar);
|
||||||
|
const [savingNames, setSavingNames] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const namesDirty =
|
||||||
|
firstName.trim() !== savedNames.firstName ||
|
||||||
|
lastName.trim() !== savedNames.lastName;
|
||||||
|
|
||||||
|
async function handleSaveNames() {
|
||||||
|
setSavingNames(true);
|
||||||
|
try {
|
||||||
|
await updateProfileNames(firstName, lastName);
|
||||||
|
setSavedNames({ firstName, lastName });
|
||||||
|
toast.success("Name saved.");
|
||||||
|
} catch {
|
||||||
|
toast.error("Couldn't save your name. Try again.");
|
||||||
|
} finally {
|
||||||
|
setSavingNames(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileChosen(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = ""; // allow re-picking the same file
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Same rules as the server action -- check early so a bad file never
|
||||||
|
// leaves the browser.
|
||||||
|
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
|
||||||
|
toast.error("Please choose a JPEG, PNG, WebP, or GIF image.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AVATAR_BYTES) {
|
||||||
|
toast.error("Image must be 10 MB or smaller.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const result = await uploadAvatarAction(file);
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAvatar(result.avatar ?? null);
|
||||||
|
toast.success("Profile photo updated.");
|
||||||
|
} catch (error) {
|
||||||
|
// A thrown (rather than returned) error is a framework-level
|
||||||
|
// rejection -- typically Next.js' server-action body limit 413ing
|
||||||
|
// the file before it reaches the action. Say so instead of the
|
||||||
|
// generic "try another image", which is misleading here.
|
||||||
|
if (error instanceof Error && /body exceeded|413/i.test(error.message)) {
|
||||||
|
toast.error(
|
||||||
|
"Image is too large for the server to accept. Try one under 10 MB."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error("Couldn't upload that photo. Try again.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveAvatar() {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
await removeAvatarAction();
|
||||||
|
setAvatar(null);
|
||||||
|
toast.success("Profile photo removed.");
|
||||||
|
} catch {
|
||||||
|
toast.error("Couldn't remove the photo. Try again.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = (firstName.trim()[0] ?? lastName.trim()[0] ?? "?").toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-xl">
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{/* Photo */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{avatar ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- data-URL avatar, no image-optimization pipeline in this self-hosted app
|
||||||
|
<img
|
||||||
|
src={avatar}
|
||||||
|
alt="Profile"
|
||||||
|
className="size-16 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xl font-bold text-primary">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_AVATAR_TYPES.join(",")}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChosen}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Camera className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{uploading ? "Updating…" : avatar ? "Change photo" : "Add photo"}
|
||||||
|
</Button>
|
||||||
|
{avatar && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profile-first-name">First name</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-first-name"
|
||||||
|
value={firstName}
|
||||||
|
maxLength={60}
|
||||||
|
autoComplete="given-name"
|
||||||
|
placeholder="Alex"
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profile-last-name">Last name</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-last-name"
|
||||||
|
value={lastName}
|
||||||
|
maxLength={60}
|
||||||
|
autoComplete="family-name"
|
||||||
|
placeholder="Fertig"
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && namesDirty && !savingNames && handleSaveNames()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter>
|
||||||
|
<Button onClick={handleSaveNames} disabled={!namesDirty || savingNames}>
|
||||||
|
{savingNames ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save name"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Clock, Link2, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { requestAccountLink } from "@/lib/actions/account-links";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Request Account Link": asks to link this account with another one on
|
||||||
|
* the server by email. On success the form is replaced by the
|
||||||
|
* "awaiting confirmation" status, since the recipient still has to accept
|
||||||
|
* it from their own Profile page.
|
||||||
|
*/
|
||||||
|
export function RequestLinkForm() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
// Set once a request goes out; the section then shows the awaiting
|
||||||
|
// confirmation status until the recipient responds (or it's denied).
|
||||||
|
const [requestedEmail, setRequestedEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await requestAccountLink(email);
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRequestedEmail(email.trim().toLowerCase());
|
||||||
|
toast.success("Link request sent.");
|
||||||
|
} catch {
|
||||||
|
toast.error("Couldn't send the link request. Try again.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Request Account Link</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Link this account with another account on this server so you can
|
||||||
|
toggle between them. The other account has to accept the request.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{requestedEmail ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span>
|
||||||
|
Request sent to{" "}
|
||||||
|
<span className="font-medium">{requestedEmail}</span> — awaiting
|
||||||
|
confirmation.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col gap-3 sm:flex-row sm:items-end"
|
||||||
|
>
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Label htmlFor="link-request-email">Account email</Label>
|
||||||
|
<Input
|
||||||
|
id="link-request-email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
disabled={submitting}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={submitting || !email.trim()}>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Link2 className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{submitting ? "Requesting…" : "Request Account Link"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
type ThemeName,
|
type ThemeName,
|
||||||
type RawTheme,
|
type RawTheme,
|
||||||
} from "@/lib/themes";
|
} from "@/lib/themes";
|
||||||
|
import { saveUserTheme } from "@/lib/actions/profile";
|
||||||
|
|
||||||
// Re-exported so existing imports keep working.
|
// Re-exported so existing imports keep working.
|
||||||
export { THEMES, DARK_SURFACES };
|
export { THEMES, DARK_SURFACES };
|
||||||
|
|
@ -51,6 +52,14 @@ export type { ThemeName, RawTheme };
|
||||||
* The provider also picks up a `data-project-theme` marker on <html> at
|
* The provider also picks up a `data-project-theme` marker on <html> at
|
||||||
* mount -- set by the project page's no-FOUC inline script -- so a scoped
|
* mount -- set by the project page's no-FOUC inline script -- so a scoped
|
||||||
* theme is in effect from the very first frame of a hard page load.
|
* theme is in effect from the very first frame of a hard page load.
|
||||||
|
*
|
||||||
|
* Per-user themes (per-account, survive account toggles):
|
||||||
|
* The root layout passes `userTheme` -- the signed-in user's preference
|
||||||
|
* from the profile row (saveUserTheme action) -- instead of defaulting
|
||||||
|
* to localStorage. That stored value is the initial state and the
|
||||||
|
* no-FOUC script applies it before first paint, and setTheme() persists
|
||||||
|
* changes back to the profile. Anonymous visitors keep the classic
|
||||||
|
* localStorage-only behavior.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY = "theme";
|
const STORAGE_KEY = "theme";
|
||||||
|
|
@ -121,15 +130,27 @@ function readInitialScope(): ThemeName | null {
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = "system",
|
defaultTheme = "system",
|
||||||
|
userTheme,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
defaultTheme?: RawTheme;
|
defaultTheme?: RawTheme;
|
||||||
|
/**
|
||||||
|
* The signed-in user's stored theme preference -- the root layout reads
|
||||||
|
* it from the profile row (and bakes it into the no-FOUC script). When
|
||||||
|
* defined it is the source of truth: the provider starts from it instead
|
||||||
|
* of localStorage, and every setTheme() call is persisted to the profile
|
||||||
|
* (saveUserTheme) so each linked account keeps its own look after an
|
||||||
|
* account toggle. When undefined (anonymous visitor) the provider keeps
|
||||||
|
* the old localStorage-only behavior.
|
||||||
|
*/
|
||||||
|
userTheme?: RawTheme;
|
||||||
}) {
|
}) {
|
||||||
// Both the server and the first client render agree on `defaultTheme`
|
// Both the server and the first client render agree on the initial theme
|
||||||
// (hydration-safe); the stored preference is picked up right after mount.
|
// (hydration-safe); anonymous users' localStorage preference is picked up
|
||||||
// The inline script in app/layout.tsx already applied the right classes
|
// right after mount. The inline script in app/layout.tsx already applied
|
||||||
// before first paint, so there is no visible jump either way.
|
// the right classes before first paint, so there is no visible jump
|
||||||
const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
|
// either way.
|
||||||
|
const [theme, setThemeState] = useState<RawTheme>(userTheme ?? defaultTheme);
|
||||||
// Starts null (matching the server render); the project-page marker is
|
// Starts null (matching the server render); the project-page marker is
|
||||||
// applied in a layout effect below, before paint.
|
// applied in a layout effect below, before paint.
|
||||||
const [scope, setScope] = useState<ThemeName | null>(null);
|
const [scope, setScope] = useState<ThemeName | null>(null);
|
||||||
|
|
@ -137,13 +158,19 @@ export function ThemeProvider({
|
||||||
const resolvedTheme: ThemeName = scope ?? resolve(theme);
|
const resolvedTheme: ThemeName = scope ?? resolve(theme);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Signed-in users: their profile's stored theme (userTheme) is
|
||||||
|
// authoritative -- it is already the initial state, and it's what the
|
||||||
|
// no-FOUC script applied, so localStorage is deliberately not
|
||||||
|
// consulted (a stale entry from another account must not win).
|
||||||
|
if (userTheme !== undefined) return;
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount-time sync from localStorage (external source), same pattern as the project-theme marker effect below
|
||||||
if (stored) setThemeState(stored as RawTheme);
|
if (stored) setThemeState(stored as RawTheme);
|
||||||
} catch {
|
} catch {
|
||||||
/* storage unavailable -- stay on defaultTheme */
|
/* storage unavailable -- stay on defaultTheme */
|
||||||
}
|
}
|
||||||
}, []);
|
}, [userTheme]);
|
||||||
|
|
||||||
// Pick up a project's no-FOUC marker (hard page load). Runs in the same
|
// Pick up a project's no-FOUC marker (hard page load). Runs in the same
|
||||||
// pre-paint window as the apply effect below, so the global-theme flash
|
// pre-paint window as the apply effect below, so the global-theme flash
|
||||||
|
|
@ -182,14 +209,27 @@ export function ThemeProvider({
|
||||||
return () => window.removeEventListener("storage", onStorage);
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
}, [defaultTheme]);
|
}, [defaultTheme]);
|
||||||
|
|
||||||
const setTheme = useCallback((t: RawTheme) => {
|
const setTheme = useCallback(
|
||||||
|
(t: RawTheme) => {
|
||||||
setThemeState(t);
|
setThemeState(t);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, t);
|
localStorage.setItem(STORAGE_KEY, t);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore -- preference just won't persist */
|
/* ignore -- preference just won't persist */
|
||||||
}
|
}
|
||||||
}, []);
|
// Signed-in: persist to the profile row so the next load -- a hard
|
||||||
|
// navigation, an account toggle, another device -- applies this
|
||||||
|
// account's theme. Fire-and-forget: the UI already reflects the
|
||||||
|
// change; a failed write just means the next load falls back to the
|
||||||
|
// previously stored value.
|
||||||
|
if (userTheme !== undefined) {
|
||||||
|
void saveUserTheme(t).catch(() => {
|
||||||
|
/* best effort -- in-memory + localStorage state still stands */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[userTheme]
|
||||||
|
);
|
||||||
|
|
||||||
const value = useMemo<ThemeContextValue>(
|
const value = useMemo<ThemeContextValue>(
|
||||||
() => ({ theme, resolvedTheme, setTheme, themes: THEMES }),
|
() => ({ theme, resolvedTheme, setTheme, themes: THEMES }),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
# Local filesystem bind mount, not a named Docker volume.
|
# Local filesystem bind mount, not a named Docker volume.
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
# Exposed on the host so host-side tooling (prisma migrate/dev, npm run
|
||||||
|
# dev with the .env's localhost DATABASE_URL) can reach it. The app
|
||||||
|
# container still connects over the compose network's "db" hostname.
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { AuthError } from "next-auth";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUserId } from "@/lib/auth-helpers";
|
||||||
|
import { EmailSchema } from "@/lib/validation/auth";
|
||||||
|
import { Prisma } from "@/lib/generated/prisma/client";
|
||||||
|
import { Role } from "@/lib/generated/prisma/enums";
|
||||||
|
import { signIn, signOut } from "@/auth";
|
||||||
|
|
||||||
|
/** Shared shape for the link-management actions: `error` on failure, a
|
||||||
|
* plain `{}` (or `ok`) on success. Kept as one flat object -- a discriminated
|
||||||
|
* union with an index signature confuses React types on the client side. */
|
||||||
|
export type AccountLinkActionResult = { error?: string; ok?: true };
|
||||||
|
|
||||||
|
/** Re-render the Profile page after any change to the linking state.
|
||||||
|
* (The (app) layout doesn't show link state, so /profile alone suffices.) */
|
||||||
|
function revalidateProfile() {
|
||||||
|
revalidatePath("/profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The canonical "does a confirmed link already exist between these two
|
||||||
|
* accounts?" check -- order-independent, since a link is stored once per
|
||||||
|
* pair in either direction. */
|
||||||
|
function linkedPairWhere(userId: string, otherUserId: string) {
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ userId, linkedUserId: otherUserId },
|
||||||
|
{ userId: otherUserId, linkedUserId: userId },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends (or silently refreshes) a link request to another account. The
|
||||||
|
* recipient sees it in their Profile page's "Requested Account Link"
|
||||||
|
* section and can create, deny, or deny-and-block it. Returns
|
||||||
|
* `{ error }` on failure; the UI then shows "awaiting confirmation".
|
||||||
|
*/
|
||||||
|
export async function requestAccountLink(email: string): Promise<AccountLinkActionResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
const parsed = EmailSchema.safeParse(email);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: parsed.error.issues[0]?.message ?? "Enter a valid email address." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { email: parsed.data } });
|
||||||
|
if (!target) {
|
||||||
|
return { error: "No account with that email address on this server." };
|
||||||
|
}
|
||||||
|
if (target.id === userId) {
|
||||||
|
return { error: "That's the account you're signed in as." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLink = await prisma.accountLink.findFirst({
|
||||||
|
where: linkedPairWhere(userId, target.id),
|
||||||
|
});
|
||||||
|
if (existingLink) {
|
||||||
|
return { error: "Those accounts are already linked." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Deny and Block Account Link" on their side stops new requests from
|
||||||
|
// this account to theirs.
|
||||||
|
const blocked = await prisma.accountLinkBlock.findUnique({
|
||||||
|
where: { fromUserId_toUserId: { fromUserId: userId, toUserId: target.id } },
|
||||||
|
});
|
||||||
|
if (blocked) {
|
||||||
|
return { error: "That account has blocked link requests from you." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert, not create: the composite unique makes re-requesting while a
|
||||||
|
// pending request already exists a no-op refresh rather than a 500.
|
||||||
|
await prisma.accountLinkRequest.upsert({
|
||||||
|
where: { fromUserId_toUserId: { fromUserId: userId, toUserId: target.id } },
|
||||||
|
create: { fromUserId: userId, toUserId: target.id },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateProfile();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipient's "Create Account Link": confirms a pending request and
|
||||||
|
* creates the (single, order-independent) link between the two accounts.
|
||||||
|
* Also closes any remaining pending requests between the pair, in either
|
||||||
|
* direction -- once linked, neither side needs to act on them anymore.
|
||||||
|
*/
|
||||||
|
export async function createAccountLink(requestId: string): Promise<AccountLinkActionResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
|
||||||
|
const request = await prisma.accountLinkRequest.findUnique({
|
||||||
|
where: { id: requestId },
|
||||||
|
});
|
||||||
|
// Only the *recipient* can confirm; the requester has no buttons here.
|
||||||
|
if (!request || request.toUserId !== userId) {
|
||||||
|
return { error: "That link request no longer exists." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const requesterId = request.fromUserId;
|
||||||
|
if (requesterId === userId) {
|
||||||
|
return { error: "That link request no longer exists." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.accountLink.create({
|
||||||
|
// Canonical order: the smaller id first. Makes the pair unique
|
||||||
|
// regardless of which direction the confirming request came in.
|
||||||
|
data: {
|
||||||
|
userId: [userId, requesterId].sort()[0],
|
||||||
|
linkedUserId: [userId, requesterId].sort()[1],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.accountLinkRequest.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ fromUserId: userId, toUserId: requesterId },
|
||||||
|
{ fromUserId: requesterId, toUserId: userId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
// A concurrent confirm (both sides clicked at once) can win the
|
||||||
|
// unique pair -- treat as success: the link exists, which is the
|
||||||
|
// outcome both sides wanted.
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateProfile();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipient's "Deny Account Link": drops the pending request. The
|
||||||
|
* requester may try again later (there's no block).
|
||||||
|
*/
|
||||||
|
export async function denyAccountLink(requestId: string): Promise<AccountLinkActionResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
|
||||||
|
const request = await prisma.accountLinkRequest.findUnique({ where: { id: requestId } });
|
||||||
|
if (!request || request.toUserId !== userId) {
|
||||||
|
return { error: "That link request no longer exists." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.accountLinkRequest.delete({ where: { id: requestId } });
|
||||||
|
|
||||||
|
revalidateProfile();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipient's "Deny and Block Account Link": denies the request and
|
||||||
|
* blocks any future link requests from that account to this one. The
|
||||||
|
* block shows up in the "Blocked Account Link Requests" section, where it
|
||||||
|
* can be lifted again.
|
||||||
|
*/
|
||||||
|
export async function denyAndBlockAccountLink(requestId: string): Promise<AccountLinkActionResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
|
||||||
|
const request = await prisma.accountLinkRequest.findUnique({ where: { id: requestId } });
|
||||||
|
if (!request || request.toUserId !== userId) {
|
||||||
|
return { error: "That link request no longer exists." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const requesterId = request.fromUserId;
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.accountLinkRequest.delete({ where: { id: requestId } }),
|
||||||
|
// Upsert: a block may already exist from an earlier denial -- keep it,
|
||||||
|
// don't fail on it.
|
||||||
|
prisma.accountLinkBlock.upsert({
|
||||||
|
where: { fromUserId_toUserId: { fromUserId: requesterId, toUserId: userId } },
|
||||||
|
create: { fromUserId: requesterId, toUserId: userId },
|
||||||
|
update: {},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
revalidateProfile();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Remove Link" from the Linked Accounts list: either account in the pair
|
||||||
|
* can remove the link. Removes only the link itself -- pending requests
|
||||||
|
* and blocks between the two accounts are left as-is (a re-request simply
|
||||||
|
* restarts the flow).
|
||||||
|
*/
|
||||||
|
export async function removeAccountLink(linkId: string): Promise<AccountLinkActionResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
|
||||||
|
const link = await prisma.accountLink.findUnique({ where: { id: linkId } });
|
||||||
|
if (!link || (link.userId !== userId && link.linkedUserId !== userId)) {
|
||||||
|
return { error: "That account link no longer exists." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.accountLink.delete({ where: { id: linkId } });
|
||||||
|
|
||||||
|
revalidateProfile();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifts a block the signed-in account issued ("Allow requests" in the
|
||||||
|
* Blocked section), letting that account send link requests again.
|
||||||
|
*/
|
||||||
|
export async function unblockAccountLinkRequests(blockId: string): Promise<AccountLinkActionResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
|
||||||
|
const block = await prisma.accountLinkBlock.findUnique({ where: { id: blockId } });
|
||||||
|
if (!block || block.toUserId !== userId) {
|
||||||
|
return { error: "That block no longer exists." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.accountLinkBlock.delete({ where: { id: blockId } });
|
||||||
|
|
||||||
|
revalidateProfile();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Toggle" button: verifies the signed-in user is actually linked to
|
||||||
|
* the given account, mints a one-time, short-lived token for the
|
||||||
|
* "account-switch" provider (auth.ts), signs the current session out, and
|
||||||
|
* signs in as the linked account -- all server-side, so the linked
|
||||||
|
* account's password is never needed. The client then navigates to the
|
||||||
|
* returned URL with the new session cookie.
|
||||||
|
*
|
||||||
|
* The token rides nowhere the user can craft it: 256 bits of randomness,
|
||||||
|
* single-use (consumed in the provider's authorize()), and it expires
|
||||||
|
* within a minute, so a copy in logs or history is worthless.
|
||||||
|
*/
|
||||||
|
const SWITCH_TOKEN_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
export type ToggleResult = { url?: string; error?: string };
|
||||||
|
|
||||||
|
export async function toggleAccount(linkedUserId: string): Promise<ToggleResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
|
||||||
|
if (linkedUserId === userId) {
|
||||||
|
return { error: "That's the account you're already signed in as." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = await prisma.accountLink.findFirst({
|
||||||
|
where: linkedPairWhere(userId, linkedUserId),
|
||||||
|
});
|
||||||
|
if (!link) {
|
||||||
|
return { error: "Those accounts aren't linked." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check before signing the user out: a PENDING (or deleted) target can
|
||||||
|
// never hold a session (the account-switch provider rejects it), so
|
||||||
|
// failing here keeps the user signed in to their own account instead of
|
||||||
|
// stranding them logged out.
|
||||||
|
const target = await prisma.user.findUnique({
|
||||||
|
where: { id: linkedUserId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
if (!target || target.role === Role.PENDING) {
|
||||||
|
return { error: "That account can't sign in right now -- it may be pending approval." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
await prisma.accountSwitchToken.create({
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
requestedBy: userId,
|
||||||
|
targetUserId: linkedUserId,
|
||||||
|
expiresAt: new Date(Date.now() + SWITCH_TOKEN_TTL_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop the current session first, then take the linked account's. Both
|
||||||
|
// responses set the same session cookie, so the second write wins and the
|
||||||
|
// browser is left exactly one signed-in account -- the toggled-to one.
|
||||||
|
await signOut({ redirect: false });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// redirect:false: signIn() commits the new session cookie through
|
||||||
|
// next/headers and returns the redirect URL as a string (see
|
||||||
|
// next-auth/lib/actions.js) instead of throwing a framework redirect --
|
||||||
|
// the client does the navigation so it can toast on failure.
|
||||||
|
const url = await signIn("account-switch", { token, redirectTo: "/", redirect: false });
|
||||||
|
return { url };
|
||||||
|
} catch (error) {
|
||||||
|
// authorize() throws SwitchAccountSignin (an AuthError) for a
|
||||||
|
// missing/expired/consumed token or a PENDING target account.
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return { error: "Couldn't switch to that account. Check that it's still linked and active." };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUserId } from "@/lib/auth-helpers";
|
||||||
|
import { projectAccessFilter } from "@/lib/access";
|
||||||
|
import { getBoard } from "@/lib/board";
|
||||||
|
import type { CategoryDTO } from "@/types/board";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fresh read of one board for the client's cross-device sync (see the
|
||||||
|
* poll in board-context.tsx). Returns the exact same shape the page's
|
||||||
|
* server component renders, so BoardProvider can diff it against its own
|
||||||
|
* state and apply it only if something actually changed. `projectId` null
|
||||||
|
* is Home scope -- same convention as getScheduledBoard.
|
||||||
|
*/
|
||||||
|
export async function getBoardSnapshot(projectId: string | null): Promise<CategoryDTO[]> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
if (projectId) {
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { id: projectId, ...projectAccessFilter(userId) },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!project) throw new Error("Project not found");
|
||||||
|
return getBoard({ projectId: project.id });
|
||||||
|
}
|
||||||
|
return getBoard({ userId, projectId: null });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUserId } from "@/lib/auth-helpers";
|
||||||
|
import { ProfileNameSchema, ThemeSchema } from "@/lib/validation/profile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh both the Profile page (its form's initial values) and the (app)
|
||||||
|
* layout (the sidebar shows the user's name/photo) after a change.
|
||||||
|
* "layout" type invalidates the whole (app) tree -- see
|
||||||
|
* next/docs revalidatePath "Revalidating a Layout path".
|
||||||
|
*/
|
||||||
|
function revalidateProfile() {
|
||||||
|
revalidatePath("/profile");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProfileResult = { error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the user's first and last name (either may be blank). Blank values
|
||||||
|
* are stored as null so "cleared" and "never set" mean the same thing.
|
||||||
|
*/
|
||||||
|
export async function updateProfileNames(
|
||||||
|
firstName: string,
|
||||||
|
lastName: string
|
||||||
|
): Promise<ProfileResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
const parsed = ProfileNameSchema.parse({ firstName, lastName });
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
firstName: parsed.firstName || null,
|
||||||
|
lastName: parsed.lastName || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateProfile();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 10 * 1024 * 1024;
|
||||||
|
const ALLOWED_AVATAR_TYPES = new Set([
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"image/gif",
|
||||||
|
]);
|
||||||
|
// Avatars render at most a couple of dozen CSS pixels; 256x256 covers
|
||||||
|
// retina without bloat. Re-encoded as JPEG so every row stored in the DB
|
||||||
|
// has the same shape regardless of the upload's original format.
|
||||||
|
const AVATAR_SIZE = 256;
|
||||||
|
|
||||||
|
export type UploadAvatarResult = { avatar?: string; error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the user's profile photo. The upload is resized to 256x256
|
||||||
|
* (cover crop, EXIF-rotated) and re-encoded as JPEG server-side, then
|
||||||
|
* saved as a data URL on the User row -- the app's container filesystem
|
||||||
|
* is ephemeral (only the Postgres volume survives deploys), so the image
|
||||||
|
* lives in the database rather than on disk.
|
||||||
|
*/
|
||||||
|
export async function uploadAvatar(file: File): Promise<UploadAvatarResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
|
||||||
|
if (!ALLOWED_AVATAR_TYPES.has(file.type)) {
|
||||||
|
return { error: "Please choose a JPEG, PNG, WebP, or GIF image." };
|
||||||
|
}
|
||||||
|
if (file.size === 0) return { error: "That file is empty." };
|
||||||
|
if (file.size > MAX_AVATAR_BYTES) {
|
||||||
|
return { error: "Image must be 10 MB or smaller." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoded: Buffer;
|
||||||
|
try {
|
||||||
|
encoded = await sharp(Buffer.from(await file.arrayBuffer()))
|
||||||
|
.rotate() // honor EXIF orientation from phones' photos
|
||||||
|
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover" })
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.toBuffer();
|
||||||
|
} catch {
|
||||||
|
return { error: "That doesn't look like a valid image." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatar = `data:image/jpeg;base64,${encoded.toString("base64")}`;
|
||||||
|
await prisma.user.update({ where: { id: userId }, data: { avatar } });
|
||||||
|
|
||||||
|
revalidateProfile();
|
||||||
|
return { avatar };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAvatar(): Promise<ProfileResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id: userId }, data: { avatar: null } });
|
||||||
|
|
||||||
|
revalidateProfile();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the user's global theme preference -- the theme menu's setTheme
|
||||||
|
* (components/theme/theme-provider.tsx) calls this for signed-in users.
|
||||||
|
* Stored on the profile row so the preference belongs to the *account*,
|
||||||
|
* not the browser: each account in a linked set keeps its own look after
|
||||||
|
* an account toggle, and the root layout (app/layout.tsx) applies it from
|
||||||
|
* the very first frame of the next page load. No revalidation needed: the
|
||||||
|
* value is read on navigation, which re-renders the layout.
|
||||||
|
*/
|
||||||
|
export async function saveUserTheme(theme: string): Promise<ProfileResult> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
const parsed = ThemeSchema.parse(theme);
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id: userId }, data: { theme: parsed } });
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
@ -14,3 +14,18 @@ import { CredentialsSignin } from "next-auth";
|
||||||
export class PendingAccountSignin extends CredentialsSignin {
|
export class PendingAccountSignin extends CredentialsSignin {
|
||||||
code = "pending-account";
|
code = "pending-account";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown from the "account-switch" provider's authorize() (in auth.ts)
|
||||||
|
* when the one-time switch token is missing, already used, expired, or
|
||||||
|
* the target account can't log in (e.g. PENDING). signIn() in the
|
||||||
|
* toggleAccount action (lib/actions/account-links.ts) then fails with it
|
||||||
|
* and the Profile page shows an error toast, keeping the user on the
|
||||||
|
* login flow instead of silently switching accounts.
|
||||||
|
*
|
||||||
|
* (Same mechanism as PendingAccountSignin: Auth.js rethrows errors from
|
||||||
|
* authorize() as-is when signIn() is called from a route/Server Action.)
|
||||||
|
*/
|
||||||
|
export class SwitchAccountSignin extends CredentialsSignin {
|
||||||
|
code = "switch-account";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,43 @@ export function themeColorScheme(name: ThemeName): "light" | "dark" {
|
||||||
return DARK_SURFACES.includes(name) ? "dark" : "light";
|
return DARK_SURFACES.includes(name) ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source of the root layout's no-FOUC theme script (app/layout.tsx renders
|
||||||
|
* it as plain server HTML so it runs before first paint).
|
||||||
|
*
|
||||||
|
* `serverTheme` is the signed-in user's stored preference (the layout
|
||||||
|
* reads it from the profile row). When present it is applied verbatim and
|
||||||
|
* localStorage is *not* consulted -- that is what lets each account in a
|
||||||
|
* linked set keep its own look in the same browser after an account
|
||||||
|
* toggle. When absent (anonymous visitor) the script keeps the old
|
||||||
|
* behavior: restore the browser's localStorage preference, falling back
|
||||||
|
* to the OS light/dark setting.
|
||||||
|
*/
|
||||||
|
export function themeInitScript(serverTheme?: RawTheme | null): string {
|
||||||
|
const allClasses = JSON.stringify([...Object.values(THEME_CLASSES), "light"]);
|
||||||
|
const classMap = JSON.stringify(THEME_CLASSES);
|
||||||
|
const dark = JSON.stringify(DARK_SURFACES);
|
||||||
|
const system =
|
||||||
|
"(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'default'";
|
||||||
|
const apply =
|
||||||
|
`r.classList.add(m[v]);r.style.colorScheme=d.indexOf(v)>=0?'dark':'light';`;
|
||||||
|
if (serverTheme != null) {
|
||||||
|
return (
|
||||||
|
`try{var r=document.documentElement,c=${allClasses};` +
|
||||||
|
`for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}` +
|
||||||
|
`var v=${JSON.stringify(serverTheme)};if(v==='system'){v=${system};}` +
|
||||||
|
`var m=${classMap},d=${dark};if(m[v]){${apply}}catch(e){}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`try{var r=document.documentElement,c=${allClasses};` +
|
||||||
|
`for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}` +
|
||||||
|
`var v=null;try{v=localStorage.getItem('theme');}catch(e){}` +
|
||||||
|
`if(v==='system'||!v){v=${system};}` +
|
||||||
|
`var m=${classMap},d=${dark};if(m[v]){${apply}}catch(e){}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline-script source that switches <html> over to `theme` immediately
|
* Inline-script source that switches <html> over to `theme` immediately
|
||||||
* (before first paint), the same way the global no-FOUC script in
|
* (before first paint), the same way the global no-FOUC script in
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { THEMES } from "@/lib/themes";
|
||||||
|
|
||||||
|
const NamePartSchema = z.string().trim().max(60, "Must be 60 characters or fewer");
|
||||||
|
|
||||||
|
export const ProfileNameSchema = z.object({
|
||||||
|
firstName: NamePartSchema,
|
||||||
|
lastName: NamePartSchema,
|
||||||
|
});
|
||||||
|
export type ProfileName = z.infer<typeof ProfileNameSchema>;
|
||||||
|
|
||||||
|
// The global theme preference stored on the profile row (User.theme):
|
||||||
|
// one of the real themes, or "system" = follow the OS light/dark setting.
|
||||||
|
// Written only by saveUserTheme (lib/actions/profile.ts), read by the
|
||||||
|
// root layout (app/layout.tsx) and the theme provider -- the cast in the
|
||||||
|
// latter two places is safe because of this schema.
|
||||||
|
export const ThemeSchema = z.enum([...THEMES, "system"]);
|
||||||
|
export type ThemePreference = z.infer<typeof ThemeSchema>;
|
||||||
|
|
@ -7,6 +7,15 @@ const nextConfig: NextConfig = {
|
||||||
// container start, not imported by app code). The Dockerfile instead
|
// container start, not imported by app code). The Dockerfile instead
|
||||||
// copies the full `node_modules` into the runtime image, which is a
|
// copies the full `node_modules` into the runtime image, which is a
|
||||||
// simpler and more reliable trade for a single self-hosted instance.
|
// simpler and more reliable trade for a single self-hosted instance.
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
// Default is 1 MB, which silently 413s profile-photo uploads larger
|
||||||
|
// than that before the action even runs (the client only sees a
|
||||||
|
// generic failure). Allow the profile action's 10 MB max file plus
|
||||||
|
// room for multipart overhead; other actions send tiny bodies.
|
||||||
|
bodySizeLimit: "11mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "avatar" TEXT,
|
||||||
|
ADD COLUMN "firstName" TEXT,
|
||||||
|
ADD COLUMN "lastName" TEXT;
|
||||||
|
|
||||||
|
-- Backfill: existing accounts only have the single `name` captured at
|
||||||
|
-- sign-up. Split it into firstName (first word) and lastName (the rest) so
|
||||||
|
-- the Profile page starts from what the user already gave us instead of a
|
||||||
|
-- blank form. Single-word names (e.g. "Cher") get firstName only.
|
||||||
|
UPDATE "User"
|
||||||
|
SET "firstName" = split_part("name", ' ', 1)
|
||||||
|
WHERE "name" IS NOT NULL AND btrim("name") <> '';
|
||||||
|
|
||||||
|
UPDATE "User"
|
||||||
|
SET "lastName" = trim(regexp_replace("name", '^\S+\s+', ''))
|
||||||
|
WHERE "name" IS NOT NULL AND "name" ~ '^\S+\s+\S+';
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AccountLinkRequest" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"fromUserId" TEXT NOT NULL,
|
||||||
|
"toUserId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AccountLinkRequest_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AccountLink" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"linkedUserId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AccountLink_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A link is between two *different* accounts; the app layer also refuses
|
||||||
|
-- self-links before ever reaching this constraint.
|
||||||
|
ALTER TABLE "AccountLink" ADD CONSTRAINT "account_link_not_self" CHECK ("userId" <> "linkedUserId");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AccountLinkBlock" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"fromUserId" TEXT NOT NULL,
|
||||||
|
"toUserId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AccountLinkBlock_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AccountSwitchToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"requestedBy" TEXT NOT NULL,
|
||||||
|
"targetUserId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AccountSwitchToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AccountLinkRequest_fromUserId_toUserId_unique" ON "AccountLinkRequest"("fromUserId", "toUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountLinkRequest_toUserId_idx" ON "AccountLinkRequest"("toUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AccountLink_userId_linkedUserId_unique" ON "AccountLink"("userId", "linkedUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AccountLinkBlock_fromUserId_toUserId_unique" ON "AccountLinkBlock"("fromUserId", "toUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AccountSwitchToken_token_key" ON "AccountSwitchToken"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountLinkRequest" ADD CONSTRAINT "AccountLinkRequest_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountLinkRequest" ADD CONSTRAINT "AccountLinkRequest_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountLink" ADD CONSTRAINT "AccountLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountLink" ADD CONSTRAINT "AccountLink_linkedUserId_fkey" FOREIGN KEY ("linkedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountLinkBlock" ADD CONSTRAINT "AccountLinkBlock_fromUserId_fkey" FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountLinkBlock" ADD CONSTRAINT "AccountLinkBlock_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountSwitchToken" ADD CONSTRAINT "AccountSwitchToken_requestedBy_fkey" FOREIGN KEY ("requestedBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountSwitchToken" ADD CONSTRAINT "AccountSwitchToken_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "theme" TEXT;
|
||||||
|
|
@ -34,7 +34,25 @@ model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
|
// Legacy single name field, captured at sign-up. Display and the Admin
|
||||||
|
// page still use it; the Profile page manages firstName/lastName below.
|
||||||
name String?
|
name String?
|
||||||
|
// Profile page fields. Null = the user hasn't set them.
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
// Profile photo, stored as a data URL (base64 JPEG) rather than a file on
|
||||||
|
// disk: this app's container filesystem is ephemeral (only the Postgres
|
||||||
|
// volume persists across deploys), so the DB is the one place it can live.
|
||||||
|
// Always server-generated from the user's upload (resized to 256x256 via
|
||||||
|
// sharp in lib/actions/profile.ts), never stored as the raw upload.
|
||||||
|
avatar String?
|
||||||
|
// Global theme preference: one of lib/themes' THEMES, or "system" (follow
|
||||||
|
// the OS light/dark setting). Null = never set -- the root layout treats
|
||||||
|
// that as "system". Stored per account (not just in localStorage) so each
|
||||||
|
// account in a linked set keeps its own look when the browser toggles
|
||||||
|
// between them; the theme menu persists it via saveUserTheme
|
||||||
|
// (lib/actions/profile.ts) and app/layout.tsx applies it on every load.
|
||||||
|
theme String?
|
||||||
// The very first person to sign up becomes ADMIN regardless of
|
// The very first person to sign up becomes ADMIN regardless of
|
||||||
// signupMode (the site needs at least one admin to bootstrap). Everyone
|
// signupMode (the site needs at least one admin to bootstrap). Everyone
|
||||||
// after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
|
// after that gets USER (signupMode OPEN) or PENDING (APPROVED/CONFIRMED),
|
||||||
|
|
@ -46,6 +64,104 @@ model User {
|
||||||
categories Category[]
|
categories Category[]
|
||||||
projects Project[] @relation("ProjectOwner")
|
projects Project[] @relation("ProjectOwner")
|
||||||
scheduledTodos ScheduledTodo[]
|
scheduledTodos ScheduledTodo[]
|
||||||
|
|
||||||
|
// Account linking (see lib/actions/account-links.ts): pending link
|
||||||
|
// requests this account has sent or received, confirmed links it is part
|
||||||
|
// of, request blocks it has issued, and switch tokens it has requested.
|
||||||
|
linkRequestsFrom AccountLinkRequest[] @relation("LinkRequestFrom")
|
||||||
|
linkRequestsTo AccountLinkRequest[] @relation("LinkRequestTo")
|
||||||
|
accountLinksUser AccountLink[] @relation("AccountLinkUser")
|
||||||
|
accountLinksLinked AccountLink[] @relation("AccountLinkLinkedUser")
|
||||||
|
linkBlocksFrom AccountLinkBlock[] @relation("LinkBlockFrom")
|
||||||
|
linkBlocksTo AccountLinkBlock[] @relation("LinkBlockTo")
|
||||||
|
switchTokenRequests AccountSwitchToken[] @relation("SwitchTokenRequester")
|
||||||
|
switchTokenTargets AccountSwitchToken[] @relation("SwitchTokenTarget")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pending account-link request: `fromUser` asked to link with `toUser`,
|
||||||
|
* who must decide ("Create Account Link", "Deny Account Link", or "Deny
|
||||||
|
* and Block Account Link" on the Profile page). At most one pending
|
||||||
|
* request per direction per pair (the composite unique), so re-requesting
|
||||||
|
* is a no-op upsert rather than a duplicate. Denied requests are deleted,
|
||||||
|
* not archived -- a later request simply starts fresh.
|
||||||
|
*/
|
||||||
|
model AccountLinkRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fromUserId String
|
||||||
|
toUserId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
fromUser User @relation("LinkRequestFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
|
||||||
|
toUser User @relation("LinkRequestTo", fields: [toUserId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([fromUserId, toUserId])
|
||||||
|
@@index([toUserId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A confirmed account link, stored exactly once per pair (either order) --
|
||||||
|
* lib/actions/account-links.ts normalizes the pair into the same order
|
||||||
|
* before creating, and the composite unique below makes a concurrent
|
||||||
|
* double-confirm fail with a unique violation instead of a duplicate row.
|
||||||
|
* Either linked account can remove the link; removing it does not prevent
|
||||||
|
* re-requesting (the request flow starts over).
|
||||||
|
* `userId` and `linkedUserId` must differ -- enforced by the
|
||||||
|
* account_link_not_self check constraint in the migration (Prisma has no
|
||||||
|
* schema-level CHECK syntax).
|
||||||
|
*/
|
||||||
|
model AccountLink {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
linkedUserId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation("AccountLinkUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
linkedUser User @relation("AccountLinkLinkedUser", fields: [linkedUserId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, linkedUserId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A denied-and-blocked pair: `fromUser` may no longer send account-link
|
||||||
|
* requests to `toUser` (checked in requestAccountLink). Created by the
|
||||||
|
* "Deny and Block Account Link" action; reversible by the blocked-against
|
||||||
|
* account (unblockAccountLinkRequests), since a block is permanent for the
|
||||||
|
* requester otherwise.
|
||||||
|
*/
|
||||||
|
model AccountLinkBlock {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fromUserId String
|
||||||
|
toUserId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
fromUser User @relation("LinkBlockFrom", fields: [fromUserId], references: [id], onDelete: Cascade)
|
||||||
|
toUser User @relation("LinkBlockTo", fields: [toUserId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([fromUserId, toUserId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short-lived, single-use token letting one linked account switch the
|
||||||
|
* browser's session to the other without knowing its password (the
|
||||||
|
* Profile page "Toggle" button). Created by toggleAccount (lib/actions/
|
||||||
|
* account-links.ts) after the link is verified, consumed exactly once by
|
||||||
|
* authorize() in the "account-switch" provider of auth.ts.
|
||||||
|
*/
|
||||||
|
model AccountSwitchToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
token String @unique
|
||||||
|
requestedBy String
|
||||||
|
targetUserId String
|
||||||
|
// Absolute expiry -- the token is dead after this even if unused.
|
||||||
|
expiresAt DateTime
|
||||||
|
// Set the moment authorize() accepts it. A token with usedAt set is
|
||||||
|
// never valid again, so a replayed URL can't switch twice.
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
requester User @relation("SwitchTokenRequester", fields: [requestedBy], references: [id], onDelete: Cascade)
|
||||||
|
target User @relation("SwitchTokenTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ async function main() {
|
||||||
email: "dev@example.com",
|
email: "dev@example.com",
|
||||||
passwordHash,
|
passwordHash,
|
||||||
name: "Dev User",
|
name: "Dev User",
|
||||||
|
// Mirrors the migration's backfill of the legacy `name` into the
|
||||||
|
// profile fields (first word / rest) so seeded accounts look the
|
||||||
|
// same as ones that predate the Profile page.
|
||||||
|
firstName: "Dev",
|
||||||
|
lastName: "User",
|
||||||
role: Role.ADMIN,
|
role: Role.ADMIN,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/** The signed-in user's profile as rendered by the Profile page and the
|
||||||
|
* sidebar -- nulls mean "not set". */
|
||||||
|
export interface ProfileDTO {
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A shared identity display: name when the account set one (profile or
|
||||||
|
* sign-up name), otherwise null and the email stands on its own. */
|
||||||
|
export interface LinkAccountIdentity {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pending link request addressed to the signed-in account (they asked,
|
||||||
|
* we decide). */
|
||||||
|
export interface PendingLinkRequestDTO extends LinkAccountIdentity {
|
||||||
|
requestId: string;
|
||||||
|
requestedAtLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A confirmed link: the other account in the pair. */
|
||||||
|
export interface LinkedAccountDTO extends LinkAccountIdentity {
|
||||||
|
linkId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An account the signed-in account blocked from sending link requests. */
|
||||||
|
export interface BlockedRequesterDTO extends LinkAccountIdentity {
|
||||||
|
blockId: string;
|
||||||
|
blockedAtLabel: string;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue