From 4a5693099d7caa5d12429e8431a8fe096376adfc Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:33:04 +0200 Subject: [PATCH] feat: automatic watching, Jira-style comments, save-on-change settings, MUI tooltips and Google account linking - Watching: people start watching a task or page only by taking part (now also by reacting or attaching files) and can stop watching themselves; nobody can add or remove anyone else, and the manual "start watching" route is gone. - A task link from a notification opens the board of the space the task lives in. - Comments: the text, actions and replies line up under the author's name, beside the avatar. - The space Tasks tab folds its epic and view options into the space menu instead of a second one. - Settings: Profile, Preferences and every space settings form show Save changes only when something changed, on the right, in space that is always reserved so nothing jumps. - Profile: a larger picture with change and remove controls on hover. - Sidebar: long titles under a space truncate with an ellipsis. - Saving rich text drops empty lines and line breaks after the last words. - Tooltips: icon buttons, add buttons and other hover hints use MUI tooltips. Files' New folder and Upload are icon buttons. - Email change: the confirmation and the warning share a subject and thread headers, so one mailbox shows them as one conversation; the warning reads as a normal change. - Google sign-in: an unknown Google account on a browser that has signed in to Strata before, or one whose address an account already uses, is offered to link to that account with its password (and two-step code) instead of creating a duplicate account or failing. --- app/frontend/src/auth.tsx | 22 ++- .../src/components/AssigneeButton.tsx | 10 +- app/frontend/src/components/BoardCanvas.tsx | 3 +- app/frontend/src/components/Button.tsx | 13 +- .../src/components/CategoriesEditor.tsx | 3 +- app/frontend/src/components/CommentsPanel.tsx | 2 +- app/frontend/src/components/DatePoll.tsx | 3 +- app/frontend/src/components/FileBrowser.tsx | 12 +- app/frontend/src/components/FileButton.tsx | 10 +- app/frontend/src/components/FilterMenu.tsx | 33 ++-- app/frontend/src/components/IconButton.tsx | 8 + app/frontend/src/components/LinkActions.tsx | 12 +- .../src/components/MonthStartField.tsx | 5 +- app/frontend/src/components/NewTask.tsx | 32 ++-- app/frontend/src/components/NoteComments.tsx | 34 +++-- app/frontend/src/components/Page.tsx | 11 +- app/frontend/src/components/PagePeople.tsx | 3 +- app/frontend/src/components/PageTree.tsx | 25 ++-- app/frontend/src/components/PeriodNav.tsx | 2 +- app/frontend/src/components/RichText.tsx | 3 +- app/frontend/src/components/SavedForm.tsx | 59 ++++++++ app/frontend/src/components/ShareLinks.tsx | 3 +- .../src/components/SpaceNavigation.tsx | 7 +- app/frontend/src/components/TaskFrame.tsx | 27 ++-- app/frontend/src/components/TaskSheet.tsx | 2 +- app/frontend/src/components/TaskTicket.tsx | 134 +++++++++-------- app/frontend/src/components/TaskViews.tsx | 3 +- app/frontend/src/components/TaskWorkspace.tsx | 35 ++++- app/frontend/src/components/Tip.tsx | 10 ++ app/frontend/src/components/UserAvatar.tsx | 21 ++- app/frontend/src/components/WatchButton.tsx | 141 ++++++------------ .../src/components/database/DatabaseView.tsx | 3 +- .../components/database/PropertiesDialog.tsx | 3 +- app/frontend/src/components/media-blocks.tsx | 3 +- app/frontend/src/dashboard/Dashboard.tsx | 3 +- .../src/dashboard/outside-widgets.tsx | 13 +- app/frontend/src/dashboard/plans-widgets.tsx | 21 +-- app/frontend/src/dashboard/time-widgets.tsx | 3 +- app/frontend/src/dashboard/widgets.tsx | 3 +- app/frontend/src/pages/BoardPage.tsx | 5 +- app/frontend/src/pages/CalendarsPage.tsx | 3 +- app/frontend/src/pages/DrivePage.tsx | 8 +- app/frontend/src/pages/GoogleCallbackPage.tsx | 72 ++++++++- app/frontend/src/pages/MealPlanPage.tsx | 3 +- app/frontend/src/pages/NotePage.tsx | 5 +- app/frontend/src/pages/TasksPage.tsx | 12 +- app/frontend/src/pages/TransactionsPage.tsx | 3 +- .../pages/settings/NotificationSettings.tsx | 3 +- .../pages/settings/PreferencesSettings.tsx | 90 ++++++----- .../src/pages/settings/ProfileSettings.tsx | 106 +++++++------ .../src/pages/settings/SecuritySettings.tsx | 3 +- app/frontend/src/pages/spaces/SpaceLayout.tsx | 2 +- .../src/pages/spaces/SpaceSettings.tsx | 44 +----- app/frontend/src/pages/spaces/SpaceTasks.tsx | 3 +- app/frontend/src/rich-document.ts | 15 ++ app/frontend/src/styles/global.css | 81 +++++++++- app/frontend/src/task-draft.ts | 2 +- app/frontend/src/time.tsx | 3 +- app/server/src/auth/auth.controller.ts | 49 +++++- app/server/src/auth/auth.service.ts | 9 +- app/server/src/auth/dto/google.dto.ts | 8 + app/server/src/google/google-auth.service.ts | 66 ++++++-- app/server/src/mail/templates.ts | 10 +- .../src/tasks/task-attachments.service.ts | 4 + app/server/src/tasks/task-comments.service.ts | 6 +- .../src/watchers/watchers.controller.ts | 24 +-- app/server/src/watchers/watchers.service.ts | 21 +-- app/server/test/account.spec.ts | 12 ++ app/server/test/google-auth.spec.ts | 45 +++++- app/server/test/integration/tasks.spec.ts | 23 +-- app/server/test/rich-document.spec.ts | 32 ++++ 71 files changed, 969 insertions(+), 543 deletions(-) create mode 100644 app/frontend/src/components/IconButton.tsx create mode 100644 app/frontend/src/components/SavedForm.tsx create mode 100644 app/frontend/src/components/Tip.tsx create mode 100644 app/frontend/src/rich-document.ts create mode 100644 app/server/test/rich-document.spec.ts diff --git a/app/frontend/src/auth.tsx b/app/frontend/src/auth.tsx index 8fc5c425..190d7b56 100644 --- a/app/frontend/src/auth.tsx +++ b/app/frontend/src/auth.tsx @@ -44,7 +44,9 @@ export interface Proof { code?: string; } -export type GoogleFinish = { signedIn: true } | { challenge: string } | { connected: 'link' | 'calendar' | 'drive'; returnTo: string }; +export type GoogleChoice = { choose: 'email' | 'browser'; email: string }; + +export type GoogleFinish = { signedIn: true } | { challenge: string } | { connected: 'link' | 'calendar' | 'drive'; returnTo: string } | GoogleChoice; interface AuthContextValue { user?: User; @@ -62,6 +64,8 @@ interface AuthContextValue { login(login: string, password: string): Promise<{ challenge: string } | undefined>; completeTwoStep(challenge: string, code: string): Promise; finishGoogle(code: string, state: string): Promise; + linkGoogleToExisting(login: string, password: string, code?: string): Promise<{ twoStep: true } | undefined>; + createFromGoogle(): Promise; logout(): Promise; changePassword(proof: Proof, newPassword: string): Promise; deleteAccount(proof: Proof): Promise; @@ -187,15 +191,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); }, finishGoogle: async (code, state) => { - const result = await apiRequest('/auth/google/finish', { + const result = await apiRequest('/auth/google/finish', { method: 'POST', body: JSON.stringify({ code, state }), }); if ('challenge' in result) return { challenge: result.challenge }; - if ('connected' in result) return result; + if ('connected' in result || 'choose' in result) return result; applySession(result); return { signedIn: true }; }, + linkGoogleToExisting: async (login, password, code) => { + const result = await apiRequest('/auth/google/existing', { + method: 'POST', + body: JSON.stringify({ login, password, ...(code ? { code } : {}) }), + }); + if ('twoStep' in result) return result; + applySession(result); + return undefined; + }, + createFromGoogle: async () => { + applySession(await apiRequest('/auth/google/new', { method: 'POST' })); + }, logout: async () => { await apiRequest('/auth/logout', { method: 'POST' }); applySession(undefined); diff --git a/app/frontend/src/components/AssigneeButton.tsx b/app/frontend/src/components/AssigneeButton.tsx index a48c69c0..1077304e 100644 --- a/app/frontend/src/components/AssigneeButton.tsx +++ b/app/frontend/src/components/AssigneeButton.tsx @@ -7,6 +7,7 @@ import type { Member } from '../spaces'; import type { Task } from '../tasks'; import { useResource } from '../useResource'; import { UserAvatar } from './UserAvatar'; +import { Tip } from './Tip'; export function AssigneeButton({ onAssign, task }: { onAssign?: (assignee: Task['assignee']) => void; task: Task }) { const [anchor, setAnchor] = useState(null); @@ -21,7 +22,14 @@ export function AssigneeButton({ onAssign, task }: { onAssign?: (assignee: Task[ ); - if (!onAssign) return task.assignee && {mark}; + if (!onAssign) + return ( + task.assignee && ( + + {mark} + + ) + ); const choose = (assignee: Task['assignee']) => { setAnchor(null); if (assignee?.id !== task.assignee?.id) onAssign(assignee); diff --git a/app/frontend/src/components/BoardCanvas.tsx b/app/frontend/src/components/BoardCanvas.tsx index ce8cdabd..27c573f1 100644 --- a/app/frontend/src/components/BoardCanvas.tsx +++ b/app/frontend/src/components/BoardCanvas.tsx @@ -4,7 +4,8 @@ import { CaptureUpdateAction, convertToExcalidrawElements, Excalidraw, exportToB import type { RemoteExcalidrawElement } from '@excalidraw/excalidraw/data/reconcile'; import type { ExcalidrawElement, ExcalidrawTextElement, FileId } from '@excalidraw/excalidraw/element/types'; import type { AppState, Collaborator, ExcalidrawImperativeAPI, ExcalidrawProps, SocketId } from '@excalidraw/excalidraw/types'; -import { Banner, FloatingToolbar, IconButton, LinesSkeleton, ToolbarButton, ToolbarSeparator, useSnackbar, type MenuItem } from '@project-graphite/ui'; +import { Banner, FloatingToolbar, LinesSkeleton, ToolbarButton, ToolbarSeparator, useSnackbar, type MenuItem } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; import type * as Y from 'yjs'; import { useAuth } from '../auth'; import { fileSize, uploadFile } from '../files'; diff --git a/app/frontend/src/components/Button.tsx b/app/frontend/src/components/Button.tsx index 11167e0f..7cd5a230 100644 --- a/app/frontend/src/components/Button.tsx +++ b/app/frontend/src/components/Button.tsx @@ -1,13 +1,16 @@ -import { Icon, Button as UiButton, IconButton, type IconName } from '@project-graphite/ui'; +import { Icon, Button as UiButton, type IconName } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; +import { Tip } from './Tip'; const look = (large: boolean, primary: boolean) => ({ size: large ? 'medium' : 'small', variant: primary ? 'primary' : 'secondary' }) as const; -export function AddButton({ disabled, label, large = false, onClick, primary = false }: { disabled?: boolean; label: string; large?: boolean; onClick?: () => void; primary?: boolean }) { - return ( - - +export function AddButton({ disabled, icon = 'plus', label, large = false, onClick, primary = false }: { disabled?: boolean; icon?: IconName; label: string; large?: boolean; onClick?: () => void; primary?: boolean }) { + const button = ( + + ); + return {disabled ? {button} : button}; } export function Button({ diff --git a/app/frontend/src/components/CategoriesEditor.tsx b/app/frontend/src/components/CategoriesEditor.tsx index ac48f673..bd080831 100644 --- a/app/frontend/src/components/CategoriesEditor.tsx +++ b/app/frontend/src/components/CategoriesEditor.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { Banner, ConfirmDialog, IconButton, Select, type TagColor, tagColors, TextField } from '@project-graphite/ui'; +import { Banner, ConfirmDialog, Select, type TagColor, tagColors, TextField } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; import { useAuth } from '../auth'; import { categoryName, type SpendingCategory } from '../spending'; import { useAction } from '../useAction'; diff --git a/app/frontend/src/components/CommentsPanel.tsx b/app/frontend/src/components/CommentsPanel.tsx index 797d348c..258080ae 100644 --- a/app/frontend/src/components/CommentsPanel.tsx +++ b/app/frontend/src/components/CommentsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; -import { IconButton } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; const widthKey = 'strata-comments-width'; const minWidth = 348; diff --git a/app/frontend/src/components/DatePoll.tsx b/app/frontend/src/components/DatePoll.tsx index d0a6d6cb..d86d6516 100644 --- a/app/frontend/src/components/DatePoll.tsx +++ b/app/frontend/src/components/DatePoll.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { DatePicker, formatDay, IconButton } from '@project-graphite/ui'; +import { DatePicker, formatDay } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; import { useAction } from '../useAction'; import { clockTime, TimeField } from '../time'; import { Hint } from './Hint'; diff --git a/app/frontend/src/components/FileBrowser.tsx b/app/frontend/src/components/FileBrowser.tsx index a1596c4b..763617c1 100644 --- a/app/frontend/src/components/FileBrowser.tsx +++ b/app/frontend/src/components/FileBrowser.tsx @@ -1,5 +1,6 @@ import { Fragment, lazy, Suspense, useRef, useState, type HTMLAttributes, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode, type SyntheticEvent } from 'react'; -import { Dialog, Icon, IconButton, type IconName } from '@project-graphite/ui'; +import { Dialog, Icon, type IconName } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; import { FilePreview, fileSize, previewable } from '../files'; import { formatMoment } from '../time'; import { ActionsMenu } from './ActionsMenu'; @@ -9,6 +10,7 @@ import { DialogActions } from './DialogActions'; import { FilterMenu, type FilterGroup } from './FilterMenu'; import { Hint } from './Hint'; import { ContextMenu, NestedMenu, type NestedMenuItem } from './NestedMenu'; +import { Tip } from './Tip'; const PdfViewer = lazy(() => import('./PdfViewer')); @@ -281,9 +283,11 @@ export function FileBrowser({ {entry.name} {entry.shared && ( - - - + + + + + )} {entry.detail && {entry.detail}} diff --git a/app/frontend/src/components/FileButton.tsx b/app/frontend/src/components/FileButton.tsx index 305fee4a..a1e94ede 100644 --- a/app/frontend/src/components/FileButton.tsx +++ b/app/frontend/src/components/FileButton.tsx @@ -1,5 +1,7 @@ import { useRef } from 'react'; -import { Button, IconButton } from '@project-graphite/ui'; +import { Button } from '@project-graphite/ui'; +import { AddButton } from './Button'; +import { IconButton } from './IconButton'; export function FileButton({ accept, @@ -28,7 +30,11 @@ export function FileButton({ return ( <> {iconOnly ? ( - input.current?.click()} size="small" /> + primary ? ( + input.current?.click()} primary /> + ) : ( + input.current?.click()} size="small" /> + ) ) : ( - )} -

{comment.body}

-
- {commentable && !comment.parentId && !comment.resolvedAt && setReplying(comment.id)} />} - {actions.length > 0 && } +
+ +
+

+ {author} · {formatMoment(comment.createdAt)} + {comment.editedAt ? ' · edited' : ''} +

+ {comment.blockId && ( + + )} +

{comment.body}

+
+ {commentable && !comment.parentId && !comment.resolvedAt && setReplying(comment.id)} />} + {actions.length > 0 && } +
); @@ -238,7 +240,7 @@ export function NoteComments({ {shown.map((thread) => (
{renderComment(thread)} -
+
{all.filter((comment) => comment.parentId === thread.id).map(renderComment)} {replying === thread.id && setReplying(undefined)} onSave={(body) => add(body, thread.id)} people={people} />}
diff --git a/app/frontend/src/components/Page.tsx b/app/frontend/src/components/Page.tsx index bec04976..b45b7b0b 100644 --- a/app/frontend/src/components/Page.tsx +++ b/app/frontend/src/components/Page.tsx @@ -6,6 +6,7 @@ import { LoadError } from './LoadError'; import { ButtonSkeleton, IconButtonSkeleton, SkeletonLine } from './Skeletons'; import { HeaderTitleContext } from '../header-title'; import { areaOf } from '../navigation'; +import { Tip } from './Tip'; export interface Back { to: string; @@ -31,10 +32,12 @@ export function useDocumentTitle(title: string | undefined, heading?: { title?: export function BackLink({ back }: { back: Back }) { return ( - - - {back.label} - + + + + {back.label} + + ); } diff --git a/app/frontend/src/components/PagePeople.tsx b/app/frontend/src/components/PagePeople.tsx index ac9003a2..520d3800 100644 --- a/app/frontend/src/components/PagePeople.tsx +++ b/app/frontend/src/components/PagePeople.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { IconButton, ListRow, SectionHeading, Select, TextField } from '@project-graphite/ui'; +import { ListRow, SectionHeading, Select, TextField } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; import { useAuth } from '../auth'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; diff --git a/app/frontend/src/components/PageTree.tsx b/app/frontend/src/components/PageTree.tsx index c6b60b2e..8f45be23 100644 --- a/app/frontend/src/components/PageTree.tsx +++ b/app/frontend/src/components/PageTree.tsx @@ -13,6 +13,7 @@ import { TextButton } from './Button'; import { CrashBoundary } from './CrashBoundary'; import { NoteMark, NoteTitle } from './NoteMark'; import { SidebarResize } from './SidebarResize'; +import { Tip } from './Tip'; type Place = 'before' | 'inside' | 'after'; type Move = { parentId: string | null } | { relativeTo: string; placement: 'before' | 'after' }; @@ -181,9 +182,11 @@ function GroupRow({ add, branch, children, depth, href, icon, label }: { add?: { {label} {add && ( - + + + )}
{branch && {children}} @@ -333,9 +336,11 @@ function SidebarTrees() {

Spaces

- - - + + + + +
{shown && spaces.data?.filter((space) => !space.archived).map((space) => )}
@@ -358,9 +363,11 @@ function SidebarTrees() { }} >

Pages

- + + +
{shown && } diff --git a/app/frontend/src/components/PeriodNav.tsx b/app/frontend/src/components/PeriodNav.tsx index bd111294..a382c2eb 100644 --- a/app/frontend/src/components/PeriodNav.tsx +++ b/app/frontend/src/components/PeriodNav.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { IconButton } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; export function PeriodNav({ disabled, diff --git a/app/frontend/src/components/RichText.tsx b/app/frontend/src/components/RichText.tsx index dcd10f9a..48d416fe 100644 --- a/app/frontend/src/components/RichText.tsx +++ b/app/frontend/src/components/RichText.tsx @@ -12,6 +12,7 @@ import { cspNonce } from '../csp-nonce'; import type { Member } from '../spaces'; import { LinkActions } from './LinkActions'; import { UserAvatar } from './UserAvatar'; +import { withoutTrailingLines } from '../rich-document'; export type RichDocument = Record | null; @@ -156,7 +157,7 @@ export function RichTextEditor({ onSelectionUpdate: ({ editor: current }) => track(current), onUpdate: ({ editor: current }) => { track(current); - latest.current(current.isEmpty ? null : (current.getJSON() as RichDocument), current.getText({ blockSeparator: '\n' }).trim()); + latest.current(current.isEmpty ? null : withoutTrailingLines(current.getJSON()), current.getText({ blockSeparator: '\n' }).trim()); }, }); live.current.insert = (person: Member) => { diff --git a/app/frontend/src/components/SavedForm.tsx b/app/frontend/src/components/SavedForm.tsx new file mode 100644 index 00000000..22a2db43 --- /dev/null +++ b/app/frontend/src/components/SavedForm.tsx @@ -0,0 +1,59 @@ +import { useState, type FormEvent, type ReactNode } from 'react'; +import { Icon } from '@project-graphite/ui'; +import { useAction } from '../useAction'; +import { Button } from './Button'; + +export const same = (first: unknown, second: unknown) => JSON.stringify(first) === JSON.stringify(second); + +export function SavedForm({ + children, + className = 'settings-form', + initial, + invalid = false, + label, + onSave, + values, +}: { + children: ReactNode; + className?: string; + initial: T; + invalid?: boolean; + label: string; + onSave: () => Promise; + values: T; +}) { + const saving = useAction(); + const [saved, setSaved] = useState(false); + const dirty = !same(initial, values); + return ( +
setSaved(false)} + onSubmit={(event: FormEvent) => { + event.preventDefault(); + if (!dirty || invalid) return; + void saving.run(async () => { + await onSave(); + setSaved(true); + return ''; + }, `Could not save ${label.toLowerCase()}`); + }} + > + {children} +
+ + {dirty ? 'Unsaved changes' : saved ? ( + + Saved + + ) : null} + + +
+
+ ); +} diff --git a/app/frontend/src/components/ShareLinks.tsx b/app/frontend/src/components/ShareLinks.tsx index 95a6d494..504fdac6 100644 --- a/app/frontend/src/components/ShareLinks.tsx +++ b/app/frontend/src/components/ShareLinks.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { ConfirmDialog, Dialog, IconButton, ListRow, Placeholder, SectionHeading, Select, TextField } from '@project-graphite/ui'; +import { ConfirmDialog, Dialog, ListRow, Placeholder, SectionHeading, Select, TextField } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; import { useAuth } from '../auth'; import { formatMoment } from '../time'; import { useAction } from '../useAction'; diff --git a/app/frontend/src/components/SpaceNavigation.tsx b/app/frontend/src/components/SpaceNavigation.tsx index 45417aca..9a96648d 100644 --- a/app/frontend/src/components/SpaceNavigation.tsx +++ b/app/frontend/src/components/SpaceNavigation.tsx @@ -7,6 +7,7 @@ import { HeaderTitleContext } from '../header-title'; import { noteAddress, noteHref, noteTitle, pageHref } from '../notes'; import { usePageNavigation } from '../page-navigation'; import { findSpace, spaceHref, useAllSpaces, useCurrentSpace } from '../spaces'; +import { Tip } from './Tip'; const areaNames: Record = { '/': 'Home', '/spaces': 'Spaces', '/inbox': 'Inbox', '/settings': 'Settings', '/admin': 'Admin', '/save': 'Save', '/more': 'More' }; @@ -40,11 +41,11 @@ export function SpaceNavigation({ active }: { active: string | null }) {
  • {index > 0 && } {crumb === currentCrumb ? ( - {crumb.label} + {crumb.label} ) : crumb.href ? ( - {crumb.label} + {crumb.label} ) : ( - {crumb.label} + {crumb.label} )}
  • diff --git a/app/frontend/src/components/TaskFrame.tsx b/app/frontend/src/components/TaskFrame.tsx index 8d617d35..243150ee 100644 --- a/app/frontend/src/components/TaskFrame.tsx +++ b/app/frontend/src/components/TaskFrame.tsx @@ -2,8 +2,10 @@ import MuiDialog from '@mui/material/Dialog'; import MuiIconButton from '@mui/material/IconButton'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useEffect, useId, useState, type ReactNode } from 'react'; -import { Icon, IconButton, Sheet } from '@project-graphite/ui'; +import { Icon, Sheet } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; import { ExpandGlyph } from './glyphs'; +import { Tip } from './Tip'; type FrameView = 'panel' | 'modal'; @@ -58,17 +60,18 @@ export function TaskFrame({ const titleId = useId(); const toggleLabel = view === 'modal' ? 'Open in the side panel' : 'Open in the middle of the screen'; const toggle = viewMenu ?? (!phone && ( - { - const next = view === 'modal' ? 'panel' : 'modal'; - storeView(next); - setView(next); - }} - title={toggleLabel} - > - {view === 'modal' ? : } - + + { + const next = view === 'modal' ? 'panel' : 'modal'; + storeView(next); + setView(next); + }} + > + {view === 'modal' ? : } + + )); if (phone || view === 'panel') { diff --git a/app/frontend/src/components/TaskSheet.tsx b/app/frontend/src/components/TaskSheet.tsx index d0b5117b..8ec0b00b 100644 --- a/app/frontend/src/components/TaskSheet.tsx +++ b/app/frontend/src/components/TaskSheet.tsx @@ -150,7 +150,7 @@ export default function TaskSheet({ composer, draft: controlledDraft, onChanged, task && ( <> - {task.spaceId && } + {task.spaceId && } {editable && ( JSON.stringify(first) === JSON.stringify(second); @@ -273,9 +275,11 @@ function Reactions({ comment, onReact }: { comment: Comment; onReact: (emoji: st {reaction.count} ))} - setAnchor(event.currentTarget)} size="small" title="Add a reaction"> - - + + setAnchor(event.currentTarget)} size="small"> + + + setAnchor(null)} open={Boolean(anchor)} slotProps={{ paper: { className: 'reaction-picker' } }}> {reactionChoices.map((emoji) => ( - )} - - )) - : [
  • Nobody yet
  • ]), - ...(manage - ? [ - , - setAdding(true)}> - - Add a watcher - , - ] - : []), - ]} + + setAnchor(event.currentTarget)} + > + + + + + {[ + ...(watching + ? [ + { + close(); + void action.run(async () => { + const next = await auth.request(`/items/${itemId}/watch`, { method: 'DELETE' }); + status.mutate(() => next); + return 'You stopped watching this.'; + }, 'Could not stop watching'); + }} + > + + Stop watching + , + , + ] + : []), + + Watching this · {watchers.length} + , + ...(watchers.length > 0 + ? watchers.map((watcher) => ( +
  • + + {watcher.id === auth.user?.id ? `${watcher.displayName} (you)` : watcher.displayName} +
  • + )) + : [
  • Nobody yet
  • ]), + ]}
    ); diff --git a/app/frontend/src/components/database/DatabaseView.tsx b/app/frontend/src/components/database/DatabaseView.tsx index f80727b6..f7ba451a 100644 --- a/app/frontend/src/components/database/DatabaseView.tsx +++ b/app/frontend/src/components/database/DatabaseView.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode } from 'react'; import { Link, useNavigate } from 'react-router'; -import { formatDay, IconButton, ListRow, Placeholder, SegmentedControl, Select, Skeleton } from '@project-graphite/ui'; +import { formatDay, ListRow, Placeholder, SegmentedControl, Select, Skeleton } from '@project-graphite/ui'; +import { IconButton } from '../IconButton'; import { addDays, dayKey, monthDays } from '../../agenda'; import { useAuth } from '../../auth'; import { databaseViews, noteHref, noteTitle, type DatabaseRow, type Note, type NoteDatabase, type Property, type PropertyValues } from '../../notes'; diff --git a/app/frontend/src/components/database/PropertiesDialog.tsx b/app/frontend/src/components/database/PropertiesDialog.tsx index ea7922b0..41a88067 100644 --- a/app/frontend/src/components/database/PropertiesDialog.tsx +++ b/app/frontend/src/components/database/PropertiesDialog.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { Banner, ConfirmDialog, IconButton, Select, tagColors, TextField } from '@project-graphite/ui'; +import { Banner, ConfirmDialog, Select, tagColors, TextField } from '@project-graphite/ui'; +import { IconButton } from '../IconButton'; import { AddButton } from '../Button'; import { FormDialog } from '../FormDialog'; import { propertyTypes, type Property, type PropertyType } from '../../notes'; diff --git a/app/frontend/src/components/media-blocks.tsx b/app/frontend/src/components/media-blocks.tsx index d3cd9365..c81cbdda 100644 --- a/app/frontend/src/components/media-blocks.tsx +++ b/app/frontend/src/components/media-blocks.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'; -import { FloatingToolbar, Icon, IconButton, ToolbarButton, useSnackbar } from '@project-graphite/ui'; +import { FloatingToolbar, Icon, ToolbarButton, useSnackbar } from '@project-graphite/ui'; +import { IconButton } from './IconButton'; import { Node } from '@tiptap/core'; import Image from '@tiptap/extension-image'; import Youtube from '@tiptap/extension-youtube'; diff --git a/app/frontend/src/dashboard/Dashboard.tsx b/app/frontend/src/dashboard/Dashboard.tsx index 49d94744..34418200 100644 --- a/app/frontend/src/dashboard/Dashboard.tsx +++ b/app/frontend/src/dashboard/Dashboard.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type CSSProperties } from 'react'; import { Navigate, useNavigate, useSearchParams } from 'react-router'; -import { ColorSwatches, ConfirmDialog, Dialog, Fieldset, formatDay, IconButton, PageSkeleton, Radio, Select, TextField, type TagColor } from '@project-graphite/ui'; +import { ColorSwatches, ConfirmDialog, Dialog, Fieldset, formatDay, PageSkeleton, Radio, Select, TextField, type TagColor } from '@project-graphite/ui'; +import { IconButton } from '../components/IconButton'; import { appCatalogue } from '../apps'; import { useAuth } from '../auth'; import { uploadFile } from '../files'; diff --git a/app/frontend/src/dashboard/outside-widgets.tsx b/app/frontend/src/dashboard/outside-widgets.tsx index fc0aacfb..c7d681b3 100644 --- a/app/frontend/src/dashboard/outside-widgets.tsx +++ b/app/frontend/src/dashboard/outside-widgets.tsx @@ -9,6 +9,7 @@ import { useLiveRefresh, useResource } from '../useResource'; import { SkeletonLine } from '../components/Skeletons'; import { LinkRowsSkeleton, WidgetEmpty, type SettingsProps, type WidgetProps } from './widget-parts'; import { Button } from '../components/Button'; +import { Tip } from '../components/Tip'; const outsideRefresh = { every: 15 * 60_000 }; @@ -91,11 +92,13 @@ export function Weather({ configure, settings }: WidgetProps) {
      {daily.map((day) => ( -
    • - {formatDay(day.date, { weekday: 'narrow' })} - {day.high}° - {day.low}° -
    • + +
    • + {formatDay(day.date, { weekday: 'narrow' })} + {day.high}° + {day.low}° +
    • +
      ))}
    diff --git a/app/frontend/src/dashboard/plans-widgets.tsx b/app/frontend/src/dashboard/plans-widgets.tsx index 2930bbc4..068a1b29 100644 --- a/app/frontend/src/dashboard/plans-widgets.tsx +++ b/app/frontend/src/dashboard/plans-widgets.tsx @@ -12,6 +12,7 @@ import { LoadError } from '../components/LoadError'; import { SkeletonLine } from '../components/Skeletons'; import { ShowAll, SplitRowsSkeleton, WidgetEmpty } from './widget-parts'; import { journalPageHref } from '../notes'; +import { Tip } from '../components/Tip'; const moods = ['Awful', 'Bad', 'Okay', 'Good', 'Great']; @@ -72,16 +73,16 @@ export function Journal() {
    {moods.map((label, index) => ( -
    diff --git a/app/frontend/src/dashboard/time-widgets.tsx b/app/frontend/src/dashboard/time-widgets.tsx index 920d2f6b..79810bd2 100644 --- a/app/frontend/src/dashboard/time-widgets.tsx +++ b/app/frontend/src/dashboard/time-widgets.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router'; -import { formatDay, IconButton, Meter, type TagColor } from '@project-graphite/ui'; +import { formatDay, Meter, type TagColor } from '@project-graphite/ui'; +import { IconButton } from '../components/IconButton'; import { addDays, dayKey, entryDay, monthDays, shiftMonth, useAgenda } from '../agenda'; import { LoadError } from '../components/LoadError'; import { useWeekStart } from '../tasks'; diff --git a/app/frontend/src/dashboard/widgets.tsx b/app/frontend/src/dashboard/widgets.tsx index 7a5bad5d..fcf36563 100644 --- a/app/frontend/src/dashboard/widgets.tsx +++ b/app/frontend/src/dashboard/widgets.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type ReactNode } from 'react'; import { Link } from 'react-router'; -import { Avatar, DatePicker, formatDay, Icon, IconButton, Meter, Placeholder, Select, Skeleton, TagChip, TextField, timeAgo, type IconName, type TagColor } from '@project-graphite/ui'; +import { Avatar, DatePicker, formatDay, Icon, Meter, Placeholder, Select, Skeleton, TagChip, TextField, timeAgo, type IconName, type TagColor } from '@project-graphite/ui'; +import { IconButton } from '../components/IconButton'; import type { Page } from '../api'; import { useAuth } from '../auth'; import { siteLabel, type Bookmark } from '../bookmarks'; diff --git a/app/frontend/src/pages/BoardPage.tsx b/app/frontend/src/pages/BoardPage.tsx index 8c05bb69..5a8bb354 100644 --- a/app/frontend/src/pages/BoardPage.tsx +++ b/app/frontend/src/pages/BoardPage.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; import { Navigate, useLocation, useNavigate, useParams } from 'react-router'; -import { IconButton, LinesSkeleton } from '@project-graphite/ui'; +import { LinesSkeleton } from '@project-graphite/ui'; +import { IconButton } from '../components/IconButton'; import { readBlob } from '../api'; import { useAuth } from '../auth'; import { WatchButton } from '../components/WatchButton'; @@ -110,7 +111,7 @@ export function BoardPage() { ), actions: ( <> - {details.spaceId && } + {details.spaceId && } {details.permissions.share && setOpen('share')} />} setCommenting(!commenting)} open={commenting} /> diff --git a/app/frontend/src/pages/CalendarsPage.tsx b/app/frontend/src/pages/CalendarsPage.tsx index 5c838cec..4aa8ca83 100644 --- a/app/frontend/src/pages/CalendarsPage.tsx +++ b/app/frontend/src/pages/CalendarsPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { Banner, ConfirmDialog, IconButton, ListRow, Placeholder, Skeleton, TextField, timeAgo } from '@project-graphite/ui'; +import { Banner, ConfirmDialog, ListRow, Placeholder, Skeleton, TextField, timeAgo } from '@project-graphite/ui'; +import { IconButton } from '../components/IconButton'; import { useAuth } from '../auth'; import { connectGoogle, googleAllowed, type GoogleStatus } from '../google'; import { ActionsMenu } from '../components/ActionsMenu'; diff --git a/app/frontend/src/pages/DrivePage.tsx b/app/frontend/src/pages/DrivePage.tsx index a516d66e..47dbcd1a 100644 --- a/app/frontend/src/pages/DrivePage.tsx +++ b/app/frontend/src/pages/DrivePage.tsx @@ -1,11 +1,11 @@ import { useRef, useState, type DragEvent } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; -import { Icon, IconButton, Placeholder, TextField } from '@project-graphite/ui'; +import { Icon, Placeholder, TextField } from '@project-graphite/ui'; +import { IconButton } from '../components/IconButton'; import { readBlob } from '../api'; import { useAuth } from '../auth'; import { connectGoogle, googleAllowed, pickDriveFiles, type GoogleStatus } from '../google'; import { filesTabs, saveBlob, uploadFiles, type DriveEntry, type DriveListing } from '../files'; -import { Button } from '../components/Button'; import { Empty } from '../components/Empty'; import { FileBrowser, type BrowserSort } from '../components/FileBrowser'; import { FileButton } from '../components/FileButton'; @@ -211,9 +211,9 @@ export function DrivePage() { actions={ writable && ( <> - {!spaceId &&
    - + ); } diff --git a/app/frontend/src/pages/settings/ProfileSettings.tsx b/app/frontend/src/pages/settings/ProfileSettings.tsx index a00a624c..361d84af 100644 --- a/app/frontend/src/pages/settings/ProfileSettings.tsx +++ b/app/frontend/src/pages/settings/ProfileSettings.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { IconButton, Placeholder, TextField } from '@project-graphite/ui'; +import { Placeholder, TextField } from '@project-graphite/ui'; +import { IconButton } from '../../components/IconButton'; import { useAuth } from '../../auth'; import { FileButton } from '../../components/FileButton'; import { FormDialog } from '../../components/FormDialog'; @@ -9,11 +10,11 @@ import { ProofFields, proofFrom } from '../../components/ProofFields'; import { forgetAvatar, UserAvatar } from '../../components/UserAvatar'; import { useAction } from '../../useAction'; import { useResource } from '../../useResource'; -import { atMost, emailAddress, required, useFormErrors } from '../../validation'; +import { emailAddress, required, useFormErrors } from '../../validation'; import { Section } from '../../components/Page'; import { ButtonSkeleton, FieldSkeleton } from '../../components/Skeletons'; +import { SavedForm } from '../../components/SavedForm'; import { SettingsRow, SettingsRowSkeleton, SettingsRows, type Me, type TwoStepStatus } from './SettingsLayout'; -import { Button } from '../../components/Button'; const pictureSide = 256; @@ -31,12 +32,11 @@ export function ProfileSettings() { const auth = useAuth(); const me = useResource('/me', true); const twoStep = useResource('/me/two-step', true); - const profile = useAction(); const picture = useAction(); const email = useAction(); - const profileForm = useFormErrors(); const emailForm = useFormErrors(); const [changingEmail, setChangingEmail] = useState(false); + const [displayName, setDisplayName] = useState(); if (me.error) return ; if (twoStep.error) return ; @@ -59,6 +59,8 @@ export function ProfileSettings() { ); } const current = me.data; + const shownName = displayName ?? current.displayName; + const nameProblem = shownName.trim() ? undefined : 'Enter a display name.'; const changePicture = (avatarId: string | null) => { forgetAvatar(current.id); me.mutate((shown) => ({ ...shown, avatarId })); @@ -68,63 +70,59 @@ export function ProfileSettings() { return ( <>
    -
    +
    - - void picture.run(async () => { - const { avatarId } = await auth.request<{ avatarId: string }>('/me/avatar', { - method: 'PUT', - headers: { 'Content-Type': 'application/octet-stream', 'X-File-Name': 'profile.png' }, - body: await squared(file!), - }); - changePicture(avatarId); - return 'Updated your profile picture.'; - }, 'Could not update your profile picture') - } - /> - {current.avatarId && ( - +
    + void picture.run(async () => { - await auth.request('/me/avatar', { method: 'DELETE' }); - changePicture(null); - return 'Removed your profile picture.'; - }, 'Could not remove your profile picture') + const { avatarId } = await auth.request<{ avatarId: string }>('/me/avatar', { + method: 'PUT', + headers: { 'Content-Type': 'application/octet-stream', 'X-File-Name': 'profile.png' }, + body: await squared(file!), + }); + changePicture(avatarId); + return 'Updated your profile picture.'; + }, 'Could not update your profile picture') } - size="small" /> - )} + {current.avatarId && ( + + void picture.run(async () => { + await auth.request('/me/avatar', { method: 'DELETE' }); + changePicture(null); + return 'Removed your profile picture.'; + }, 'Could not remove your profile picture') + } + size="small" + /> + )} +
    -
    { - event.preventDefault(); - const target = event.currentTarget; - if (!profileForm.check(target, { displayName: [required('Enter a display name.'), atMost(80, 'Use at most 80 characters.')] })) return; - const displayName = String(new FormData(target).get('displayName')).trim(); - void profile.run(async () => { - const next = await auth.request('/me', { method: 'PATCH', body: JSON.stringify({ displayName }) }); - me.mutate(() => next); - auth.updateUser({ displayName: next.displayName }); - return 'Saved your name.'; - }, 'Could not save your name'); + { + const next = await auth.request('/me', { method: 'PATCH', body: JSON.stringify({ displayName: shownName.trim() }) }); + me.mutate(() => next); + auth.updateUser({ displayName: next.displayName }); + setDisplayName(undefined); }} + values={shownName} > - -
    -
    - + setDisplayName(event.currentTarget.value)} value={shownName} /> +
    diff --git a/app/frontend/src/pages/settings/SecuritySettings.tsx b/app/frontend/src/pages/settings/SecuritySettings.tsx index ddd58528..8d214fe6 100644 --- a/app/frontend/src/pages/settings/SecuritySettings.tsx +++ b/app/frontend/src/pages/settings/SecuritySettings.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { Card, CodeInput, ConfirmDialog, Dialog, IconButton, Placeholder, TextField, TextLink } from '@project-graphite/ui'; +import { Card, CodeInput, ConfirmDialog, Dialog, Placeholder, TextField, TextLink } from '@project-graphite/ui'; +import { IconButton } from '../../components/IconButton'; import { useAuth } from '../../auth'; import { connectGoogle, googleAllowed, type GoogleStatus } from '../../google'; import { ActionsMenu } from '../../components/ActionsMenu'; diff --git a/app/frontend/src/pages/spaces/SpaceLayout.tsx b/app/frontend/src/pages/spaces/SpaceLayout.tsx index b420160c..a4cf56af 100644 --- a/app/frontend/src/pages/spaces/SpaceLayout.tsx +++ b/app/frontend/src/pages/spaces/SpaceLayout.tsx @@ -174,7 +174,7 @@ export function SpaceLayout() { ({ label, onSelect: () => navigate(href) })), - ...[...menu, ...actions.items(space)].map((item, index) => (index === 0 ? { ...item, separated: true } : item)), + ...[menu, actions.items(space)].flatMap((group) => group.map((item, index) => (index === 0 ? { ...item, separated: true } : item))), ]} name={space.name} /> diff --git a/app/frontend/src/pages/spaces/SpaceSettings.tsx b/app/frontend/src/pages/spaces/SpaceSettings.tsx index 5653d9ea..c417b4a6 100644 --- a/app/frontend/src/pages/spaces/SpaceSettings.tsx +++ b/app/frontend/src/pages/spaces/SpaceSettings.tsx @@ -1,11 +1,12 @@ -import { useState, type FormEvent, type ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import { Link } from 'react-router'; -import { ColorSwatches, ConfirmDialog, Icon, IconButton, ListRow, Placeholder, SectionHeading, SegmentedControl, Select, TagChip, TextAreaField, TextField } from '@project-graphite/ui'; +import { ColorSwatches, ConfirmDialog, Icon, ListRow, Placeholder, SectionHeading, SegmentedControl, Select, TagChip, TextAreaField, TextField } from '@project-graphite/ui'; +import { IconButton } from '../../components/IconButton'; import { useAuth } from '../../auth'; import { boardTemplates } from '../../components/board-templates'; -import { Button } from '../../components/Button'; import { IconPicker } from '../../components/IconPicker'; import { LoadError } from '../../components/LoadError'; +import { SavedForm } from '../../components/SavedForm'; import { ButtonSkeleton, FieldSkeleton, RowsSkeleton } from '../../components/Skeletons'; import { useTaskColumns } from '../../components/TaskColumns'; import { Toggle } from '../../components/Toggle'; @@ -47,8 +48,6 @@ const linkLengths = [ { value: 'never', label: 'Never expire' }, ]; -const same = (first: unknown, second: unknown) => JSON.stringify(first) === JSON.stringify(second); - function Panel({ children, description, id, title }: { children: ReactNode; description?: ReactNode; id: (typeof groups)[number][0]; title: string }) { return (
    @@ -60,41 +59,6 @@ function Panel({ children, description, id, title }: { children: ReactNode; desc ); } -function SavedForm({ children, initial, invalid = false, label, onSave, values }: { children: ReactNode; initial: T; invalid?: boolean; label: string; onSave: () => Promise; values: T }) { - const saving = useAction(); - const [saved, setSaved] = useState(false); - const dirty = !same(initial, values); - return ( -
    setSaved(false)} - onSubmit={(event: FormEvent) => { - event.preventDefault(); - if (!dirty || invalid) return; - void saving.run(async () => { - await onSave(); - setSaved(true); - return ''; - }, `Could not save ${label.toLowerCase()}`); - }} - > - {children} -
    -
    -
    - ); -} - function General({ current, save }: { current: SpaceSettingsData; save: (change: Change) => Promise }) { const initial = { name: current.name, color: current.color, description: current.description }; const [values, setValues] = useState(initial); diff --git a/app/frontend/src/pages/spaces/SpaceTasks.tsx b/app/frontend/src/pages/spaces/SpaceTasks.tsx index 7c9b76ba..88168b4c 100644 --- a/app/frontend/src/pages/spaces/SpaceTasks.tsx +++ b/app/frontend/src/pages/spaces/SpaceTasks.tsx @@ -1,6 +1,6 @@ import { TaskWorkspace } from '../../components/TaskWorkspace'; import { spaceHref } from '../../spaces'; -import { useSpace } from './SpaceLayout'; +import { useSpace, useSpaceMenu } from './SpaceLayout'; export function SpaceTasks() { const space = useSpace(); @@ -13,6 +13,7 @@ export function SpaceTasks() { editableTask={() => editable} linked listsPath={`/spaces/${space.id}/lists`} + menuInto={useSpaceMenu()} ready shape={`space-tasks-${space.id}`} spaceId={space.id} diff --git a/app/frontend/src/rich-document.ts b/app/frontend/src/rich-document.ts new file mode 100644 index 00000000..d305397d --- /dev/null +++ b/app/frontend/src/rich-document.ts @@ -0,0 +1,15 @@ +export type RichNode = { type?: string; text?: string; content?: RichNode[] }; + +const blank = (node: RichNode) => node.type === 'paragraph' && (node.content ?? []).every((child) => child.type === 'hardBreak' || (child.type === 'text' && !child.text?.trim())); + +export function withoutTrailingLines(document: RichNode): Record | null { + const content = [...(document.content ?? [])]; + while (content.length > 0 && blank(content.at(-1)!)) content.pop(); + const last = content.at(-1); + if (last?.type === 'paragraph' && last.content) { + const inline = [...last.content]; + while (inline.at(-1)?.type === 'hardBreak') inline.pop(); + content[content.length - 1] = { ...last, content: inline }; + } + return content.length > 0 ? { ...document, content } : null; +} diff --git a/app/frontend/src/styles/global.css b/app/frontend/src/styles/global.css index 3483863b..4b94f859 100644 --- a/app/frontend/src/styles/global.css +++ b/app/frontend/src/styles/global.css @@ -1637,9 +1637,21 @@ button.habit-day::before { .task-comment { display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + column-gap: var(--space-3); +} + +.task-comment-main { + display: grid; + min-width: 0; gap: var(--space-2); } +.comment-thread-replies { + margin-inline-start: calc(1.75rem + var(--space-3)); +} + .comment-head { display: flex; flex-wrap: wrap; @@ -4057,14 +4069,59 @@ main.shell { font-variant-numeric: tabular-nums; } +.profile-identity { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: var(--space-6); +} + +@media (max-width: 480px) { + .profile-identity { + grid-template-columns: minmax(0, 1fr); + justify-items: start; + } +} + .profile-picture { + position: relative; + border-radius: 9999px; +} + +.profile-picture .avatar.avatar-lg { + width: 7.5rem; + height: 7.5rem; + font-size: 2.75rem; +} + +.profile-picture-actions { + position: absolute; + inset: 0; display: flex; align-items: center; - gap: var(--space-2); + justify-content: center; + gap: var(--space-1); + border-radius: inherit; + background: color-mix(in srgb, #000 55%, transparent); + color: #fff; + opacity: 0; + transition: opacity 160ms ease; +} + +.profile-picture-actions .MuiIconButton-root { + color: inherit; } -.profile-picture > :first-child { - margin-inline-end: var(--space-2); +.profile-picture:hover .profile-picture-actions, +.profile-picture:focus-within .profile-picture-actions { + opacity: 1; +} + +@media (hover: none) { + .profile-picture-actions { + opacity: 1; + background: color-mix(in srgb, #000 35%, transparent); + } } .avatar-button-picture { @@ -4382,6 +4439,7 @@ main.shell { .reveal { display: grid; + grid-template-columns: minmax(0, 1fr); grid-template-rows: 0fr; visibility: hidden; } @@ -4529,13 +4587,26 @@ main.shell { gap: var(--space-5, 1.25rem); } -.settings-form-foot { +.save-bar { display: flex; - flex-wrap: wrap; align-items: center; + justify-content: flex-end; gap: var(--space-3); } +.save-bar-button { + transition: + opacity 160ms ease, + transform 160ms ease, + visibility 160ms; +} + +.save-bar:not([data-dirty]) .save-bar-button { + visibility: hidden; + opacity: 0; + transform: translateY(0.25rem); +} + .settings-grid { display: grid; gap: var(--space-4); diff --git a/app/frontend/src/task-draft.ts b/app/frontend/src/task-draft.ts index 9350a796..9d61ae5a 100644 --- a/app/frontend/src/task-draft.ts +++ b/app/frontend/src/task-draft.ts @@ -22,7 +22,7 @@ type TextNode = { type?: string; text?: string; content?: TextNode[] }; export const paragraphOf = (text: string) => ({ type: 'doc', content: text.split('\n').map((line) => ({ type: 'paragraph', ...(line ? { content: [{ type: 'text', text: line }] } : {}) })) }); -export const plainDescription = (text: string) => (text.trim() ? paragraphOf(text) : null); +export const plainDescription = (text: string) => (text.trim() ? paragraphOf(text.trimEnd()) : null); function textOf(node: TextNode): string { if (node.text) return node.text; diff --git a/app/frontend/src/time.tsx b/app/frontend/src/time.tsx index 61090dd0..a3bcea93 100644 --- a/app/frontend/src/time.tsx +++ b/app/frontend/src/time.tsx @@ -1,5 +1,6 @@ import { useRef, useState, type KeyboardEvent } from 'react'; -import { Dialog, formatDay, IconButton, PickerField, Button as UiButton } from '@project-graphite/ui'; +import { Dialog, formatDay, PickerField, Button as UiButton } from '@project-graphite/ui'; +import { IconButton } from './components/IconButton'; import { Button, TextButton } from './components/Button'; import { DialogActions } from './components/DialogActions'; diff --git a/app/server/src/auth/auth.controller.ts b/app/server/src/auth/auth.controller.ts index c05bdac3..3a31a914 100644 --- a/app/server/src/auth/auth.controller.ts +++ b/app/server/src/auth/auth.controller.ts @@ -17,7 +17,7 @@ import type { AuthenticatedUser } from './auth.types'; import { CurrentUser } from './current-user.decorator'; import { deviceHash, deviceLabel, newDeviceId, type Device } from './device'; import { ChangePasswordDto, CompleteSignInDto, EmailDto, ResetPasswordDto } from './dto/account.dto'; -import { GoogleConnectDto, GoogleFinishDto, GoogleStartDto } from './dto/google.dto'; +import { GoogleConnectDto, GoogleExistingDto, GoogleFinishDto, GoogleStartDto } from './dto/google.dto'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { VerifyEmailDto } from './dto/verify-email.dto'; @@ -182,19 +182,52 @@ export class AuthController { @RateLimit('google-finish', 30, 900) async finishGoogle(@Body() input: GoogleFinishDto, @Req() request: Request, @Res({ passthrough: true }) response: Response) { this.assertNotCrossSite(request); + const result = await this.withGoogleCookie(request, response, (sealed) => this.google.finish(sealed, input, this.device(request, response))); + if ('pending' in result) { + this.setGoogleCookie(response, result.pending); + return { choose: result.reason, email: result.email }; + } + return this.googleSession(response, result); + } + + @Public() + @Post('google/existing') + @RateLimit('login', 10, 900) + async linkGoogleToExisting(@Body() input: GoogleExistingDto, @Req() request: Request, @Res({ passthrough: true }) response: Response) { + this.assertNotCrossSite(request); + const sealed = request.cookies[googleCookie] as string | undefined; + const result = await this.google.linkExisting(sealed, { login: input.login ?? input.email!, password: input.password, code: input.code }, this.device(request, response)); + if ('twoStep' in result) return result; + response.clearCookie(googleCookie, { path: '/api/v1/auth' }); + return this.googleSession(response, result); + } + + @Public() + @Post('google/new') + @RateLimit('register', 5, 3_600) + async createFromGoogle(@Req() request: Request, @Res({ passthrough: true }) response: Response) { + this.assertNotCrossSite(request); + const result = await this.withGoogleCookie(request, response, (sealed) => this.google.createFromPending(sealed, this.device(request, response))); + return this.googleSession(response, result); + } + + private async withGoogleCookie(request: Request, response: Response, work: (sealed: string | undefined) => Promise) { try { - const result = await this.google.finish(request.cookies[googleCookie] as string | undefined, input, this.device(request, response)); - if ('challenge' in result) return { twoStep: true, challenge: result.challenge }; - if ('session' in result) { - this.setRefreshCookie(response, result.session.refreshToken); - return { accessToken: result.session.accessToken, user: result.session.user }; - } - return result; + return await work(request.cookies[googleCookie] as string | undefined); } finally { response.clearCookie(googleCookie, { path: '/api/v1/auth' }); } } + private googleSession(response: Response, result: T | { challenge: string } | { session: { accessToken: string; refreshToken: string; user: unknown } }) { + if ('challenge' in result) return { twoStep: true, challenge: result.challenge }; + if ('session' in result) { + this.setRefreshCookie(response, result.session.refreshToken); + return { accessToken: result.session.accessToken, user: result.session.user }; + } + return result; + } + private setGoogleCookie(response: Response, flow: string) { response.cookie(googleCookie, flow, { httpOnly: true, diff --git a/app/server/src/auth/auth.service.ts b/app/server/src/auth/auth.service.ts index 76a57ecf..88d39add 100644 --- a/app/server/src/auth/auth.service.ts +++ b/app/server/src/auth/auth.service.ts @@ -430,12 +430,14 @@ export class AuthService implements OnModuleInit { if (email === user.email) { throw new BadRequestException('This is already your email address'); } + const thread = ``; + const headers = { 'In-Reply-To': thread, References: thread }; const taken = await this.prisma.user.findUnique({ where: { email }, select: { id: true } }); - if (!taken && !(await this.sendVerification(this.prisma, user, TokenPurpose.CHANGE_EMAIL, email))) { + if (!taken && !(await this.sendVerification(this.prisma, user, TokenPurpose.CHANGE_EMAIL, email, headers))) { throw emailUnavailable(); } await this.audit.record(userId, 'email_change_requested'); - await this.mail.trySend({ to: user.email, ...emailChangeRequestedEmail(this.mail.link, { name: user.displayName }) }); + await this.mail.trySend({ to: user.email, headers, ...emailChangeRequestedEmail(this.mail.link, { name: user.displayName }) }); } async deleteAccount(userId: string, proof: Proof) { @@ -582,10 +584,11 @@ export class AuthService implements OnModuleInit { user: { id: string; displayName: string }, purpose: typeof TokenPurpose.VERIFY_EMAIL | typeof TokenPurpose.CHANGE_EMAIL, to: string, + headers?: Record, ) { const changing = purpose === TokenPurpose.CHANGE_EMAIL; const token = await this.createToken(client, user.id, purpose, changing ? to : null); - return this.mail.trySend({ to, ...verifyEmail(this.mail.link, { name: user.displayName, token, changing }) }); + return this.mail.trySend({ to, headers, ...verifyEmail(this.mail.link, { name: user.displayName, token, changing }) }); } private async createToken( diff --git a/app/server/src/auth/dto/google.dto.ts b/app/server/src/auth/dto/google.dto.ts index ee470566..3e5d63f7 100644 --- a/app/server/src/auth/dto/google.dto.ts +++ b/app/server/src/auth/dto/google.dto.ts @@ -1,4 +1,5 @@ import { IsIn, IsOptional, IsString, Length, Matches } from 'class-validator'; +import { LoginDto } from './login.dto'; export class GoogleStartDto { @IsOptional() @@ -25,3 +26,10 @@ export class GoogleFinishDto { @Length(1, 100) state!: string; } + +export class GoogleExistingDto extends LoginDto { + @IsOptional() + @IsString() + @Length(6, 20, { message: 'Enter the six-digit code or a recovery code.' }) + code?: string; +} diff --git a/app/server/src/google/google-auth.service.ts b/app/server/src/google/google-auth.service.ts index 5b2952d7..b049d413 100644 --- a/app/server/src/google/google-auth.service.ts +++ b/app/server/src/google/google-auth.service.ts @@ -21,6 +21,15 @@ interface Flow { expiresAt: number; } +interface Pending { + subject: string; + email: string; + name: string | null; + invite?: string; + reason: 'email' | 'browser'; + expiresAt: number; +} + const flowLifetimeMs = 10 * 60 * 1000; const expired = 'This Google sign-in expired. Start again.'; const purposeScopes: Record = { @@ -77,7 +86,7 @@ export class GoogleAuthService { } async finish(sealed: string | undefined, input: { code: string; state: string }, device: Device) { - const flow = sealed ? this.readFlow(sealed) : undefined; + const flow = sealed ? this.unseal(sealed) : undefined; if (!flow || flow.state !== input.state || flow.expiresAt < Date.now()) throw new UnauthorizedException(expired); const grant = await this.google.exchange(input.code, flow.verifier).catch((error: Error) => { this.logger.warn(`Google sign-in could not be confirmed: ${error.message}`); @@ -127,23 +136,60 @@ export class GoogleAuthService { return this.auth.signInWithGoogle(linked.userId, device); } if (!identity.emailVerified) throw new ForbiddenException('Google has not verified this email address, so it cannot be used to sign in.'); - if (await this.prisma.user.count({ where: { email: identity.email } })) { - throw new ConflictException('A Strata account already uses this email. Sign in with your password, then link Google in Settings → Security.'); + const taken = (await this.prisma.user.count({ where: { email: identity.email } })) > 0; + if (taken || (await this.prisma.knownDevice.count({ where: { deviceHash: device.hash } })) > 0) { + const reason = taken ? 'email' : 'browser'; + const pending: Pending = { subject: identity.subject, email: identity.email, name: identity.name, invite, reason, expiresAt: Date.now() + flowLifetimeMs }; + return { pending: this.box.seal(JSON.stringify(pending)), reason, email: identity.email }; + } + return this.create({ subject: identity.subject, email: identity.email, name: identity.name, invite }, device); + } + + async linkExisting(sealed: string | undefined, input: { login: string; password: string; code?: string }, device: Device) { + const pending = this.readPending(sealed); + const signedIn = await this.auth.login({ login: input.login, password: input.password }, device); + if (signedIn.challenge && !input.code) return { twoStep: true as const }; + const session = signedIn.challenge ? await this.auth.completeTwoStep(signedIn.challenge, input.code!, device) : signedIn.session!; + const current = await this.linkable(session.user.id, pending.subject); + if (!current) { + await this.prisma.googleAccount.create({ data: { userId: session.user.id, subject: pending.subject, email: pending.email } }); + await this.audit.record(session.user.id, 'google_linked', { email: pending.email }); } - const user = await this.auth.registerWithGoogle({ email: identity.email, displayName: identity.name ?? identity.email.split('@')[0]!, subject: identity.subject, invite }); + return { session }; + } + + async createFromPending(sealed: string | undefined, device: Device) { + const pending = this.readPending(sealed); + if (pending.reason === 'email') throw new ConflictException('A Strata account already uses this email. Sign in to it to link Google.'); + return this.create(pending, device); + } + + private async create(identity: { subject: string; email: string; name: string | null; invite?: string }, device: Device) { + const user = await this.auth.registerWithGoogle({ email: identity.email, displayName: identity.name ?? identity.email.split('@')[0]!, subject: identity.subject, invite: identity.invite }); await this.audit.record(user.id, 'google_linked', { email: identity.email }); return this.auth.signInWithGoogle(user.id, device); } + private async linkable(userId: string, subject: string) { + const owner = await this.prisma.googleAccount.findUnique({ where: { subject }, select: { userId: true } }); + if (owner && owner.userId !== userId) throw new ConflictException('This Google account is linked to another Strata account.'); + const current = await this.prisma.googleAccount.findUnique({ where: { userId }, select: { subject: true, email: true } }); + if (current && current.subject !== subject) throw new ConflictException(`This Strata account is linked to another Google account, ${current.email}.`); + return current; + } + + private readPending(sealed: string | undefined) { + const pending = sealed ? this.unseal(sealed) : undefined; + if (!pending?.subject || pending.expiresAt < Date.now()) throw new UnauthorizedException(expired); + return pending; + } + private async link(userId: string, purpose: Exclude, grant: GoogleGrant) { const { identity } = grant; if (purposeScopes[purpose].some((scope) => !grant.scopes.includes(scope))) { throw new ForbiddenException(purpose === 'calendar' ? 'Allow Strata to see and edit your Google calendars to sync them.' : 'Allow Strata to open the Drive files you pick to import them.'); } - const owner = await this.prisma.googleAccount.findUnique({ where: { subject: identity.subject }, select: { userId: true } }); - if (owner && owner.userId !== userId) throw new ConflictException('This Google account is linked to another Strata account.'); - const current = await this.prisma.googleAccount.findUnique({ where: { userId }, select: { subject: true, email: true } }); - if (current && current.subject !== identity.subject) throw new ConflictException(`Choose the Google account you linked, ${current.email}.`); + const current = await this.linkable(userId, identity.subject); const refreshTokenSealed = grant.refreshToken ? this.box.seal(grant.refreshToken) : undefined; await this.prisma.googleAccount.upsert({ where: { userId }, @@ -154,9 +200,9 @@ export class GoogleAuthService { if (!current) await this.audit.record(userId, 'google_linked', { email: identity.email }); } - private readFlow(sealed: string) { + private unseal(sealed: string) { try { - return JSON.parse(this.box.open(sealed)) as Flow; + return JSON.parse(this.box.open(sealed)) as T; } catch { return undefined; } diff --git a/app/server/src/mail/templates.ts b/app/server/src/mail/templates.ts index 9734696f..f10360e5 100644 --- a/app/server/src/mail/templates.ts +++ b/app/server/src/mail/templates.ts @@ -132,9 +132,11 @@ export const existingAccountEmail = (link: Link, { name }: { name: string }) => actions: [resetPassword(link)], }); +export const emailChangeSubject = 'Changing your Strata email'; + export const verifyEmail = (link: Link, { name, token, changing }: { name: string; token: string; changing: boolean }) => compose(link, { - subject: changing ? 'Confirm your new Strata email' : 'Verify your Strata email', + subject: changing ? emailChangeSubject : 'Verify your Strata email', preheader: 'The link works for 24 hours.', greeting: hi(name), prompt: changing ? 'Confirm this address to use it for your Strata account:' : 'Confirm this address to finish creating your Strata account:', @@ -164,11 +166,11 @@ export const passwordChangedEmail = (link: Link, { name }: { name: string }) => export const emailChangeRequestedEmail = (link: Link, { name }: { name: string }) => compose(link, { - subject: 'Your Strata email is being changed', + subject: emailChangeSubject, preheader: 'Nothing changes until the new address is confirmed.', greeting: hi(name), - lines: ['Someone asked to move your Strata account to another email address. Nothing changes until that address is confirmed.'], - prompt: 'If this was not you, reset your password now. That cancels the change:', + lines: ['Your Strata account is moving to a new email address. We sent a link there to confirm it, and nothing changes until it is confirmed.'], + prompt: 'If you did not ask for this, reset your password now. That cancels the change:', actions: [resetPassword(link)], }); diff --git a/app/server/src/tasks/task-attachments.service.ts b/app/server/src/tasks/task-attachments.service.ts index 3a467900..4317a83d 100644 --- a/app/server/src/tasks/task-attachments.service.ts +++ b/app/server/src/tasks/task-attachments.service.ts @@ -4,6 +4,7 @@ import { audienceOf, inHome } from '../access/access.service'; import { ChangesService } from '../changes/changes.service'; import { FilesService } from '../files/files.service'; import { PrismaService } from '../prisma/prisma.service'; +import { WatchersService } from '../watchers/watchers.service'; import { TasksService } from './tasks.service'; const attachmentFields = { @@ -23,6 +24,7 @@ export class TaskAttachmentsService { private readonly tasks: TasksService, private readonly files: FilesService, private readonly changes: ChangesService, + private readonly watchers: WatchersService, ) {} async list(userId: string, taskId: string) { @@ -46,6 +48,7 @@ export class TaskAttachmentsService { update: {}, select: attachmentFields, }); + await this.watchers.watch(this.prisma, taskId, [userId]); this.changes.notify('task-details', { ...audienceOf(task.home), itemId: taskId }); return present(link); } @@ -60,6 +63,7 @@ export class TaskAttachmentsService { const task = await this.tasks.assertTask(userId, taskId, 'edit'); const { count } = await this.prisma.itemLink.deleteMany({ where: { sourceItemId: taskId, targetItemId: fileId, kind: LinkKind.ATTACHMENT } }); if (count === 0) throw new NotFoundException('That file is not attached to this task'); + await this.watchers.watch(this.prisma, taskId, [userId]); this.changes.notify('task-details', { ...audienceOf(task.home), itemId: taskId }); } } diff --git a/app/server/src/tasks/task-comments.service.ts b/app/server/src/tasks/task-comments.service.ts index 14fe9da2..503d7110 100644 --- a/app/server/src/tasks/task-comments.service.ts +++ b/app/server/src/tasks/task-comments.service.ts @@ -99,8 +99,10 @@ export class TaskCommentsService { if (!commentReactions.includes(emoji)) throw new BadRequestException('Choose one of the offered reactions.'); const task = await this.tasks.assertTask(userId, taskId, 'read'); if (!(await this.prisma.noteComment.count({ where: { id: commentId, itemId: taskId } }))) throw new NotFoundException('Comment not found'); - if (reacting) await this.prisma.commentReaction.createMany({ data: [{ commentId, userId, emoji }], skipDuplicates: true }); - else await this.prisma.commentReaction.deleteMany({ where: { commentId, userId, emoji } }); + if (reacting) { + await this.prisma.commentReaction.createMany({ data: [{ commentId, userId, emoji }], skipDuplicates: true }); + await this.watchers.watch(this.prisma, taskId, [userId]); + } else await this.prisma.commentReaction.deleteMany({ where: { commentId, userId, emoji } }); this.changes.notify('task-details', { ...audienceOf(task.home), itemId: taskId }); return present(await this.prisma.noteComment.findUniqueOrThrow({ where: { id: commentId }, select: commentFields }), userId); } diff --git a/app/server/src/watchers/watchers.controller.ts b/app/server/src/watchers/watchers.controller.ts index 0679cfd9..584d5754 100644 --- a/app/server/src/watchers/watchers.controller.ts +++ b/app/server/src/watchers/watchers.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Delete, Get, Param, Put } from '@nestjs/common'; +import { Controller, Delete, Get, Param } from '@nestjs/common'; import { Scope } from '../access-tokens/scopes'; import { AuthenticatedUser } from '../auth/auth.types'; import { CurrentUser } from '../auth/current-user.decorator'; @@ -15,27 +15,9 @@ export class WatchersController { return this.watchers.status(user.id, id); } - @Put('watch') - @Scope('items:read') - watch(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string) { - return this.watchers.setWatching(user.id, id, true); - } - @Delete('watch') @Scope('items:read') unwatch(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string) { - return this.watchers.setWatching(user.id, id, false); - } - - @Put('watchers/:userId') - @Scope('items:write') - add(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Param('userId', UuidPipe) watcherId: string) { - return this.watchers.setOtherWatching(user.id, id, watcherId, true); - } - - @Delete('watchers/:userId') - @Scope('items:write') - remove(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Param('userId', UuidPipe) watcherId: string) { - return this.watchers.setOtherWatching(user.id, id, watcherId, false); + return this.watchers.stopWatching(user.id, id); } -} \ No newline at end of file +} diff --git a/app/server/src/watchers/watchers.service.ts b/app/server/src/watchers/watchers.service.ts index d48b62d1..b7b34faa 100644 --- a/app/server/src/watchers/watchers.service.ts +++ b/app/server/src/watchers/watchers.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ItemKind, type Prisma } from '@prisma/client'; import { InboxService } from '../inbox/inbox.service'; import { PageAccessService } from '../notes/page-access.service'; @@ -60,24 +60,9 @@ export class WatchersService { return { watching: watchers.some((watcher) => watcher.user.id === userId), watchers: watchers.map((watcher) => watcher.user) }; } - async setWatching(userId: string, itemId: string, watching: boolean) { + async stopWatching(userId: string, itemId: string) { await this.pages.assertReference(userId, itemId, 'read'); - if (watching) await this.watch(this.prisma, itemId, [userId]); - else await this.prisma.itemWatcher.deleteMany({ where: { itemId, userId } }); - return this.status(userId, itemId); - } - - async setOtherWatching(userId: string, itemId: string, watcherId: string, watching: boolean) { - await this.pages.assertReference(userId, itemId, 'edit'); - if (watching) { - await this.pages.assertReference(watcherId, itemId, 'read').catch((error: unknown) => { - if (error instanceof NotFoundException) throw new BadRequestException('Choose someone who can see this'); - throw error; - }); - await this.watch(this.prisma, itemId, [watcherId]); - } else { - await this.prisma.itemWatcher.deleteMany({ where: { itemId, userId: watcherId } }); - } + await this.prisma.itemWatcher.deleteMany({ where: { itemId, userId } }); return this.status(userId, itemId); } } \ No newline at end of file diff --git a/app/server/test/account.spec.ts b/app/server/test/account.spec.ts index 034106c5..1fe98f62 100644 --- a/app/server/test/account.spec.ts +++ b/app/server/test/account.spec.ts @@ -451,6 +451,18 @@ describe('Account email flows', () => { expect(warning.text).not.toMatch(/token=/); }); + it('threads the confirmation and the warning as one conversation', async () => { + const { mail, service } = setup(); + + await service.requestEmailChange(user.id, 'new@example.com', { password: 'correct horse battery' }); + const sent = mail.send.mock.calls.map(([message]) => message as { to: string; subject: string; headers?: Record }); + const [confirmation, warning] = ['new@example.com', user.email].map((to) => sent.find((message) => message.to === to)!); + expect(confirmation.subject).toBe(warning.subject); + expect(confirmation.headers?.References).toMatch(/^$/); + expect(warning.headers).toEqual(confirmation.headers); + expect(confirmation.headers?.['In-Reply-To']).toBe(confirmation.headers?.References); + }); + it('refuses an email change when the confirmation cannot be sent', async () => { const { mail, service } = setup(); const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); diff --git a/app/server/test/google-auth.spec.ts b/app/server/test/google-auth.spec.ts index b1533276..16c4723e 100644 --- a/app/server/test/google-auth.spec.ts +++ b/app/server/test/google-auth.spec.ts @@ -10,7 +10,8 @@ const box = new SecretBox(new ConfigService({ DATA_ENCRYPTION_KEY: Buffer.alloc( function setup() { const prisma = { - googleAccount: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), upsert: vi.fn(), delete: vi.fn() }, + googleAccount: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), upsert: vi.fn(), delete: vi.fn(), create: vi.fn() }, + knownDevice: { count: vi.fn().mockResolvedValue(0) }, googleCalendar: { deleteMany: vi.fn() }, item: { updateMany: vi.fn() }, user: { count: vi.fn().mockResolvedValue(0), findUniqueOrThrow: vi.fn() }, @@ -24,6 +25,8 @@ function setup() { revoke: vi.fn(), }; const auth = { + login: vi.fn(), + completeTwoStep: vi.fn(), signInWithGoogle: vi.fn().mockResolvedValue({ session: { accessToken: 'access', refreshToken: 'refresh', user: {} } }), registerWithGoogle: vi.fn().mockResolvedValue({ id: 'new-user', email: 'sam@example.com' }), }; @@ -79,14 +82,50 @@ describe('GoogleAuthService', () => { expect(context.auth.registerWithGoogle).not.toHaveBeenCalled(); }); - it('never links a Google account to an existing Strata account by email alone', async () => { + it('never links a Google account to an existing Strata account by email alone, and asks for its password instead', async () => { const { flow, state, nonce } = started(context, 'sign-in'); context.google.exchange.mockResolvedValue(grant({ nonce })); context.prisma.user.count.mockResolvedValue(1); - await expect(context.service.finish(flow, { code: 'code', state }, device)).rejects.toBeInstanceOf(ConflictException); + const result = await context.service.finish(flow, { code: 'code', state }, device); + expect(result).toMatchObject({ reason: 'email', email: 'sam@example.com', pending: expect.any(String) }); expect(context.auth.registerWithGoogle).not.toHaveBeenCalled(); expect(context.auth.signInWithGoogle).not.toHaveBeenCalled(); + await expect(context.service.createFromPending((result as { pending: string }).pending, device)).rejects.toBeInstanceOf(ConflictException); + }); + + it('asks before creating an account on a browser that already signed in to Strata, and creates it when told to', async () => { + const { flow, state, nonce } = started(context, 'sign-in'); + context.google.exchange.mockResolvedValue(grant({ nonce, email: 'renamed@example.com' })); + context.prisma.knownDevice.count.mockResolvedValue(1); + + const result = (await context.service.finish(flow, { code: 'code', state }, device)) as { pending: string; reason: string }; + expect(result.reason).toBe('browser'); + expect(context.auth.registerWithGoogle).not.toHaveBeenCalled(); + expect(context.prisma.knownDevice.count).toHaveBeenCalledWith({ where: { deviceHash: device.hash } }); + + await context.service.createFromPending(result.pending, device); + expect(context.auth.registerWithGoogle).toHaveBeenCalledWith(expect.objectContaining({ email: 'renamed@example.com', subject: 'google-sub' })); + expect(context.auth.signInWithGoogle).toHaveBeenCalledWith('new-user', device); + }); + + it('links the waiting Google account to the account whose password is entered, asking for the two-step code when needed', async () => { + const { flow, state, nonce } = started(context, 'sign-in'); + context.google.exchange.mockResolvedValue(grant({ nonce, email: 'renamed@example.com' })); + context.prisma.knownDevice.count.mockResolvedValue(1); + const { pending } = (await context.service.finish(flow, { code: 'code', state }, device)) as { pending: string }; + const session = { accessToken: 'access', refreshToken: 'refresh', user: { id: 'user-1' } }; + context.auth.login.mockResolvedValue({ challenge: 'challenge' }); + context.auth.completeTwoStep.mockResolvedValue(session); + + await expect(context.service.linkExisting(pending, { login: 'sam', password: 'correct horse battery' }, device)).resolves.toEqual({ twoStep: true }); + expect(context.prisma.googleAccount.create).not.toHaveBeenCalled(); + + await expect(context.service.linkExisting(pending, { login: 'sam', password: 'correct horse battery', code: '123456' }, device)).resolves.toEqual({ session }); + expect(context.auth.completeTwoStep).toHaveBeenCalledWith('challenge', '123456', device); + expect(context.prisma.googleAccount.create).toHaveBeenCalledWith({ data: { userId: 'user-1', subject: 'google-sub', email: 'renamed@example.com' } }); + expect(context.audit.record).toHaveBeenCalledWith('user-1', 'google_linked', { email: 'renamed@example.com' }); + await expect(context.service.linkExisting(flow, { login: 'sam', password: 'x' }, device)).rejects.toBeInstanceOf(UnauthorizedException); }); it('creates an account for a new verified Google address, redeeming the invite it came with', async () => { diff --git a/app/server/test/integration/tasks.spec.ts b/app/server/test/integration/tasks.spec.ts index 87eb18c0..829bf306 100644 --- a/app/server/test/integration/tasks.spec.ts +++ b/app/server/test/integration/tasks.spec.ts @@ -232,24 +232,29 @@ describe('Tasks against Postgres', () => { expect((await owner.call('DELETE', `/items/${task.id}/watch`)).body.watching).toBe(false); await partner.call('POST', `/tasks/${task.id}/complete`); expect(await watching(owner)).not.toEqual(expect.arrayContaining([expect.stringContaining('finished')])); - expect((await outsider.call('PUT', `/items/${task.id}/watch`)).status).toBe(404); + expect((await outsider.call('DELETE', `/items/${task.id}/watch`)).status).toBe(404); }); - it('lets editors add and remove other watchers who can see the task', async () => { + it('starts watching only by taking part, and nobody can change who else is watching', async () => { const owner = await member('watch-lead'); const editor = await member('watch-editor'); const viewer = await member('watch-viewer'); - const outsider = await member('watch-stranger'); const team = (await owner.call('POST', '/spaces', { name: 'Watchlist' })).body; await strata.join(team.id, editor, SpaceRole.EDITOR); await strata.join(team.id, viewer, SpaceRole.VIEWER); const task = (await owner.call('POST', `/spaces/${team.id}/tasks`, { title: 'Review copy' })).body; - const ids = (reply: { body: { watchers: { id: string }[] } }) => reply.body.watchers.map((watcher) => watcher.id).sort(); - - expect(ids(await editor.call('PUT', `/items/${task.id}/watchers/${viewer.id}`))).toEqual([owner.id, viewer.id].sort()); - expect((await editor.call('PUT', `/items/${task.id}/watchers/${outsider.id}`)).status).toBe(400); - expect((await viewer.call('PUT', `/items/${task.id}/watchers/${editor.id}`)).status).toBe(403); - expect(ids(await editor.call('DELETE', `/items/${task.id}/watchers/${viewer.id}`))).toEqual([owner.id]); + const ids = async () => (await viewer.call('GET', `/items/${task.id}/watchers`)).body.watchers.map((watcher: { id: string }) => watcher.id).sort(); + + expect((await editor.call('PUT', `/items/${task.id}/watchers/${viewer.id}`)).status).toBe(404); + expect((await owner.call('DELETE', `/items/${task.id}/watchers/${owner.id}`)).status).toBe(404); + expect((await viewer.call('PUT', `/items/${task.id}/watch`)).status).toBe(404); + expect(await ids()).toEqual([owner.id]); + + const comment = (await owner.call('POST', `/tasks/${task.id}/comments`, { body: 'Thoughts?' })).body; + await viewer.call('PUT', `/tasks/${task.id}/comments/${comment.id}/reactions/${encodeURIComponent('👍')}`); + expect(await ids()).toEqual([owner.id, viewer.id].sort()); + expect((await viewer.call('DELETE', `/items/${task.id}/watch`)).body.watching).toBe(false); + expect(await ids()).toEqual([owner.id]); }); it('shares a task with people in its space, who start watching it, and with anyone by a view-only link', async () => { diff --git a/app/server/test/rich-document.spec.ts b/app/server/test/rich-document.spec.ts new file mode 100644 index 00000000..ec6cc127 --- /dev/null +++ b/app/server/test/rich-document.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { withoutTrailingLines } from '../../frontend/src/rich-document'; +import { plainDescription } from '../../frontend/src/task-draft'; + +const text = (value: string) => ({ type: 'text', text: value }); +const paragraph = (...content: object[]) => ({ type: 'paragraph', ...(content.length ? { content } : {}) }); + +describe('withoutTrailingLines', () => { + it('drops empty lines and line breaks after the last words, keeping the ones in between', () => { + const document = { + type: 'doc', + content: [paragraph(text('First')), paragraph(), paragraph(text('Second'), { type: 'hardBreak' }, { type: 'hardBreak' }), paragraph(), paragraph(text(' '), { type: 'hardBreak' })], + }; + + expect(withoutTrailingLines(document)).toEqual({ type: 'doc', content: [paragraph(text('First')), paragraph(), paragraph(text('Second'))] }); + }); + + it('leaves a document with nothing in it empty', () => { + expect(withoutTrailingLines({ type: 'doc', content: [paragraph(), paragraph({ type: 'hardBreak' })] })).toBeNull(); + }); + + it('keeps a list or other block that ends the document', () => { + const list = { type: 'bulletList', content: [{ type: 'listItem', content: [paragraph(text('One'))] }] }; + expect(withoutTrailingLines({ type: 'doc', content: [list, paragraph()] })).toEqual({ type: 'doc', content: [list] }); + }); +}); + +describe('plainDescription', () => { + it('leaves no empty paragraphs after the last line', () => { + expect(plainDescription('Buy milk\n\nand bread\n\n')).toEqual({ type: 'doc', content: [paragraph(text('Buy milk')), paragraph(), paragraph(text('and bread'))] }); + }); +});