chore: import upstream snapshot with attribution
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
## Background & Context
|
||||
|
||||
The MemoDetail page (`web/src/pages/MemoDetail.tsx`) renders a memo's full content with a right sidebar (`MemoDetailSidebar`) on desktop. The sidebar currently shows share controls, timestamps, property badges (links, to-do, code), and tags. Memo content is rendered via `react-markdown` with custom heading components (h1–h6) in `MemoContent/markdown/Heading.tsx`. The page already has hash-based scroll-to-element infrastructure for comments (lines 39–43 of MemoDetail.tsx).
|
||||
|
||||
## Issue Statement
|
||||
|
||||
When viewing a memo with structured headings, there is no outline or table of contents in the MemoDetail sidebar, so readers cannot see the document structure at a glance or jump to specific sections. Heading elements rendered by the `Heading` component do not have `id` attributes, preventing any anchor-based navigation to them.
|
||||
|
||||
## Current State
|
||||
|
||||
- **`web/src/pages/MemoDetail.tsx`** (93 lines) — MemoDetail page. Hash-based `scrollIntoView` logic exists at lines 39–43 but only targets comment elements. The sidebar is rendered at lines 82–86 as `<MemoDetailSidebar>`.
|
||||
- **`web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx`** (109 lines) — Desktop sidebar. Contains sections: Share (lines 58–65), Created-at (lines 67–76), Properties (lines 78–89), Tags (lines 91–102). Uses a reusable `SidebarSection` helper (lines 24–32). No outline section exists.
|
||||
- **`web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx`** (36 lines) — Mobile drawer wrapper for the sidebar.
|
||||
- **`web/src/components/MemoContent/markdown/Heading.tsx`** (30 lines) — Renders h1–h6 with Tailwind classes. Does not generate `id` attributes on heading elements.
|
||||
- **`web/src/components/MemoContent/index.tsx`** (157 lines) — ReactMarkdown renderer. Maps h1–h6 to the `Heading` component (lines 72–101). No rehype-slug or equivalent plugin is installed.
|
||||
- **`web/src/utils/markdown-manipulation.ts`** (143 lines) — Contains `fromMarkdown()` MDAST parsing with GFM extensions. Used for task extraction; no heading extraction exists.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redesigning the overall MemoDetail layout or sidebar structure beyond adding the outline section.
|
||||
- Supporting outline for the mobile drawer — the outline section should appear in the desktop sidebar only for now.
|
||||
- Adding active heading highlighting on scroll (scroll-spy behavior).
|
||||
- Supporting outline in the memo list/timeline view (compact mode).
|
||||
- Changing the existing heading visual styles in the content area.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which heading levels to include in the outline? (default: h1–h4, matching user request)
|
||||
- How to generate slug IDs — install `rehype-slug` or custom implementation? (default: lightweight custom slug generation in the Heading component to avoid a new dependency)
|
||||
- Should the outline section be hidden when no headings exist? (default: yes, hide entirely like Properties/Tags sections)
|
||||
- Should duplicate heading text produce unique slugs (e.g. `my-heading`, `my-heading-1`)? (default: yes, append numeric suffix for duplicates)
|
||||
|
||||
## Scope
|
||||
|
||||
**M** — Touches 3–4 files (Heading component, MemoDetailSidebar, possibly a new outline component, and a slug utility). Existing patterns (SidebarSection, hash scrolling) apply directly. No new subsystem or novel approach required.
|
||||
@@ -0,0 +1,47 @@
|
||||
## Task List
|
||||
|
||||
T1: Add heading extraction utility [S] — T2: Add slug IDs to Heading component [S] — T3: Create MemoOutline sidebar component [M] — T4: Integrate outline into MemoDetailSidebar [S]
|
||||
|
||||
### T1: Add heading extraction utility [S]
|
||||
|
||||
**Objective**: Provide a function to extract h1–h4 headings from markdown content with slugified IDs, reusing the existing MDAST parsing pattern from `markdown-manipulation.ts`.
|
||||
**Files**: `web/src/utils/markdown-manipulation.ts`
|
||||
**Implementation**: Add `HeadingItem` interface (text, level, slug) and `extractHeadings(markdown: string): HeadingItem[]` function. Use existing `fromMarkdown()` + `visit()` pattern. Visit `"heading"` nodes with depth 1–4, extract text from children, generate slug via `slugify()` helper (lowercase, replace non-alphanumeric with hyphens, deduplicate). Export both.
|
||||
**Validation**: `cd web && pnpm lint` — no new errors
|
||||
|
||||
### T2: Add slug IDs to Heading component [S]
|
||||
|
||||
**Objective**: Generate deterministic `id` attributes on h1–h6 elements so outline links can scroll to them via `#hash`.
|
||||
**Files**: `web/src/components/MemoContent/markdown/Heading.tsx`
|
||||
**Implementation**: In `Heading` (~line 13), extract text from `children` using a `getTextContent(children)` helper that recursively extracts string content from React children. Generate slug with the same `slugify` logic. Apply `id={slug}` to the rendered `<Component>`.
|
||||
**Validation**: `cd web && pnpm lint` — no new errors
|
||||
|
||||
### T3: Create MemoOutline sidebar component [M]
|
||||
|
||||
**Objective**: Create a modern, Claude/Linear-style outline component that renders h1–h4 headings as anchor links with indentation by level.
|
||||
**Size**: M (new component file, modern styling)
|
||||
**Files**:
|
||||
- Create: `web/src/components/MemoDetailSidebar/MemoOutline.tsx`
|
||||
**Implementation**:
|
||||
1. Props: `{ headings: HeadingItem[] }` from `markdown-manipulation.ts`
|
||||
2. Render a `<nav>` with vertical list of `<a href="#slug">` links
|
||||
3. Styling per level: h1 no indent, h2 `pl-3`, h3 `pl-6`, h4 `pl-9`. Text size: h1 `text-[13px] font-medium`, h2–h4 `text-[13px] font-normal`. Color: `text-muted-foreground` with `hover:text-foreground` transition. Left border accent line (2px) along the nav. Smooth scroll on click via `scrollIntoView`.
|
||||
4. Each link: `block py-1 truncate transition-colors` with level-based indentation
|
||||
**Boundaries**: No scroll-spy / active state tracking. No mobile drawer integration.
|
||||
**Dependencies**: T1
|
||||
**Expected Outcome**: Component renders a clean, modern outline navigation.
|
||||
**Validation**: `cd web && pnpm lint` — no new errors
|
||||
|
||||
### T4: Integrate outline into MemoDetailSidebar [S]
|
||||
|
||||
**Objective**: Add the outline section as the first section in `MemoDetailSidebar`, shown only when headings exist.
|
||||
**Files**: `web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx`
|
||||
**Implementation**: Import `extractHeadings` and `MemoOutline`. In `MemoDetailSidebar` (~line 48), compute `headings = useMemo(() => extractHeadings(memo.content), [memo.content])`. Before the Share section (~line 58), add conditional: `{headings.length > 0 && <SidebarSection label="Outline"><MemoOutline headings={headings} /></SidebarSection>}`.
|
||||
**Validation**: `cd web && pnpm lint && pnpm build` — no errors
|
||||
|
||||
## Out-of-Scope Tasks
|
||||
|
||||
- Scroll-spy / active heading highlighting in the outline
|
||||
- Mobile drawer outline support
|
||||
- Outline in memo list view (compact mode)
|
||||
- Changing existing heading visual styles in content area
|
||||
@@ -0,0 +1,98 @@
|
||||
## Background & Context
|
||||
|
||||
Memos has a content-blur feature: when a memo's tag list contains the literal string `NSFW`
|
||||
(case-insensitive), the memo body is rendered with a `blur-lg` CSS class and a click-to-reveal
|
||||
overlay is shown. This was simplified in v0.26.x from a previous admin-configurable system
|
||||
(which had an on/off toggle and a custom-tag-list) down to a single hardcoded tag name.
|
||||
|
||||
In the same release cycle, an `InstanceTagsSetting` system was introduced that lets admins attach
|
||||
metadata (currently only `background_color`) to tag name patterns via a regex-keyed map. This
|
||||
system has its own proto definitions, store layer, API service handlers, frontend context, utility
|
||||
library, and settings UI — all independent of the blur feature.
|
||||
|
||||
A sponsor raised (orgs/usememos/discussions/5708) that the hardcoded tag name is inconvenient:
|
||||
users who organised their content under a different tag (e.g. a non-English word, a project-
|
||||
specific label, or simply a term they prefer) must re-tag all existing memos just to use the blur
|
||||
feature. Community comments echo the same concern and additionally ask for the ability to disable
|
||||
the blur globally.
|
||||
|
||||
## Issue Statement
|
||||
|
||||
The memo content-blur trigger is evaluated exclusively against the hardcoded string `"NSFW"`
|
||||
(case-insensitive) in `MemoView.tsx`, with no connection to the `InstanceTagsSetting` system,
|
||||
making it impossible for an administrator to designate any other tag name — or set of tag name
|
||||
patterns — as a blur trigger, and preventing users from re-using existing tag taxonomies to
|
||||
activate content blurring.
|
||||
|
||||
## Current State
|
||||
|
||||
**Blur detection — frontend**
|
||||
|
||||
| File | Lines | Behaviour |
|
||||
|------|-------|-----------|
|
||||
| `web/src/components/MemoView/MemoView.tsx` | 27–30 | `const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;` — single hardcoded string comparison |
|
||||
| `web/src/components/MemoView/MemoViewContext.tsx` | 16–19 | Context shape exposes `nsfw: boolean`, `showNSFWContent: boolean`, `toggleNsfwVisibility` |
|
||||
| `web/src/components/MemoView/components/MemoBody.tsx` | 11–23, 37, 53 | Applies `blur-lg transition-all duration-200` when `nsfw && !showNSFWContent`; renders `NsfwOverlay` button using i18n key `memo.click-to-show-nsfw-content` |
|
||||
| `web/src/components/MemoPreview/MemoPreview.tsx` | 24–27 | Stub context value: `nsfw: false`, `showNSFWContent: false` — blur never active in preview |
|
||||
|
||||
**Localisation strings that contain the "NSFW" term**
|
||||
|
||||
| File | Keys |
|
||||
|------|------|
|
||||
| `web/src/locales/en.json` (and ~30 other locale files) | `memo.click-to-hide-nsfw-content`, `memo.click-to-show-nsfw-content`, `settings.enable-blur-nsfw-content` |
|
||||
|
||||
Note: most non-English translations already use "sensitive content" rather than "NSFW" in these
|
||||
keys; English is the outlier.
|
||||
|
||||
**Tag metadata system — proto**
|
||||
|
||||
| File | Lines | Content |
|
||||
|------|-------|---------|
|
||||
| `proto/api/v1/instance_service.proto` | 168–181 | `message TagMetadata { google.type.Color background_color = 1; }` nested inside `InstanceSetting`; `TagsSetting` is a `map<string, TagMetadata>` |
|
||||
| `proto/store/instance_setting.proto` | 113–124 | `message InstanceTagMetadata { google.type.Color background_color = 1; }` inside `InstanceTagsSetting` |
|
||||
|
||||
**Tag metadata system — backend**
|
||||
|
||||
| File | Lines | Content |
|
||||
|------|-------|---------|
|
||||
| `store/instance_setting.go` | 166–192 | `GetInstanceTagsSetting()` retrieves and caches the tags map |
|
||||
| `server/router/api/v1/instance_service.go` | 300–328 | `convertInstanceTagsSettingFromStore()` / `convertInstanceTagsSettingToStore()` convert between store and API representations, field-by-field |
|
||||
| `server/router/api/v1/instance_service.go` | 387–409 | `validateInstanceTagsSetting()` validates each key as a regex pattern and the color value |
|
||||
|
||||
**Tag metadata system — frontend**
|
||||
|
||||
| File | Lines | Content |
|
||||
|------|-------|---------|
|
||||
| `web/src/lib/tag.ts` | 28–43 | `findTagMetadata(tag, tagsSetting)` — exact-match then regex-match lookup returning `TagMetadata \| undefined` |
|
||||
| `web/src/components/MemoContent/Tag.tsx` | 23–38 | Calls `findTagMetadata()` to apply `background_color` to inline tag chips |
|
||||
| `web/src/components/Settings/TagsSection.tsx` | 36–206 | Admin settings UI for managing the tag→metadata map; currently shows only a colour picker per tag |
|
||||
| `web/src/contexts/InstanceContext.tsx` | 83–99 | `tagsSetting` selector and fetch during app initialisation |
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redesigning or replacing the `InstanceTagsSetting` proto or store structure beyond adding one field.
|
||||
- Providing a per-user (as opposed to per-instance) blur preference.
|
||||
- Changing how background-color metadata is stored, validated, or rendered.
|
||||
- Adding a global on/off toggle for blurring (separate from per-tag configuration).
|
||||
- Modifying the blur visual effect (CSS class, animation, overlay button layout).
|
||||
- Migrating or auto-converting any existing memos that were tagged with `NSFW`.
|
||||
- Changing non-English locale strings that already use neutral terminology.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should the `blur_content` field in tag metadata be configurable per-tag only by admins, or also by individual users via user-level tag settings? (default: admin-only, matching the existing `InstanceTagsSetting` access model)
|
||||
|
||||
2. When a memo has multiple tags and more than one of them has `blur_content = true`, should the blur activate if _any_ matching tag has the flag set, or only if _all_ matching tags do? (default: any — OR semantics, consistent with the current single-tag check)
|
||||
|
||||
3. Should there be a migration that automatically sets `blur_content = true` for any existing `InstanceTagsSetting` entry whose key is `"NSFW"` (case-insensitive)? (default: no automatic migration; admins reconfigure manually)
|
||||
|
||||
4. What should the English-locale i18n key strings say, given that "NSFW" is to be avoided? (default: "Click to show sensitive content" / "Click to hide sensitive content")
|
||||
|
||||
## Scope
|
||||
|
||||
**M** — the change adds one `bool` field to two existing proto messages, threads it through two
|
||||
existing conversion functions in the backend, replaces one hardcoded string comparison in
|
||||
`MemoView.tsx` with a call to the already-present `findTagMetadata()` utility, adds a checkbox to
|
||||
the existing `TagsSection.tsx` settings UI, and renames three i18n keys. All required patterns
|
||||
(field addition, conversion, `findTagMetadata` lookup, settings UI checkbox) already exist in the
|
||||
codebase.
|
||||
@@ -0,0 +1,63 @@
|
||||
## Execution Log
|
||||
|
||||
### T1: Add blur_content field to proto messages
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `proto/api/v1/instance_service.proto`, `proto/store/instance_setting.proto`
|
||||
**Validation**: `buf lint` — PASS
|
||||
**Path Corrections**: None
|
||||
**Deviations**: None
|
||||
|
||||
### T2: Regenerate proto code
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `proto/gen/` (Go + OpenAPI), `web/src/types/proto/` (TypeScript)
|
||||
**Validation**: `grep blur_content` in generated files — PASS (field present in Go, TS, OpenAPI)
|
||||
**Path Corrections**: None
|
||||
**Deviations**: None
|
||||
|
||||
### T3: Thread blur_content through backend conversions
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `server/router/api/v1/instance_service.go`
|
||||
**Validation**: `go build ./...` — PASS
|
||||
**Path Corrections**: None
|
||||
**Deviations**: None
|
||||
|
||||
### T4: Replace hardcoded NSFW check with tag metadata lookup
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `web/src/components/MemoView/MemoView.tsx`, `web/src/components/MemoView/MemoViewContext.tsx`, `web/src/components/MemoView/components/MemoBody.tsx`, `web/src/components/MemoPreview/MemoPreview.tsx`
|
||||
**Validation**: `pnpm lint` — PASS
|
||||
**Path Corrections**: i18n key update (T6) was pulled forward to unblock TypeScript type checking, since the i18n key type is statically checked.
|
||||
**Deviations**: None
|
||||
|
||||
### T5: Add blur checkbox to TagsSection settings UI
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `web/src/components/Settings/TagsSection.tsx`
|
||||
**Validation**: `pnpm lint` — PASS
|
||||
**Path Corrections**: None
|
||||
**Deviations**: None
|
||||
|
||||
### T6: Update English i18n keys
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `web/src/locales/en.json`
|
||||
**Validation**: `grep -c "nsfw\|NSFW" en.json` — returns 0, PASS
|
||||
**Path Corrections**: Executed during T4/T5 to unblock type checking. Added `setting.tags.blur-content` key (not in original plan but required by T5's new checkbox column).
|
||||
**Deviations**: None
|
||||
|
||||
## Completion Declaration
|
||||
|
||||
**All tasks completed successfully.**
|
||||
|
||||
Summary of changes:
|
||||
- Added `bool blur_content = 2` to both API and store proto TagMetadata messages
|
||||
- Regenerated Go, TypeScript, and OpenAPI code
|
||||
- Threaded `blur_content` through `convertInstanceTagsSettingFromStore()` and `convertInstanceTagsSettingToStore()`
|
||||
- Replaced hardcoded `tag.toUpperCase() === "NSFW"` with `findTagMetadata(tag, tagsSetting)?.blurContent` lookup
|
||||
- Renamed context fields: `nsfw` → `blurred`, `showNSFWContent` → `showBlurredContent`, `toggleNsfwVisibility` → `toggleBlurVisibility`
|
||||
- Renamed `NsfwOverlay` → `BlurOverlay` component
|
||||
- Expanded TagsSection local state to track `{ color, blur }` per tag and added a "Blur content" checkbox column
|
||||
- Updated English i18n: renamed NSFW keys to "sensitive content", removed unused key, added `blur-content` setting key
|
||||
@@ -0,0 +1,89 @@
|
||||
## Task List
|
||||
|
||||
T1: Add blur_content field to proto messages [S] — T2: Regenerate proto code [S] — T3: Thread blur_content through backend conversions [S] — T4: Replace hardcoded NSFW check with tag metadata lookup [M] — T5: Add blur checkbox to TagsSection settings UI [S] — T6: Update English i18n keys [S]
|
||||
|
||||
### T1: Add blur_content field to proto messages [S]
|
||||
|
||||
**Objective**: Add a `bool blur_content` field to both the API and store proto TagMetadata messages.
|
||||
**Files**: `proto/api/v1/instance_service.proto`, `proto/store/instance_setting.proto`
|
||||
**Implementation**:
|
||||
- In `proto/api/v1/instance_service.proto` (~line 171), add `bool blur_content = 2;` to `message TagMetadata` after `background_color`
|
||||
- In `proto/store/instance_setting.proto` (~line 115), add `bool blur_content = 2;` to `message InstanceTagMetadata` after `background_color`
|
||||
**Validation**: `cd proto && buf lint` — no errors
|
||||
|
||||
### T2: Regenerate proto code [S]
|
||||
|
||||
**Objective**: Regenerate Go + TypeScript + OpenAPI from updated proto definitions.
|
||||
**Files**: `proto/gen/` (generated), `web/src/types/proto/` (generated)
|
||||
**Implementation**: Run `cd proto && buf generate`
|
||||
**Dependencies**: T1
|
||||
**Validation**: `grep -r "blur_content\|blurContent" proto/gen/ web/src/types/proto/ | head -10` — shows new field in generated Go and TS files
|
||||
|
||||
### T3: Thread blur_content through backend conversion functions [S]
|
||||
|
||||
**Objective**: Pass `blur_content` through the store↔API conversion functions so the field round-trips correctly.
|
||||
**Files**: `server/router/api/v1/instance_service.go`
|
||||
**Implementation**:
|
||||
- In `convertInstanceTagsSettingFromStore()` (~line 306): add `BlurContent: metadata.GetBlurContent()` to the `InstanceSetting_TagMetadata` struct literal
|
||||
- In `convertInstanceTagsSettingToStore()` (~line 321): add `BlurContent: metadata.GetBlurContent()` to the `InstanceTagMetadata` struct literal
|
||||
**Dependencies**: T2
|
||||
**Validation**: `cd /Users/steven/Projects/usememos/memos && go build ./...` — compiles without errors
|
||||
|
||||
### T4: Replace hardcoded NSFW check with tag metadata lookup [M]
|
||||
|
||||
**Objective**: Replace the hardcoded `tag.toUpperCase() === "NSFW"` check with a lookup against `InstanceTagsSetting` via the existing `findTagMetadata()` utility, so any tag with `blur_content: true` triggers the blur.
|
||||
**Size**: M (3 files, moderate logic)
|
||||
**Files**:
|
||||
- Modify: `web/src/components/MemoView/MemoView.tsx`
|
||||
- Modify: `web/src/components/MemoView/MemoViewContext.tsx`
|
||||
- Modify: `web/src/components/MemoView/components/MemoBody.tsx`
|
||||
- Modify: `web/src/components/MemoPreview/MemoPreview.tsx`
|
||||
**Implementation**:
|
||||
1. In `MemoView.tsx`:
|
||||
- Import `useInstance` from `@/contexts/InstanceContext` and `findTagMetadata` from `@/lib/tag`
|
||||
- Replace `const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;` with a check that iterates `memoData.tags` and uses `findTagMetadata(tag, tagsSetting)?.blurContent` — OR semantics (any match triggers blur)
|
||||
- Rename state/variables: `showNSFWContent` → `showBlurredContent`, `nsfw` → `blurred`, `toggleNsfwVisibility` → `toggleBlurVisibility`
|
||||
2. In `MemoViewContext.tsx`:
|
||||
- Rename interface fields: `nsfw` → `blurred`, `showNSFWContent` → `showBlurredContent`, `toggleNsfwVisibility` → `toggleBlurVisibility`
|
||||
3. In `MemoBody.tsx`:
|
||||
- Update destructured context fields to use new names (`blurred`, `showBlurredContent`, `toggleBlurVisibility`)
|
||||
- Rename `NsfwOverlay` component to `BlurOverlay`
|
||||
- Change i18n key from `memo.click-to-show-nsfw-content` to `memo.click-to-show-sensitive-content`
|
||||
4. In `MemoPreview.tsx`:
|
||||
- Update stub context to use new field names (`blurred`, `showBlurredContent`, `toggleBlurVisibility`)
|
||||
**Boundaries**: Do NOT change blur CSS classes, animation, or overlay layout
|
||||
**Dependencies**: T2
|
||||
**Validation**: `cd web && pnpm lint` — no type or lint errors
|
||||
|
||||
### T5: Add blur checkbox to TagsSection settings UI [S]
|
||||
|
||||
**Objective**: Add a "Blur content" checkbox column to the tag settings table so admins can toggle `blur_content` per tag pattern.
|
||||
**Files**: `web/src/components/Settings/TagsSection.tsx`
|
||||
**Implementation**:
|
||||
- Expand `localTags` state from `Record<string, string>` (hex only) to `Record<string, { color: string; blur: boolean }>` to track both fields
|
||||
- Update `useEffect` sync, `originalHexMap` comparison, `handleColorChange`, `handleRemoveTag`, `handleAddTag` to work with the new shape
|
||||
- Add a new `[newTagBlur, setNewTagBlur]` state for the add-tag row (default `false`)
|
||||
- In `handleSave`, pass `blurContent` when creating `InstanceSetting_TagMetadata`
|
||||
- Add a new column to `SettingTable` between "Background color" and "Actions": header `t("setting.tags.blur-content")`, renders a checkbox bound to `localTags[row.name].blur`
|
||||
- Add the i18n key `setting.tags.blur-content` to `en.json` with value `"Blur content"`
|
||||
**Dependencies**: T2
|
||||
**Validation**: `cd web && pnpm lint` — no type or lint errors
|
||||
|
||||
### T6: Update English i18n keys [S]
|
||||
|
||||
**Objective**: Rename NSFW-specific i18n keys in `en.json` to use neutral "sensitive content" terminology.
|
||||
**Files**: `web/src/locales/en.json`
|
||||
**Implementation**:
|
||||
- Change key `memo.click-to-show-nsfw-content` → `memo.click-to-show-sensitive-content` with value `"Click to show sensitive content"`
|
||||
- Change key `memo.click-to-hide-nsfw-content` → `memo.click-to-hide-sensitive-content` with value `"Click to hide sensitive content"` (dead key but renamed for consistency)
|
||||
- The key `settings.enable-blur-nsfw-content` is unused in code — remove it
|
||||
**Dependencies**: T4 (key rename must match code references)
|
||||
**Validation**: `grep -c "nsfw" web/src/locales/en.json` — returns `0`
|
||||
|
||||
## Out-of-Scope Tasks
|
||||
|
||||
- Updating non-English locale files (per non-goals: "Changing non-English locale strings that already use neutral terminology")
|
||||
- Adding automatic migration for existing NSFW tag entries
|
||||
- Per-user blur preferences
|
||||
- Global on/off toggle for blurring
|
||||
- Modifying blur visual effect (CSS, animation, overlay layout)
|
||||
@@ -0,0 +1,41 @@
|
||||
## Background & Context
|
||||
|
||||
User resources in Memos v1 are exposed through Connect/gRPC-Gateway handlers in `server/router/api/v1`, proto resource definitions in `proto/api/v1`, frontend profile flows in `web/src`, and MCP JSON helpers in `server/router/mcp`. The store schema already persists both an internal integer `id` and a unique `username` for each user. The GitHub issue reports that public user resource names such as `users/2` are still emitted across responses and nested user-scoped resources. Existing code already mixes identifier forms: `GetUser` accepts either `users/{id}` or `users/{username}`, the fileserver avatar route accepts either identifier, and the frontend profile page already enters the API through `users/{username}` before reusing the returned `user.name`.
|
||||
|
||||
## Issue Statement
|
||||
|
||||
Across the v1 API surface, canonical user resource names are currently constructed from `store.User.ID` rather than `store.User.Username`, and many handlers parse those emitted names back into integers for authorization and lookup. As a result, top-level user resources and nested user-scoped references in settings, stats, shortcuts, webhooks, notifications, memo creators, reactions, and MCP payloads expose sequential database IDs and couple downstream callers to integer-based user tokens in server-emitted names.
|
||||
|
||||
## Current State
|
||||
|
||||
- `store/user.go:26-42` defines `store.User` with both `ID int32` and `Username string`; `store/migration/sqlite/LATEST.sql:10-21` declares `username TEXT NOT NULL UNIQUE`.
|
||||
- `server/router/api/v1/user_service.go:72-102` handles `GetUser` by extracting `users/{id_or_username}` and resolving either a numeric ID or a username; `server/router/api/v1/user_service.go:914-937` still serializes `User.name` as `users/{id}` and derives avatar URLs from that name.
|
||||
- `server/router/api/v1/resource_name.go:67-89` has two different parsing paths: `ExtractUserIDFromName` only accepts numeric user tokens, while `extractUserIdentifierFromName` accepts either token and is currently only used by `GetUser`.
|
||||
- `server/router/api/v1/user_service.go:335-369`, `server/router/api/v1/user_service.go:372-460`, `server/router/api/v1/user_service.go:463-517`, `server/router/api/v1/user_service.go:536-676`, `server/router/api/v1/user_service.go:679-911`, and `server/router/api/v1/user_service.go:1400-1488` parse numeric user segments for settings, personal access tokens, webhooks, and notifications, and emit names such as `users/%d/settings/...`, `users/%d/webhooks/...`, and `users/%d/notifications/%d`.
|
||||
- `server/router/api/v1/shortcut_service.go:20-43` parses `users/{user}/shortcuts/{shortcut}` by converting the `user` segment to `int32`, and constructs shortcut names as `users/%d/shortcuts/%s`.
|
||||
- `server/router/api/v1/user_service_stats.go:63-65`, `server/router/api/v1/user_service_stats.go:113`, `server/router/api/v1/user_service_stats.go:132-145`, `server/router/api/v1/user_service_stats.go:214-223` emit `users/%d/stats` and `users/%d/memos/%d`, and resolve stats requests through numeric `ExtractUserIDFromName`.
|
||||
- `server/router/api/v1/memo_service_converter.go:26-37` serializes `Memo.creator` as `users/{id}`; `server/router/api/v1/reaction_service.go:154-164` serializes `Reaction.creator` as `users/{id}`; `server/router/api/v1/memo_service.go:636-643` and `server/router/api/v1/memo_service.go:815-845` parse `memo.Creator` through the numeric helper for inbox and webhook flows.
|
||||
- `server/router/mcp/tools_memo.go:75-86`, `server/router/mcp/tools_attachment.go:29-37`, and `server/router/mcp/tools_reaction.go:64-71` plus `server/router/mcp/tools_reaction.go:133-138` serialize creator fields as `users/{id}` in MCP tool output.
|
||||
- `server/router/fileserver/fileserver.go:153-181` and `server/router/fileserver/fileserver.go:533-539` currently resolve avatar requests by either numeric ID or username.
|
||||
- `proto/api/v1/user_service.proto:22-29` and `proto/api/v1/user_service.proto:247-256` document `GetUser` accepting both `users/{id}` and `users/{username}`. The same proto file defines the `User` resource at `proto/api/v1/user_service.proto:161-178` and nested user resource formats at `proto/api/v1/user_service.proto:307-317` and `proto/api/v1/user_service.proto:361-373`; example text still uses numeric user tokens such as `users/123/settings/GENERAL`.
|
||||
- `web/src/pages/UserProfile.tsx:74-86` requests `users/{username}` from the route param, and `web/src/layouts/MainLayout.tsx:37-48` stores the returned canonical `user.name` for later stats requests.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing internal `user.id` primary keys, foreign keys, or existing store schemas.
|
||||
- Introducing a new opaque UUID-based public user identifier.
|
||||
- Changing user discovery, public profile visibility, or authorization rules beyond how user resource names are parsed and emitted.
|
||||
- Adding username history, redirect, or alias preservation for old usernames after a rename.
|
||||
- Redesigning unrelated resource naming schemes such as memo, attachment, share, or identity-provider identifiers.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which public surfaces are in scope for username-based canonical output? (default: all server-emitted v1 API and MCP payload fields that currently contain `users/{...}` resource names)
|
||||
- Should legacy numeric inputs continue to resolve on user-scoped endpoints beyond `GetUser`? (default: no, accept only username-based user resource names)
|
||||
- If a username changes, must previously emitted `users/{old-username}` names continue to resolve? (default: no additional alias or redirect layer; only the current username remains valid)
|
||||
- Should notification, webhook, shortcut, and personal-access-token child identifiers keep their existing child token formats while only the parent user token changes? (default: yes)
|
||||
- Does the issue include avatar URLs and other derived file paths that are built from `User.name`? (default: yes, because avatar URLs are emitted from the same canonical user name field)
|
||||
|
||||
## Scope
|
||||
|
||||
**L** — Current behavior spans `server/router/api/v1`, `server/router/mcp`, `server/router/fileserver`, `proto/api/v1`, frontend consumers in `web/src`, and the request parsers that turn user resource names back into internal IDs. Changing both emitted and accepted user resource names across those surfaces is a broad API contract change rather than a single local edit.
|
||||
@@ -0,0 +1,63 @@
|
||||
## References
|
||||
|
||||
- [AIP-122: Resource names](https://google.aip.dev/122)
|
||||
- [AIP-123: Resource types](https://google.aip.dev/123)
|
||||
- [AIP-148: Standard fields](https://google.aip.dev/148)
|
||||
- [AIP-180: Backwards compatibility](https://google.aip.dev/180)
|
||||
- [Insecure Direct Object Reference Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html)
|
||||
- [REST API endpoints for users - GitHub Docs](https://docs.github.com/en/enterprise-server%403.19/rest/users/users)
|
||||
- [Users API - GitLab Docs](https://docs.gitlab.com/api/users/)
|
||||
- [API Usage - Gitea Documentation](https://docs.gitea.com/next/development/api-usage)
|
||||
|
||||
## Industry Baseline
|
||||
|
||||
`AIP-122: Resource names` and `AIP-148: Standard fields` treat `name` as the canonical identifier that clients store and reuse, and expect request `name` and `parent` fields to accept the same resource-name vocabulary across a service. `AIP-122` also allows aliases for lookup, but requires responses to emit the canonical resource name.
|
||||
|
||||
`REST API endpoints for users - GitHub Docs` and `API Usage - Gitea Documentation` use username-based public user paths and nested user-scoped routes, while keeping numeric or system-assigned identifiers as separate data or alternate endpoints when a durable internal identifier is required.
|
||||
|
||||
`Users API - GitLab Docs` shows a mixed-input compatibility pattern on some endpoints with `id_or_username`, which keeps older callers working while allowing username-oriented public routes.
|
||||
|
||||
`Insecure Direct Object Reference Prevention Cheat Sheet` treats enumerable numeric identifiers as a defense-in-depth concern, but not a substitute for authorization. Replacing `users/{id}` with `users/{username}` changes discoverability characteristics, but permission checks still have to enforce access from internal user IDs.
|
||||
|
||||
`AIP-180: Backwards compatibility` treats changes to resource-name format and server-generated field construction as breaking. Any design that changes emitted `User.name` values inside `v1` has to preserve as much request compatibility as possible and document the remaining response-format risk explicitly.
|
||||
|
||||
## Research Summary
|
||||
|
||||
Memos already has most of the prerequisites for username-based canonical names. The schema stores a unique username, `GetUser` already resolves either ID or username, the fileserver avatar route already uses an `identifier` abstraction, and the frontend profile page already starts from `users/{username}`. No database migration is required to identify users by username at the API boundary.
|
||||
|
||||
The current coupling problem is concentrated in two places. First, response builders serialize `users/{id}` in many modules, including memo conversion, stats, settings, shortcuts, notifications, webhooks, and MCP JSON helpers. Second, many request handlers assume they can parse a numeric ID back out of those names for authorization and storage lookups.
|
||||
|
||||
Research points to a common pattern of canonical public resource names plus server-side resolution to internal IDs. In Memos, switching the canonical token from numeric ID to username can reuse the existing unique username column and existing username lookups, but `AIP-123: Resource types` and `AIP-180: Backwards compatibility` still make clear that changing accepted and emitted resource-name formats inside `v1` is a breaking API contract change. That makes this design a deliberate contract replacement rather than a compatibility layer.
|
||||
|
||||
## Design Goals
|
||||
|
||||
- All server-emitted v1 and MCP response fields that serialize user resource names under `users/{...}` use the current username token instead of the numeric database ID.
|
||||
- User-scoped request fields that reference `users/{...}` accept username-based resource names only.
|
||||
- Authorization, ownership checks, inbox/webhook dispatch, and other internal workflows continue to operate on `store.User.ID` after resolving the public resource name.
|
||||
- List and batch endpoints avoid introducing per-item user lookups when serializing username-based names.
|
||||
- No database schema, foreign-key, or storage-key redesign is required.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing internal `user.id` primary keys, foreign keys, or existing store schemas.
|
||||
- Introducing a new opaque UUID-based public user identifier.
|
||||
- Changing user discovery, public profile visibility, or authorization rules beyond how user resource names are parsed and emitted.
|
||||
- Adding username history, redirect, or alias preservation for old usernames after a rename.
|
||||
- Redesigning unrelated resource naming schemes such as memo, attachment, share, or identity-provider identifiers.
|
||||
- Adding a new API version as part of this issue.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
Introduce a single canonical user-name builder in the v1 API layer that serializes `users/{username}` from resolved user data, and route every public user-name emitter through it. This includes `convertUserFromStore`, memo and reaction creator fields, user stats, settings, shortcuts, webhooks, notifications, personal-access-token names, webhook payloads, avatar URLs derived from `User.name`, and the MCP JSON helpers. This satisfies the first design goal and aligns the public resource shape with `AIP-122: Resource names`.
|
||||
|
||||
Introduce a shared user-token resolver in `server/router/api/v1` that extracts the `users/{token}` segment, validates it as a username-form resource token, resolves the corresponding `store.User`, and then passes the resolved internal ID into permission checks and storage lookups. This replaces numeric-only parsing in helpers such as `ExtractUserIDFromName`, `ExtractUserIDAndSettingKeyFromName`, shortcut and webhook parsers, personal-access-token deletion, and notification parsing. The fileserver's current `getUserByIdentifier` behavior shows both lookup styles exist today, but the API-layer contract for this issue becomes username-only rather than dual-mode.
|
||||
|
||||
Keep child resource tokens unchanged and only change the user segment. For names such as `users/{user}/settings/{setting}`, `users/{user}/webhooks/{webhook}`, `users/{user}/notifications/{notification}`, `users/{user}/shortcuts/{shortcut}`, and `users/{user}/personalAccessTokens/{token}`, the parent `user` token is resolved from the username, while the child token keeps its existing format and storage mapping. This is narrower than redesigning child identifiers and keeps the issue bounded to the user-resource segment.
|
||||
|
||||
Use response-side user resolution strategies that match endpoint shape. Single-resource handlers can resolve one user directly and serialize the username immediately. List and batch handlers such as memo conversion, stats aggregation, notifications, and MCP list output should collect distinct user IDs first and resolve usernames once per response, reusing the store's existing user lookup path and cache where available. This keeps username-based output from turning into hidden N+1 query behavior and satisfies the performance goal without changing persistence.
|
||||
|
||||
Replace the public user-resource contract rather than extending it. Server-emitted `name`, `parent`, `creator`, and `sender` fields become username-based canonical output, and handlers that currently accept `users/{id}` are updated to require `users/{username}`. `AIP-180: Backwards compatibility` indicates that changing both the construction and accepted format of an existing resource name is a breaking change for clients that persist, compare, or generate old `name` values. The design therefore requires updated proto comments, API examples, handler tests, and release notes to make the new canonical form and the removed numeric form explicit.
|
||||
|
||||
Do not add a username alias table in this issue. If a username changes, newly serialized resource names use the current username, and previously emitted username-based names stop resolving unless they match the current username. This keeps the scope aligned with existing `UpdateUser` behavior and avoids introducing a new subsystem for historical username resolution. The alternative of adding permanent old-username aliases was rejected because it expands the problem from canonical serialization into identity-history management.
|
||||
|
||||
Do not solve this by adding a second public identifier field and leaving `User.name` numeric. `AIP-122: Resource names` treats `name` as the canonical resource identifier, and the GitHub issue is specifically about the public names currently emitted under `users/{id}`. Adding a second field would preserve the exposed sequential identifier in the canonical slot and fail the primary design goal. Likewise, introducing a new opaque UUID-based public identifier was rejected because the repository already has a unique username field and the issue is scoped to replacing numeric user resource names with that existing identifier.
|
||||
@@ -0,0 +1,62 @@
|
||||
## Execution Log
|
||||
|
||||
### T1: Add username-only user resource helpers
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- `server/router/api/v1/user_resource_name.go`
|
||||
- `server/router/api/v1/resource_name.go`
|
||||
- `server/router/api/v1/user_service.go`
|
||||
- `server/router/api/v1/test/user_resource_name_test.go`
|
||||
**Validation**: `go test -v ./server/router/api/v1/test -run 'TestUserResourceName'` — PASS
|
||||
**Path Corrections**: Tightened username-token validation so numeric-only `users/1` fails at the resource-name layer instead of falling through to `NotFound`.
|
||||
**Deviations**: None
|
||||
|
||||
### T2: Migrate user-scoped API handlers
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- `server/router/api/v1/user_service.go`
|
||||
- `server/router/api/v1/shortcut_service.go`
|
||||
- `server/router/api/v1/user_service_stats.go`
|
||||
- `server/router/api/v1/test/shortcut_service_test.go`
|
||||
- `server/router/api/v1/test/user_service_stats_test.go`
|
||||
- `server/router/api/v1/test/user_notification_test.go`
|
||||
- `server/router/api/v1/test/user_service_registration_test.go`
|
||||
**Validation**: `go test -v ./server/router/api/v1/test -run 'Test(ListShortcuts|GetShortcut|CreateShortcut|UpdateShortcut|DeleteShortcut|ShortcutFiltering|ShortcutCRUDComplete|GetUserStats_TagCount|ListUserNotifications|UserRegistration)'` — PASS
|
||||
**Path Corrections**: Updated test fixtures to use valid username-form resource names (`users/testuser`, `users/test-user`) and corrected one stale registration-name expectation during the later broader suite rerun.
|
||||
**Deviations**: None
|
||||
|
||||
### T3: Migrate memo, reaction, MCP, and avatar user references
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- `server/router/api/v1/memo_service_converter.go`
|
||||
- `server/router/api/v1/memo_service.go`
|
||||
- `server/router/api/v1/reaction_service.go`
|
||||
- `server/router/mcp/tools_memo.go`
|
||||
- `server/router/mcp/tools_attachment.go`
|
||||
- `server/router/mcp/tools_reaction.go`
|
||||
- `server/router/fileserver/fileserver.go`
|
||||
- `server/router/api/v1/test/memo_service_test.go`
|
||||
- `server/router/api/v1/test/reaction_service_test.go`
|
||||
**Validation**: `go test ./server/router/api/v1/... ./server/router/mcp/... ./server/router/fileserver/...` — PASS
|
||||
**Path Corrections**: Removed an unused fileserver import after the first package build failed; kept MCP tool helper signatures stable for undeclared callers and switched tool call sites to username-aware wrappers.
|
||||
**Deviations**: None
|
||||
|
||||
### T4: Update contract docs and regression tests
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- `proto/api/v1/user_service.proto`
|
||||
- `proto/api/v1/shortcut_service.proto`
|
||||
- `web/src/layouts/MainLayout.tsx`
|
||||
- `web/src/components/MemoExplorer/ShortcutsSection.tsx`
|
||||
- `server/router/fileserver/README.md`
|
||||
**Validation**: `go test -v ./server/router/api/v1/test/...` — PASS
|
||||
**Path Corrections**: None
|
||||
**Deviations**: None
|
||||
|
||||
## Completion Declaration
|
||||
|
||||
All tasks completed successfully
|
||||
@@ -0,0 +1,106 @@
|
||||
## Task List
|
||||
|
||||
Task Index
|
||||
T1: Add username-only user resource helpers [L] — T2: Migrate user-scoped API handlers [L] — T3: Migrate memo, reaction, MCP, and avatar user references [L] — T4: Update contract docs and regression tests [L]
|
||||
|
||||
### T1: Add username-only user resource helpers [L]
|
||||
|
||||
**Objective**: Establish one v1 API mechanism for serializing `users/{username}` and resolving username-based user resource names back to internal user records, including root `GetUser` handling.
|
||||
**Size**: L (multiple files, shared identifier logic used across handlers)
|
||||
**Files**:
|
||||
- Create: `server/router/api/v1/user_resource_name.go`
|
||||
- Modify: `server/router/api/v1/resource_name.go`
|
||||
- Modify: `server/router/api/v1/user_service.go`
|
||||
- Test: `server/router/api/v1/test/user_resource_name_test.go`
|
||||
**Implementation**:
|
||||
1. In `server/router/api/v1/user_resource_name.go`: add the shared helper surface for canonical user-name construction, extracting the `users/{token}` segment, validating the username-form token, and resolving the corresponding `store.User`.
|
||||
2. In `server/router/api/v1/resource_name.go`: replace `ExtractUserIDFromName()`’s numeric-only behavior with username-oriented resolution helpers or thin wrappers that delegate to the new shared module.
|
||||
3. In `server/router/api/v1/user_service.go`: update `GetUser()` (~lines 72-102) and `convertUserFromStore()` (~lines 914-937) to use username-only resource names and reject legacy numeric `users/{id}` requests.
|
||||
4. In `server/router/api/v1/test/user_resource_name_test.go`: add direct coverage for `GetUser users/{username}` success, canonical `User.name == users/{username}`, and rejection of `users/{id}`.
|
||||
**Boundaries**: Do not migrate nested user-scoped handlers, memo/reaction emitters, MCP output, or fileserver behavior in this task.
|
||||
**Dependencies**: None
|
||||
**Expected Outcome**: Shared username-only helper logic exists, root user resources serialize as `users/{username}`, and root numeric user-name requests fail.
|
||||
**Validation**: `go test -v ./server/router/api/v1/test -run 'TestUserResourceName'` — expected output includes `PASS` and `ok`
|
||||
|
||||
### T2: Migrate user-scoped API handlers [L]
|
||||
|
||||
**Objective**: Convert user-scoped v1 handlers and nested resource emitters to require `users/{username}` while continuing to authorize and store by resolved internal user ID.
|
||||
**Size**: L (multiple handlers in one large service plus shortcut and stats code)
|
||||
**Files**:
|
||||
- Modify: `server/router/api/v1/user_service.go`
|
||||
- Modify: `server/router/api/v1/shortcut_service.go`
|
||||
- Modify: `server/router/api/v1/user_service_stats.go`
|
||||
- Test: `server/router/api/v1/test/shortcut_service_test.go`
|
||||
- Test: `server/router/api/v1/test/user_service_stats_test.go`
|
||||
- Test: `server/router/api/v1/test/user_notification_test.go`
|
||||
- Test: `server/router/api/v1/test/user_service_registration_test.go`
|
||||
**Implementation**:
|
||||
1. In `server/router/api/v1/user_service.go`: update settings, PAT, webhook, and notification parsing/emission paths (~lines 335-911 and ~1400-1488) to resolve `users/{username}` and emit username-based parent/child resource names.
|
||||
2. In `server/router/api/v1/shortcut_service.go`: update shortcut name parsing and construction (~lines 20-43) plus handler entry points to use username parents and nested names.
|
||||
3. In `server/router/api/v1/user_service_stats.go`: update stats request parsing and `UserStats.name` / `PinnedMemos` serialization (~lines 63-65, 113, 132-145, 214-223) to use usernames.
|
||||
4. In the listed tests: replace numeric user-name inputs with username-based parents, assert username-based emitted names, and add numeric-request rejection coverage for representative user-scoped endpoints.
|
||||
**Boundaries**: Do not change memo/reaction creator fields, MCP JSON output, or fileserver avatar routing in this task.
|
||||
**Dependencies**: T1
|
||||
**Expected Outcome**: User settings, notifications, shortcuts, stats, PATs, and webhooks all accept only `users/{username}` and emit only username-based user resource names.
|
||||
**Validation**: `go test -v ./server/router/api/v1/test -run 'Test(ListShortcuts|GetShortcut|CreateShortcut|UpdateShortcut|DeleteShortcut|ShortcutFiltering|ShortcutCRUDComplete|GetUserStats_TagCount|ListUserNotifications|UserRegistration)'` — expected output includes `PASS` and `ok`
|
||||
|
||||
### T3: Migrate memo, reaction, MCP, and avatar user references [L]
|
||||
|
||||
**Objective**: Remove numeric user resource names from memo/reaction-related API responses, dependent webhook/inbox flows, MCP JSON output, and avatar URLs/routing.
|
||||
**Size**: L (cross-package serialization and lookup changes, including response-side user resolution)
|
||||
**Files**:
|
||||
- Modify: `server/router/api/v1/memo_service_converter.go`
|
||||
- Modify: `server/router/api/v1/memo_service.go`
|
||||
- Modify: `server/router/api/v1/reaction_service.go`
|
||||
- Modify: `server/router/mcp/tools_memo.go`
|
||||
- Modify: `server/router/mcp/tools_attachment.go`
|
||||
- Modify: `server/router/mcp/tools_reaction.go`
|
||||
- Modify: `server/router/fileserver/fileserver.go`
|
||||
- Test: `server/router/api/v1/test/memo_service_test.go`
|
||||
- Test: `server/router/api/v1/test/reaction_service_test.go`
|
||||
**Implementation**:
|
||||
1. In `server/router/api/v1/memo_service_converter.go`: update `convertMemoFromStore()` (~lines 16-73) to serialize `Memo.creator` from resolved usernames rather than numeric IDs, using response-side batching or shared lookup helpers so list responses do not regress into hidden per-item lookups.
|
||||
2. In `server/router/api/v1/reaction_service.go`: update `convertReactionFromStore()` (~lines 154-164) to emit username-based creators.
|
||||
3. In `server/router/api/v1/memo_service.go`: update memo comment, webhook dispatch, and webhook payload helpers (~lines 636-643 and 815-845) to resolve username-based memo creators before using internal IDs.
|
||||
4. In `server/router/mcp/tools_memo.go`, `server/router/mcp/tools_attachment.go`, and `server/router/mcp/tools_reaction.go`: replace `users/%d` creator serialization with username-based values.
|
||||
5. In `server/router/fileserver/fileserver.go`: change avatar lookup to accept username identifiers only and ensure avatar URLs derived from `User.name` continue to resolve under `users/{username}`.
|
||||
6. In the listed tests: update creator assertions to `users/{username}` and add representative rejection coverage where numeric user names previously flowed through memo/reaction-related paths.
|
||||
**Boundaries**: Do not update proto comments, README examples, or frontend comments in this task.
|
||||
**Dependencies**: T1
|
||||
**Expected Outcome**: Memo/reaction creators, webhook payload creators, MCP creator fields, and avatar-derived user paths no longer expose numeric user IDs.
|
||||
**Validation**: `go test ./server/router/api/v1/... ./server/router/mcp/... ./server/router/fileserver/...` — expected output includes `ok` for all touched packages
|
||||
|
||||
### T4: Update contract docs and regression tests [L]
|
||||
|
||||
**Objective**: Align public contract comments/examples and the final regression suite with the username-only user resource-name contract.
|
||||
**Size**: L (multiple contract/documentation files plus end-to-end regression coverage)
|
||||
**Files**:
|
||||
- Modify: `proto/api/v1/user_service.proto`
|
||||
- Modify: `proto/api/v1/shortcut_service.proto`
|
||||
- Modify: `web/src/layouts/MainLayout.tsx`
|
||||
- Modify: `web/src/components/MemoExplorer/ShortcutsSection.tsx`
|
||||
- Modify: `server/router/fileserver/README.md`
|
||||
- Modify: `server/router/api/v1/test/user_resource_name_test.go`
|
||||
- Modify: `server/router/api/v1/test/shortcut_service_test.go`
|
||||
- Modify: `server/router/api/v1/test/user_service_stats_test.go`
|
||||
- Modify: `server/router/api/v1/test/user_notification_test.go`
|
||||
- Modify: `server/router/api/v1/test/memo_service_test.go`
|
||||
- Modify: `server/router/api/v1/test/reaction_service_test.go`
|
||||
- Modify: `server/router/api/v1/test/user_service_registration_test.go`
|
||||
**Implementation**:
|
||||
1. In `proto/api/v1/user_service.proto` and `proto/api/v1/shortcut_service.proto`: rewrite resource-name comments and examples so they document username-only user resource names and remove `users/{id}` examples.
|
||||
2. In `web/src/layouts/MainLayout.tsx` and `web/src/components/MemoExplorer/ShortcutsSection.tsx`: update inline comments/examples that still describe numeric user resource names.
|
||||
3. In `server/router/fileserver/README.md`: replace numeric avatar examples with username-based examples.
|
||||
4. In the listed test files: finish any remaining request/response assertions so the suite consistently encodes the username-only contract and explicitly rejects numeric user resource names where that contract is externally visible.
|
||||
**Boundaries**: Do not add schema migrations, generated proto output refreshes, or username-history behavior.
|
||||
**Dependencies**: T2, T3
|
||||
**Expected Outcome**: Source comments, examples, and regression tests all describe and enforce a username-only `users/{username}` public contract.
|
||||
**Validation**: `go test -v ./server/router/api/v1/test/...` — expected output includes `PASS` and `ok`
|
||||
|
||||
## Out-of-Scope Tasks
|
||||
|
||||
- Database schema or migration changes for the `user` table or foreign keys.
|
||||
- Username history, alias, redirect, or backward-compatibility layers.
|
||||
- A new opaque public user identifier or a new API version.
|
||||
- Opportunistic refactors outside the files listed above.
|
||||
- Generated code refreshes (`buf generate`) unless a later approved plan revision explicitly requires schema changes.
|
||||
@@ -0,0 +1,47 @@
|
||||
## Background & Context
|
||||
|
||||
Memos is a self-hosted note-taking product whose main write path is the React memo composer in `web/src/components/MemoEditor`. Memo content is stored as Markdown text, attachments are uploaded through the v1 attachment API, and the server already has dedicated file-serving behavior for media playback. The most recent relevant change in this area was commit `63a17d89`, which refactored audio attachment rendering into reusable playback components. That change improved how audio files are displayed after upload; it did not add a microphone-driven input path inside the compose flow.
|
||||
|
||||
## Issue Statement
|
||||
|
||||
Memo creation currently starts from typed text plus file upload and metadata pickers, while audio support in the product begins only after an audio file already exists as an attachment. Users who want to capture memo content by speaking must leave the compose flow to record elsewhere, then upload or manually transcribe the result, because the editor has no direct path from microphone input to memo text or an in-progress audio attachment.
|
||||
|
||||
## Current State
|
||||
|
||||
- `web/src/components/MemoEditor/index.tsx:26-154` assembles the compose flow from `EditorContent`, `EditorMetadata`, and `EditorToolbar`, and persists drafts through `memoService.save`.
|
||||
- `web/src/components/MemoEditor/Editor/index.tsx:27-214` implements the editor surface as a `<textarea>` with slash commands and tag suggestions. It has no microphone entrypoint, recording lifecycle, or transcript state.
|
||||
- `web/src/components/MemoEditor/components/EditorToolbar.tsx:10-54` renders the bottom toolbar with `InsertMenu`, visibility, cancel, and save actions. There is no first-class voice action in the primary control row.
|
||||
- `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx:40-189` exposes upload, link-memo, location, and focus-mode actions, and uses a hidden `<input type="file">` for attachments. It does not expose microphone capture or dictation.
|
||||
- `web/src/components/MemoEditor/components/EditorContent.tsx:12-54` handles drag-and-drop and paste for binary files only, and `web/src/components/MemoEditor/hooks/useFileUpload.ts:4-33` handles file-picker selection only.
|
||||
- `web/src/components/MemoEditor/state/types.ts:8-30`, `web/src/components/MemoEditor/state/actions.ts:6-78`, and `web/src/components/MemoEditor/state/reducer.ts:4-130` track memo text, metadata, local files, and loading flags. There is no state for microphone permission, recording mode, partial transcript, cleanup review, or a pending audio blob.
|
||||
- `web/src/components/MemoEditor/hooks/useAutoSave.ts:4-8` saves only the current `content` string to local storage. There is no draft persistence model for an in-progress voice session.
|
||||
- `web/src/components/MemoEditor/services/validationService.ts:9-30` allows save when the draft has text, saved attachments, or local files, and `web/src/components/MemoEditor/services/uploadService.ts:8-26` uploads local files to `AttachmentService`. This means the existing save path can already persist an audio blob if one is present as a `LocalFile`.
|
||||
- `web/src/components/MemoEditor/types/attachment.ts:4-28` classifies editor-side files only as `image`, `video`, or `document`, so an unsaved audio recording would currently fall into the generic document path in the editor draft surface.
|
||||
- `web/src/utils/attachment.ts:15-38` recognizes `audio/*`, `web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx:98-130` groups persisted attachments into visual/audio/docs sections, and `web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx:48-173` renders the dedicated audio playback card added by the last commit.
|
||||
- `server/server.go:71-74` and `server/router/fileserver/fileserver.go:120-149,187-214` already treat video/audio attachments as native HTTP media streams once an attachment exists.
|
||||
- `proto/api/v1/attachment_service.proto:48-90` and `server/router/api/v1/attachment_service.go:64-167` define binary attachment upload and metadata only. There is no transcription request/response shape, language hint, transcript cleanup option, or voice-session metadata in the API.
|
||||
- `proto/api/v1/memo_service.proto:176-245` defines memo content as a single Markdown string plus optional attachments and relations. There is no separate speech transcript field or audio-note abstraction in the memo resource.
|
||||
- `proto/api/v1/instance_service.proto:56-90` and `server/router/api/v1/instance_service.go:36-139` expose instance settings for `GENERAL`, `STORAGE`, `MEMO_RELATED`, `TAGS`, and `NOTIFICATION` only. There is no speech-provider or transcription-retention configuration surface.
|
||||
- No existing implementation found for `getUserMedia`, `MediaRecorder`, browser speech recognition, or server-side transcription anywhere under `web/src`, `server`, `proto`, `plugin`, or `store`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redesigning the current persisted audio attachment playback UI introduced in commit `63a17d89`.
|
||||
- Building a full duplex spoken assistant or chatbot response loop inside Memos.
|
||||
- Replacing the Markdown textarea editor with a different editor architecture.
|
||||
- Shipping native desktop or mobile OS integrations such as global system-wide hotkeys.
|
||||
- Redesigning attachment storage backends or the general file upload pipeline beyond voice-related usage.
|
||||
- Adding broad AI rewrite/edit commands unrelated to capturing spoken memo text into the current draft.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which client surfaces are in scope for the first rollout? (default: the existing React memo composer in the web app, including touch-friendly mobile-browser behavior)
|
||||
- Is the first release a conversational voice mode or a dictation workflow? (default: dictation-first voice capture that inserts text into the current memo draft rather than opening a separate assistant session)
|
||||
- Should Memos retain the raw recording after transcription? (default: no by default; keeping the recording is an explicit user choice that stores it as a normal attachment)
|
||||
- Where does transcription execute? (default: behind a server-owned API so behavior, provider choice, and privacy copy are instance-controlled rather than browser-vendor specific)
|
||||
- How much transcript cleanup is in scope? (default: punctuation plus limited filler/self-correction cleanup, with a review step before insertion)
|
||||
- Does this issue include spoken edit commands such as “rewrite this shorter”? (default: no, only spoken text capture and insertion or replacement)
|
||||
|
||||
## Scope
|
||||
|
||||
**L** — The current gap spans the memo composer UI, editor state model, local file preview behavior, attachment save path, public API surface, and instance settings. There is no existing microphone or transcription implementation to extend, and a complete voice-input workflow would introduce both a new client interaction model and a new server contract rather than a single local edit.
|
||||
@@ -0,0 +1,58 @@
|
||||
## References
|
||||
|
||||
- [OpenAI Help: ChatGPT Release Notes](https://help.openai.com/en/articles/6825453-chatgpt-release-notes%3F.ejs)
|
||||
- [Anthropic Support: Using voice mode on Claude mobile apps](https://support.anthropic.com/en/articles/11101966-using-voice-mode-on-claude-mobile-apps)
|
||||
- [Typeless](https://www.typeless.com/)
|
||||
- [Typeless FAQ](https://www.typeless.com/help/faqs)
|
||||
- [DeltaCircuit/react-media-recorder README](https://github.com/DeltaCircuit/react-media-recorder/blob/master/README.md)
|
||||
|
||||
## Industry Baseline
|
||||
|
||||
ChatGPT, Claude, and Typeless all treat voice capture as a first-class entrypoint near the main compose surface rather than as a secondary attachment action. The common pattern is immediate access to the microphone, visible recording state, and explicit stop/discard control.
|
||||
|
||||
Those products also keep the capture loop short. The user starts recording, sees a clear recording state, and either keeps or cancels the result. Even when the product supports richer voice features, the initial interaction cost stays low.
|
||||
|
||||
The `react-media-recorder` reference reflects the common browser implementation pattern behind that interaction: explicit recorder states, start/stop commands, generated blob URLs, and preview playback of the recorded media. That maps well to the current Memos editor because the editor already knows how to persist local files through the attachment upload path.
|
||||
|
||||
## Research Summary
|
||||
|
||||
The current Memos composer already has the downstream path needed for recorded audio files: local draft files can be attached in the editor, `uploadService` can persist them through `AttachmentService`, and persisted audio attachments already have dedicated playback UI. What is missing is the upstream capture step inside the composer.
|
||||
|
||||
Given the revised scope, the right fit is not dictation or voice chat. The immediate problem is only that users cannot create an audio file from the memo composer itself. That means the smallest useful design is a browser voice recorder that produces a normal draft attachment.
|
||||
|
||||
Because the scope is now frontend-only, the design should not introduce new server contracts, new instance settings, or any transcription workflow. The recorded clip should flow through the existing `LocalFile -> uploadService -> attachment` path exactly like other draft files.
|
||||
|
||||
## Design Goals
|
||||
|
||||
- A user can start recording from the memo composer in one explicit action without opening the attachment menu.
|
||||
- Recording state is visible and explicit: idle, requesting permission, recording, recorded, unsupported, or error.
|
||||
- A completed recording can be kept as a draft audio file or discarded before memo save.
|
||||
- Kept recordings reuse the existing local-file and attachment-save flow with no backend changes.
|
||||
- The draft attachment surface renders recorded audio as playable audio, not as a generic document row.
|
||||
- Save is blocked while recording is actively in progress, but succeeds once a recording has been stopped and kept.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Transcribing audio to text.
|
||||
- Adding any proto, store, server, or instance-settings changes.
|
||||
- Building a spoken assistant or voice-chat mode.
|
||||
- Redesigning persisted audio playback beyond the minimum draft preview needed for local recordings.
|
||||
- Adding background recording, global hotkeys, or native device integrations.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
Add a `Voice note` entry to `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx` rather than keeping a separate always-visible mic button in `web/src/components/MemoEditor/components/EditorToolbar.tsx`. This keeps the recorder close to the existing file and metadata insertion actions, reduces toolbar clutter, and still gives the user a direct path to create an audio attachment from the composer.
|
||||
|
||||
Introduce a `VoiceRecorderPanel` inside the memo editor layout, rendered between the editor body and the bottom metadata/toolbar area in `web/src/components/MemoEditor/index.tsx`. The panel is responsible for showing recorder state and actions, but it does not alter memo content. Its job is only to create or discard a draft audio file.
|
||||
|
||||
Add a dedicated `voiceRecorder` slice to `web/src/components/MemoEditor/state/types.ts` and `web/src/components/MemoEditor/state/reducer.ts`. The slice should hold support state, permission state, recorder status, elapsed time, pending error, and the most recent recorded draft clip before the user decides to keep or discard it. Keeping this state separate from `content`, `metadata`, and `localFiles` prevents recorder lifecycle state from leaking into unrelated editor behaviors.
|
||||
|
||||
Implement the browser media lifecycle in a dedicated hook such as `useVoiceRecorder`, following the `MediaRecorder` state pattern shown in `react-media-recorder`. The hook owns capability detection, `getUserMedia` requests, recorder start/stop, elapsed-time updates, blob assembly, and cleanup of tracks and blob URLs. The editor UI consumes only the hook output and dispatches reducer actions from it.
|
||||
|
||||
When the user stops a recording, convert the captured blob into a `File` and then into the existing `LocalFile` shape already used by file upload, paste, and drag-and-drop flows. The user can then either keep that `LocalFile`, which appends it to `state.localFiles`, or discard it, which revokes the blob URL and clears the recorder state. This keeps the design aligned with the existing upload path and avoids introducing a parallel attachment model.
|
||||
|
||||
Extend `web/src/components/MemoEditor/types/attachment.ts` so local `audio/*` files are classified as audio rather than falling back to `document`. Then update `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx` so draft audio files render with playable audio controls and normal remove behavior. This reuses the recent product investment in better audio presentation without requiring persisted attachments before preview is possible.
|
||||
|
||||
Update `web/src/components/MemoEditor/services/validationService.ts` so save remains allowed for normal local files and kept audio drafts, but not while `voiceRecorder.status` is actively `recording` or `requesting_permission`. That avoids saving a memo in the middle of a live recording session while preserving the existing rule that a memo may be saved with attachments and no text.
|
||||
|
||||
Do not introduce transcription, transcript review, speech-provider configuration, or server upload-before-save behavior in this design. Those alternatives were intentionally rejected because they expand the problem from “create an audio file quickly” into a larger speech-input subsystem. The current narrowed issue only requires fast recording and clean integration with the existing attachment flow.
|
||||
@@ -0,0 +1,40 @@
|
||||
## Execution Log
|
||||
|
||||
### T1: Add recorder state and browser capture hook
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- `web/src/components/MemoEditor/hooks/useVoiceRecorder.ts`
|
||||
- `web/src/components/MemoEditor/hooks/index.ts`
|
||||
- `web/src/components/MemoEditor/state/types.ts`
|
||||
- `web/src/components/MemoEditor/state/actions.ts`
|
||||
- `web/src/components/MemoEditor/state/reducer.ts`
|
||||
- `web/src/components/MemoEditor/services/memoService.ts`
|
||||
**Validation**: `cd web && pnpm lint` — PASS
|
||||
**Path Corrections**: Added `web/src/components/MemoEditor/services/memoService.ts` after plan update because `memoService.fromMemo()` also constructs `EditorState`.
|
||||
**Deviations**: None after the approved plan correction.
|
||||
|
||||
Implemented a dedicated `voiceRecorder` editor state slice, reducer/actions for recorder lifecycle updates, a browser `MediaRecorder` hook that produces a `LocalFile` preview, and the matching `fromMemo()` defaults needed to keep the editor state shape valid for existing memo edit flows.
|
||||
|
||||
### T2: Add composer recorder UI and draft audio handling
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- `web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx`
|
||||
- `web/src/components/MemoEditor/components/EditorToolbar.tsx`
|
||||
- `web/src/components/MemoEditor/components/index.ts`
|
||||
- `web/src/components/MemoEditor/index.tsx`
|
||||
- `web/src/components/MemoEditor/types/components.ts`
|
||||
- `web/src/components/MemoEditor/types/attachment.ts`
|
||||
- `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx`
|
||||
- `web/src/components/MemoEditor/services/validationService.ts`
|
||||
- `web/src/locales/en.json`
|
||||
**Validation**: `cd web && pnpm lint` — PASS
|
||||
**Path Corrections**: None
|
||||
**Deviations**: None
|
||||
|
||||
Added a `Voice note` action to the editor tool dropdown, wired the memo editor to start recording and render an inline recorder/review panel, let users keep a completed clip as a normal draft `LocalFile`, rendered local audio drafts with playable controls in the attachment editor, and blocked save only while permission is pending or recording is live.
|
||||
|
||||
## Completion Declaration
|
||||
|
||||
**Execution completed successfully** — the frontend memo composer now has a tool-dropdown voice recorder entrypoint that creates draft audio files through the existing attachment flow, with no backend or transcription changes.
|
||||
@@ -0,0 +1,63 @@
|
||||
## Task List
|
||||
|
||||
Task Index
|
||||
|
||||
T1: Add recorder state and browser capture hook [M] — T2: Add composer recorder UI and draft audio handling [L]
|
||||
|
||||
### T1: Add recorder state and browser capture hook [M]
|
||||
|
||||
**Objective**: Introduce the frontend-only state and hook needed to record audio in the browser and convert the finished clip into the existing `LocalFile` draft format.
|
||||
**Size**: M (2-3 files, moderate logic)
|
||||
**Files**:
|
||||
- Create: `web/src/components/MemoEditor/hooks/useVoiceRecorder.ts`
|
||||
- Modify: `web/src/components/MemoEditor/state/types.ts`
|
||||
- Modify: `web/src/components/MemoEditor/state/actions.ts`
|
||||
- Modify: `web/src/components/MemoEditor/state/reducer.ts`
|
||||
- Modify: `web/src/components/MemoEditor/hooks/index.ts`
|
||||
- Modify: `web/src/components/MemoEditor/services/memoService.ts`
|
||||
**Implementation**:
|
||||
1. In `web/src/components/MemoEditor/state/types.ts`, add a `voiceRecorder` state slice for recorder support, permission, status, elapsed seconds, pending error, and the latest temporary recording preview.
|
||||
2. In `web/src/components/MemoEditor/state/actions.ts`, add actions for support/permission updates, recorder status changes, timer updates, temporary recording storage, and recorder reset.
|
||||
3. In `web/src/components/MemoEditor/state/reducer.ts`, implement the new voice-recorder actions without changing existing content, metadata, or save behavior.
|
||||
4. In new `web/src/components/MemoEditor/hooks/useVoiceRecorder.ts`, implement browser capability detection, `getUserMedia`, `MediaRecorder` setup, start/stop lifecycle, blob collection, cleanup, and conversion of the stopped recording into a `File` plus preview URL compatible with `LocalFile`.
|
||||
5. In `web/src/components/MemoEditor/services/memoService.ts`, update `fromMemo()` so loaded memo state includes the new `voiceRecorder` defaults required by `EditorState`.
|
||||
6. In `web/src/components/MemoEditor/hooks/index.ts`, export the new hook for editor integration.
|
||||
**Boundaries**: This task must not add any toolbar/panel UI, attachment rendering updates, or transcription/network behavior.
|
||||
**Dependencies**: None
|
||||
**Expected Outcome**: The memo editor has a recorder state model and a reusable browser recording hook that can produce a draft audio file.
|
||||
**Validation**: `cd web && pnpm lint` — expected output: TypeScript and Biome checks pass.
|
||||
|
||||
### T2: Add composer recorder UI and draft audio handling [L]
|
||||
|
||||
**Objective**: Add a voice-recorder entry inside the composer tool dropdown and make kept recordings behave like draft audio attachments in the existing save flow.
|
||||
**Size**: L (multiple files, coordinated UI/state integration)
|
||||
**Files**:
|
||||
- Create: `web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/index.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/components/EditorToolbar.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/components/index.ts`
|
||||
- Modify: `web/src/components/MemoEditor/types/components.ts`
|
||||
- Modify: `web/src/components/MemoEditor/types/attachment.ts`
|
||||
- Modify: `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/services/validationService.ts`
|
||||
- Modify: `web/src/locales/en.json`
|
||||
**Implementation**:
|
||||
1. In `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx` and `web/src/components/MemoEditor/components/EditorToolbar.tsx`, add a `Voice note` action to the existing compose tool dropdown instead of a separate toolbar button.
|
||||
2. In new `web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx`, render the recorder states `unsupported`, `idle`, `requesting_permission`, `recording`, `recorded`, and `error`, with explicit `Start`, `Stop`, `Keep`, and `Discard` actions.
|
||||
3. In `web/src/components/MemoEditor/index.tsx`, render the recorder panel between editor content and the metadata/toolbar group, wire it to the editor context, and on `Keep` append the produced `LocalFile` to `state.localFiles`.
|
||||
4. In `web/src/components/MemoEditor/types/attachment.ts`, classify local `audio/*` files as audio instead of generic documents.
|
||||
5. In `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx`, render local draft audio items with playable audio controls while preserving existing remove behavior and existing attachment reordering rules.
|
||||
6. In `web/src/components/MemoEditor/services/validationService.ts`, block save while a recording is actively running or permission is still being requested, but continue to allow save for kept draft audio files.
|
||||
7. In `web/src/components/MemoEditor/components/index.ts`, `web/src/components/MemoEditor/types/components.ts`, and `web/src/locales/en.json`, add the exports, prop types, and English labels needed for the recorder UI.
|
||||
**Boundaries**: This task must not add transcription, backend/API calls, settings UI, or redesign persisted audio playback beyond local draft preview.
|
||||
**Dependencies**: T1
|
||||
**Expected Outcome**: A user can choose `Voice note` from the memo composer tool dropdown, record audio in the browser, keep or discard the clip, preview a kept clip as a draft audio attachment, and save it through the existing attachment upload path.
|
||||
**Validation**: `cd web && pnpm lint` — expected output: TypeScript and Biome checks pass with the new recorder workflow.
|
||||
|
||||
## Out-of-Scope Tasks
|
||||
|
||||
- Any transcription or speech-to-text behavior.
|
||||
- Any proto, store, server, or instance-settings changes.
|
||||
- Any speech provider configuration.
|
||||
- Assistant-style voice conversations or spoken edit commands.
|
||||
- Full locale backfill beyond the required English copy for this feature.
|
||||
@@ -0,0 +1,49 @@
|
||||
## Background & Context
|
||||
|
||||
Memos stores memo bodies as markdown, rebuilds derived memo metadata into `MemoPayload`, exposes user notifications through the inbox model, and renders memo content in the React client with custom markdown plugins. The requested `@someone` feature spans both top-level memos and memo comments: users need to type `@`, pick a valid person, render the mention inline, and notify the mentioned user. The current product already has adjacent primitives for this work: a backend markdown extension for `#tag`, an inbox-backed notification center, a generic editor suggestion popup, public user profiles under username-based routes, and a memo update path that already rebuilds payloads on create and edit.
|
||||
|
||||
External product behavior is consistent on the core interaction but different on scope. Notion supports real-time `@` suggestions inside pages, comments, and discussions, stores mention notifications in an inbox, and suppresses notification if the mentioned user cannot access the content. Confluence supports autocomplete mentions for people and teams, sends a notification on the first mention, and does not keep notifying on repeated mentions in the same page. Coda supports `@` mentions inside comment threads, treats mentions and thread participation as notification triggers, and allows broader comment-subscription settings beyond explicit mentions. These patterns suggest that the common baseline for Memos is inline autocomplete, access-aware notification, deduplication, and a clear separation between mention notifications and broader thread-subscription features.
|
||||
|
||||
## Issue Statement
|
||||
|
||||
Memos does not currently recognize `@username` tokens as structured content in memo bodies or comment bodies, does not expose any non-admin user-search endpoint that the editor can use to suggest mentionable users, does not persist or diff mention metadata during memo create or update flows, and does not have an inbox or API notification type for mentions. As a result, `@someone` currently behaves as plain text and cannot drive inline rendering, target validation, or notification delivery.
|
||||
|
||||
## Current State
|
||||
|
||||
- `server/router/api/v1/memo_service.go:32-159` creates memos by copying raw `request.Memo.Content` into `store.Memo`, enforcing length limits, and calling `memopayload.RebuildMemoPayload`; `server/router/api/v1/memo_service.go:404-510` rebuilds payload only when `content` changes during memo updates.
|
||||
- `server/router/api/v1/memo_service.go:590-681` creates memo comments by internally creating another memo and only generates inbox notifications for non-private comments to the parent memo creator via `InboxMessage_MEMO_COMMENT`.
|
||||
- `server/router/api/v1/memo_update_helpers.go:27-77` only dispatches webhook and SSE side effects after memo updates; there is no mention-diff side-effect hook.
|
||||
- `internal/markdown/markdown.go:20-24` defines extracted markdown metadata as `Tags` plus `Property`; `internal/markdown/markdown.go:68-89` only wires the custom tag extension; `internal/markdown/markdown.go:324-386` extracts tags and properties but no mention metadata.
|
||||
- `internal/markdown/extensions/tag.go:13-23` and the related tag parser/AST types are the only custom inline markdown extension path today.
|
||||
- `proto/store/memo.proto:7-29` limits `MemoPayload` to `property`, `location`, and `tags`; there is no repeated mention field or structured mention metadata.
|
||||
- `proto/store/inbox.proto:7-24` defines only `InboxMessage_MEMO_COMMENT`; `proto/api/v1/user_service.proto:592-679` defines only `UserNotification_MEMO_COMMENT`.
|
||||
- `server/router/api/v1/user_service.go:1272-1312` lists notifications by filtering inbox rows to `InboxMessage_MEMO_COMMENT` only; `server/router/api/v1/user_service.go:1433-1524` converts only that message type into API notifications.
|
||||
- `web/src/pages/Inboxes.tsx:19-114` and `web/src/components/Inbox/MemoCommentMessage.tsx` only render memo comment notifications; other notification types would currently be dropped.
|
||||
- `server/router/api/v1/user_service.go:32-70` exposes `ListUsers` only to admins, and `store/user.go:59-74` plus `store/db/sqlite/user.go:88-175` support exact-match user filtering but no general search, ranking, or pagination for mention autocomplete.
|
||||
- `server/router/api/v1/acl_config.go:20-27` whitelists `/memos.api.v1.UserService/SearchUsers`, but `proto/api/v1/user_service.proto:16-120` does not define a `SearchUsers` RPC and there is no server implementation.
|
||||
- `web/src/components/MemoEditor/Editor/index.tsx:189-214`, `web/src/components/MemoEditor/Editor/useSuggestions.ts:28-158`, and `web/src/components/MemoEditor/Editor/TagSuggestions.tsx:10-49` provide a reusable textarea suggestion popup, but it is only instantiated for `#tag`.
|
||||
- `web/src/components/MemoContent/index.tsx:53-136`, `web/src/utils/remark-plugins/remark-tag.ts:24-112`, and `web/src/components/MemoContent/Tag.tsx` parse and render `#tag` as a structured inline element; there is no `remarkMention` equivalent.
|
||||
- `web/src/hooks/useUserQueries.ts:176-245` has `useListUsers()` for admin listing and `useUsersByNames()` for fetching known usernames one by one, but nothing that returns ranked candidates for an in-editor `@` query.
|
||||
- `web/src/router/index.tsx:65-72` already routes public user profiles at `u/:username`, so inline mention rendering can target username-based profile URLs without inventing a new frontend route.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Adding group mentions, team mentions, page mentions, or date mentions.
|
||||
- Building a general “watch this memo/thread” subscription system beyond explicit mentions.
|
||||
- Adding email, push, Slack, or webhook delivery for mentions in this issue.
|
||||
- Redesigning memo visibility, access control, or per-user sharing semantics.
|
||||
- Making old mentions follow username changes automatically.
|
||||
- Redesigning the editor away from the current textarea-based implementation.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which content surfaces are in scope for `@mention`? (default: top-level memos and memo comments, because both already share the same memo content pipeline)
|
||||
- What mention token syntax should be recognized? (default: `@username` only, using canonical usernames rather than display names)
|
||||
- Should edits trigger mention notifications after the initial create? (default: yes, but only for newly added mention targets compared with the memo’s previous mention set)
|
||||
- What happens if someone types `@username` in content the target cannot access? (default: render the token as a mention in the author’s view, but do not send a notification unless the target can already access the memo/comment under existing visibility rules)
|
||||
- Should mentioning yourself create an inbox item? (default: no, because self-mentions do not require attention routing)
|
||||
- Should the mention candidate API be public like `GetUser`, or authenticated like the editor? (default: authenticated only, because ranked user search is a broader directory-enumeration surface than fetching a known public profile)
|
||||
|
||||
## Scope
|
||||
|
||||
**L** — The work crosses markdown parsing, memo payload extraction, memo create/update side effects, inbox and notification protos, user search APIs, three SQL drivers, React editor autocomplete, markdown rendering, and inbox UI. The repository already contains adjacent pieces for tags and comment notifications, but `@mention` requires stitching several existing subsystems together rather than extending a single isolated module.
|
||||
@@ -0,0 +1,69 @@
|
||||
## References
|
||||
|
||||
- [Comments, mentions & reactions - Notion Help Center](https://www.notion.com/help/comments-mentions-and-reminders)
|
||||
- [Notification settings - Notion Help Center](https://www.notion.com/help/notification-settings)
|
||||
- [Mention a person or team - Confluence Cloud](https://support.atlassian.com/confluence-cloud/docs/mention-a-person-or-team/)
|
||||
- [Comment on Coda docs - Coda Help](https://help.coda.io/hc/en-us/articles/39555917053069-Comment-on-Coda-docs)
|
||||
- [Customize notifications from comments - Coda Help](https://help.coda.io/hc/en-us/articles/39555901119117-Customize-notifications-from-comments)
|
||||
|
||||
## Industry Baseline
|
||||
|
||||
`Comments, mentions & reactions - Notion Help Center` shows the most common editor-side behavior: typing `@` triggers real-time search, mentions can live inline in page bodies and comments, clicking an inbox item takes the user back to the exact context, and no notification is sent when the target cannot access the page. `Notification settings - Notion Help Center` also separates in-product inbox behavior from secondary delivery like desktop or email.
|
||||
|
||||
`Mention a person or team - Confluence Cloud` adds two useful guardrails for a collaborative editor: autocomplete suggestions appear directly from `@`, and notifications are intentionally deduplicated so people are notified on the first mention rather than on every repeated mention in the same page.
|
||||
|
||||
`Comment on Coda docs` and `Customize notifications from comments` show a narrower scope for mentions inside comments, but reinforce two patterns that matter for Memos: explicit `@` mentions are a distinct notification trigger from generic participation, and products often keep mention notifications separate from broader thread-subscription or owner-subscription rules.
|
||||
|
||||
Across these products, the default implementation is not “parse arbitrary display text and hope it matches a user.” The stable interaction is: search among valid workspace members, insert a canonical mention token, render it differently from plain text, and only notify when access and deduplication rules say the event is meaningful.
|
||||
|
||||
## Research Summary
|
||||
|
||||
Memos already has the right extension points to adopt that baseline without a storage redesign. The backend has a custom inline markdown extension pipeline for `#tag`, memo create and update both rebuild `MemoPayload`, and the inbox model already represents user-facing attention items. The frontend editor already has a trigger-character suggestion popup, the markdown renderer already recognizes custom inline nodes, and public user profiles are already routed by username.
|
||||
|
||||
The biggest mismatch is user discovery. The current `ListUsers` path is admin-only and exact-match oriented, while mention autocomplete needs a normal authenticated user search API that can return ranked candidates by username and display name. The second mismatch is notification shape: the inbox and API layers only understand memo-comment notifications today, so a mention feature cannot be expressed as a first-class notification without extending the inbox proto and inbox UI.
|
||||
|
||||
Research also suggests that Memos should stay narrower than Notion or Confluence. There is no existing concept of teams, group mentions, page mentions, or per-page ACLs. The codebase already treats usernames as the public user token and memo visibility as a coarse `PUBLIC/PROTECTED/PRIVATE` rule. The best fit is therefore person mentions only, keyed by canonical username, with notification rules that are access-aware and deduplicated across repeated edits.
|
||||
|
||||
## Design Goals
|
||||
|
||||
- Typing `@` in the memo editor or comment editor shows ranked, authenticated user candidates and inserts a canonical `@username` token on selection.
|
||||
- The backend extracts mention targets from memo/comment content during create, update, and payload rebuild, and produces the same mention set for equivalent content across all supported databases.
|
||||
- Mention notifications are created only for newly added targets, at most once per target per memo revision, and never for self-mentions or inaccessible private content.
|
||||
- Memo content renders resolved mentions as interactive inline entities and degrades unresolved tokens to plain text.
|
||||
- The inbox API and inbox UI expose mention notifications as a first-class type distinct from comment notifications.
|
||||
- The design does not require a relational schema migration; it only extends existing proto-backed JSON payloads and server/frontend code paths.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Adding group mentions, team mentions, page mentions, or date mentions.
|
||||
- Building a generic watch/subscription system for memo activity.
|
||||
- Sending mention notifications through email, push, Slack, or webhooks.
|
||||
- Making mention references survive username changes automatically.
|
||||
- Replacing the textarea editor with a richer block editor.
|
||||
- Redesigning memo visibility or introducing user-level memo sharing.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
Support only canonical `@username` mentions in this issue. The parser should recognize the same username token vocabulary that the API already accepts for public user names, instead of trying to match display names or arbitrary free text. This keeps mention authoring aligned with existing user resource naming and avoids ambiguous matches when multiple users share similar display names. Mention suggestions may show both display name and username, but the inserted source text remains `@username`.
|
||||
|
||||
Add a backend markdown mention extension parallel to the existing tag extension. Introduce `internal/markdown/ast.MentionNode`, `internal/markdown/parser.NewMentionParser()`, and `internal/markdown/extensions.MentionExtension`, then wire it into `internal/markdown/markdown.go` next to `TagExtension`. The mention parser should require a word boundary before `@` so email addresses and URLs do not become mentions, and it should normalize the captured token to lowercase before lookup because usernames are canonicalized that way in the API layer.
|
||||
|
||||
Extend `storepb.MemoPayload` with a repeated mention metadata field, for example `repeated Mention mentions`, where each item stores at least `username` and resolved `user_id`. The raw markdown remains the source of truth for author-visible text, but the payload becomes the normalized server-side mention set for diffing and notification decisions. This reuses the existing memo payload rebuild path and avoids reparsing memo bodies in multiple side-effect handlers. No SQL migration is required because memo payloads are already stored as proto-backed JSON blobs in each database driver.
|
||||
|
||||
Teach `memopayload.RebuildMemoPayload` to resolve mention metadata while rebuilding tags and properties. The extraction step should walk the markdown AST once, collect raw `@username` tokens, resolve them to active users via the store, deduplicate by `user_id`, and populate `memo.Payload.Mentions`. Unresolved usernames should not fail memo creation; they should simply be omitted from normalized mention metadata so the feature remains tolerant of free-typed text. This mirrors how the frontend can degrade unresolved tokens back to plain text.
|
||||
|
||||
Add a dedicated mention side-effect helper around memo create and update flows. On create, after the memo is persisted and the final payload is available, compute the normalized mentioned user set from `memo.Payload.Mentions` and create inbox items for allowed targets. On update, diff the previous and new normalized mention sets and only notify targets that were newly added in the latest saved revision. This follows the Confluence-style deduplication pattern and prevents repeated notifications when a memo is edited without changing its mention set. If a mention is removed and later re-added, it counts as newly added again and may generate a fresh inbox item.
|
||||
|
||||
Apply access and duplication rules before writing inbox rows. Self-mentions are ignored. For top-level memos, notify only when the target can already read the memo under current visibility rules. For comments, notify the mentioned user when they can read the comment context and are not already covered by the existing memo-comment notification to the parent memo owner for that same event. This keeps mention notifications meaningful and avoids sending an owner both a comment notification and a mention notification for the same comment creation unless future product requirements explicitly want both. For `PRIVATE` memos and `PRIVATE` comments, mentions remain author-visible text but do not generate inbox notifications for other users.
|
||||
|
||||
Extend inbox storage and API notifications with a dedicated mention type instead of overloading the existing comment type. Add `MEMO_MENTION` to `proto/store/inbox.proto` with a payload that can represent both top-level memos and comments, such as `memo_id` plus optional `related_memo_id`. Mirror that in `proto/api/v1/user_service.proto` with `UserNotification_MEMO_MENTION` and `MemoMentionPayload`. Reuse the current notification conversion pattern in `server/router/api/v1/user_service.go`: resolve memo names from stored IDs, return a first-class mention payload, and let the inbox page render a separate mention card component. This keeps the notification center composable as new activity types appear.
|
||||
|
||||
Add an authenticated user-search endpoint specifically for mention autocomplete. The repository already has a stale public-method placeholder for `SearchUsers`, but no proto or handler. Define `SearchUsers` in `proto/api/v1/user_service.proto`, remove it from the public ACL list, and implement it in `server/router/api/v1/user_service.go` as an authenticated RPC that accepts a short query string plus page size. Extend `store.FindUser` with search-oriented fields and implement driver-specific case-insensitive matching in SQLite, MySQL, and PostgreSQL over `username` and `nickname`, ordered by exact username match, username prefix, nickname prefix, then a stable fallback. This produces a usable editor candidate list without reusing the admin-only `ListUsers` contract.
|
||||
|
||||
Implement frontend mention suggestions by reusing the existing generic textarea suggestion system. Add a `MentionSuggestions` component beside `TagSuggestions`, hook it into `web/src/components/MemoEditor/Editor/index.tsx`, and back it with a debounced `useSearchUsers(query)` hook. The popup should render avatar, display name, and `@username`, while selection inserts `@username ` exactly. Because `useSuggestions` currently operates on local item arrays, it can stay generic if the mention hook owns the remote query and passes the current ranked results down as `items`.
|
||||
|
||||
Implement frontend mention rendering with a dedicated markdown plugin and component instead of trying to infer mentions from links or plain spans. Add `remarkMention` beside `remarkTag`, a `Mention` inline component beside `Tag`, and a mention type guard in `web/src/types/markdown.ts`. The renderer should link resolved mentions to `/u/:username`, show display name or username with avatar-based affordance when lookup data is available, and render unresolved mention text non-interactively. To avoid N-per-mention network fetches, `MemoContent` should collect mentioned usernames from content and hydrate them through the existing `useUsersByNames()` hook once per memo render tree.
|
||||
|
||||
Render mention notifications as their own inbox card. Reuse the existing `MemoCommentMessage` pattern, but resolve the source memo/comment and optional related memo from the `MemoMentionPayload`. The card should show who mentioned the user, in what memo or comment, a short snippet, and navigate to the relevant memo detail on click. `web/src/pages/Inboxes.tsx` should switch on both `MEMO_COMMENT` and `MEMO_MENTION` so the inbox can grow by type without silently discarding new notifications.
|
||||
|
||||
Do not solve username drift in this issue. If a user later changes username, existing raw markdown still contains the old `@username` text, and rebuilt payload metadata will stop resolving unless the old token still matches a live username. This is acceptable for the current scope because username-history and alias resolution are already out of scope elsewhere in the codebase. The alternative of storing opaque mention IDs in source markdown or adding a username-alias subsystem was rejected because it turns a contained collaboration feature into a broader identity migration project.
|
||||
@@ -0,0 +1,37 @@
|
||||
## Execution Log
|
||||
|
||||
### T1: Add backend mention parsing and payload extraction
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `internal/markdown/ast/mention.go`, `internal/markdown/parser/mention.go`, `internal/markdown/extensions/mention.go`, `internal/markdown/markdown.go`, `internal/markdown/renderer/markdown_renderer.go`, `server/runner/memopayload/runner.go`, `server/router/api/v1/memo_service.go`, `server/router/api/v1/v1.go`, `server/router/api/v1/test/test_helper.go`, `internal/markdown/markdown_test.go`
|
||||
**Validation**: `go test ./internal/markdown` — PASS
|
||||
**Path Corrections**: `RebuildMemoPayload` needed `context + store` so mention resolution could happen during payload rebuild.
|
||||
**Deviations**: None
|
||||
|
||||
### T2: Add mention notifications and user search APIs
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `proto/store/memo.proto`, `proto/store/inbox.proto`, `proto/api/v1/user_service.proto`, `server/router/api/v1/user_service.go`, `server/router/api/v1/connect_services.go`, `server/router/api/v1/acl_config.go`, `server/router/api/v1/acl_config_test.go`, `server/router/api/v1/memo_mention_helpers.go`, `store/user.go`, `store/db/sqlite/user.go`, `store/db/postgres/user.go`, `store/db/mysql/user.go`, `server/router/api/v1/test/user_notification_test.go`, `server/router/api/v1/test/user_search_test.go`
|
||||
**Validation**: `go test ./server/router/api/v1/...` — PASS
|
||||
**Path Corrections**: Unknown legacy inbox message types are filtered server-side to keep unread counts aligned with rendered cards.
|
||||
**Deviations**: None
|
||||
|
||||
### T3: Add frontend mention autocomplete, rendering, and inbox UI
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `web/src/components/MemoEditor/Editor/MentionSuggestions.tsx`, `web/src/components/MemoEditor/Editor/index.tsx`, `web/src/components/MemoEditor/Editor/useSuggestions.ts`, `web/src/hooks/useUserQueries.ts`, `web/src/utils/remark-plugins/remark-mention.ts`, `web/src/components/MemoContent/MentionContext.tsx`, `web/src/components/MemoContent/Mention.tsx`, `web/src/components/MemoContent/index.tsx`, `web/src/components/MemoContent/ConditionalComponent.tsx`, `web/src/types/markdown.ts`, `web/src/components/Inbox/MemoMentionMessage.tsx`, `web/src/pages/Inboxes.tsx`
|
||||
**Validation**: `pnpm lint && pnpm build` — PASS
|
||||
**Path Corrections**: Editor autocomplete reused the existing generic suggestion hook by exposing the live query rather than duplicating keyboard navigation logic.
|
||||
**Deviations**: None
|
||||
|
||||
### T4: Regenerate code and validate the feature
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `proto/gen/**`, `web/src/types/proto/**`
|
||||
**Validation**: `buf generate` — PASS; `go test ./internal/markdown ./server/router/api/v1/...` — PASS; `pnpm lint` — PASS; `pnpm build` — PASS
|
||||
**Path Corrections**: None
|
||||
**Deviations**: None
|
||||
|
||||
## Completion Declaration
|
||||
|
||||
All tasks completed successfully
|
||||
@@ -0,0 +1,104 @@
|
||||
## Task List
|
||||
|
||||
### Task Index
|
||||
|
||||
T1: Add backend mention parsing and payload extraction [M] — T2: Add mention notifications and user search APIs [L] — T3: Add frontend mention autocomplete, rendering, and inbox UI [L] — T4: Regenerate code and validate the feature [M]
|
||||
|
||||
### T1: Add backend mention parsing and payload extraction [M]
|
||||
|
||||
**Objective**: Parse `@username` tokens into structured mention metadata during memo payload rebuilds.
|
||||
**Size**: M
|
||||
**Files**:
|
||||
- Create: `internal/markdown/ast/mention.go`
|
||||
- Create: `internal/markdown/parser/mention.go`
|
||||
- Create: `internal/markdown/extensions/mention.go`
|
||||
- Modify: `internal/markdown/markdown.go`
|
||||
- Modify: `internal/markdown/renderer/markdown_renderer.go`
|
||||
- Modify: `server/runner/memopayload/runner.go`
|
||||
- Modify: `server/router/api/v1/memo_service.go`
|
||||
- Test: `internal/markdown/markdown_test.go`
|
||||
**Implementation**:
|
||||
1. Add mention AST/parser/extension parallel to the existing tag implementation.
|
||||
2. Extend extracted markdown data and `MemoPayload` rebuild to collect normalized mentions and resolve them to users.
|
||||
3. Update memo create/update and background payload rebuild paths to use the new mention-aware payload builder.
|
||||
**Boundaries**: Do not add a relational schema migration.
|
||||
**Dependencies**: None
|
||||
**Expected Outcome**: Memo payloads carry normalized mention metadata rebuilt from markdown content.
|
||||
**Validation**: `go test ./internal/markdown` — expected `ok`
|
||||
|
||||
### T2: Add mention notifications and user search APIs [L]
|
||||
|
||||
**Objective**: Expose mention-aware APIs and create inbox items for newly added mentions.
|
||||
**Size**: L
|
||||
**Files**:
|
||||
- Modify: `proto/store/memo.proto`
|
||||
- Modify: `proto/store/inbox.proto`
|
||||
- Modify: `proto/api/v1/user_service.proto`
|
||||
- Modify: `server/router/api/v1/user_service.go`
|
||||
- Modify: `server/router/api/v1/connect_services.go`
|
||||
- Modify: `server/router/api/v1/acl_config.go`
|
||||
- Modify: `server/router/api/v1/acl_config_test.go`
|
||||
- Create: `server/router/api/v1/memo_mention_helpers.go`
|
||||
- Modify: `store/user.go`
|
||||
- Modify: `store/db/sqlite/user.go`
|
||||
- Modify: `store/db/postgres/user.go`
|
||||
- Modify: `store/db/mysql/user.go`
|
||||
- Test: `server/router/api/v1/test/user_notification_test.go`
|
||||
- Test: `server/router/api/v1/test/user_search_test.go`
|
||||
**Implementation**:
|
||||
1. Extend proto contracts with `MemoPayload.mentions`, `InboxMessage.MEMO_MENTION`, `UserNotification.MEMO_MENTION`, and `SearchUsers`.
|
||||
2. Implement authenticated user search over username and nickname.
|
||||
3. Add mention notification side effects for memo create/update/comment flows with diffing and duplicate suppression.
|
||||
4. Convert inbox rows into either comment or mention notifications and filter unknown legacy types.
|
||||
**Boundaries**: Do not add email/push/webhook mention delivery.
|
||||
**Dependencies**: T1
|
||||
**Expected Outcome**: Mentioned users receive inbox notifications and the editor has an API to fetch mention candidates.
|
||||
**Validation**: `go test ./server/router/api/v1/...` — expected `ok`
|
||||
|
||||
### T3: Add frontend mention autocomplete, rendering, and inbox UI [L]
|
||||
|
||||
**Objective**: Let users insert mentions from the editor and render/read them in the UI.
|
||||
**Size**: L
|
||||
**Files**:
|
||||
- Create: `web/src/components/MemoEditor/Editor/MentionSuggestions.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/Editor/index.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/Editor/useSuggestions.ts`
|
||||
- Modify: `web/src/hooks/useUserQueries.ts`
|
||||
- Create: `web/src/utils/remark-plugins/remark-mention.ts`
|
||||
- Create: `web/src/components/MemoContent/MentionContext.tsx`
|
||||
- Create: `web/src/components/MemoContent/Mention.tsx`
|
||||
- Modify: `web/src/components/MemoContent/index.tsx`
|
||||
- Modify: `web/src/components/MemoContent/ConditionalComponent.tsx`
|
||||
- Modify: `web/src/types/markdown.ts`
|
||||
- Create: `web/src/components/Inbox/MemoMentionMessage.tsx`
|
||||
- Modify: `web/src/pages/Inboxes.tsx`
|
||||
**Implementation**:
|
||||
1. Add `@` autocomplete backed by `SearchUsers`.
|
||||
2. Add markdown mention parsing/rendering and hydrate mentioned users once per memo render.
|
||||
3. Add a dedicated inbox card for memo mention notifications.
|
||||
**Boundaries**: Do not redesign the textarea editor.
|
||||
**Dependencies**: T2
|
||||
**Expected Outcome**: Users can insert, see, and open mentions from memo content and inbox notifications.
|
||||
**Validation**: `pnpm lint && pnpm build` — expected success
|
||||
|
||||
### T4: Regenerate code and validate the feature [M]
|
||||
|
||||
**Objective**: Regenerate generated code and verify backend/frontend behavior.
|
||||
**Size**: M
|
||||
**Files**:
|
||||
- Modify: `proto/gen/**`
|
||||
- Modify: `web/src/types/proto/**`
|
||||
**Implementation**:
|
||||
1. Run `buf generate` after proto changes.
|
||||
2. Re-run focused Go tests and frontend lint/build.
|
||||
**Boundaries**: Do not broaden into unrelated CI cleanup.
|
||||
**Dependencies**: T1, T2, T3
|
||||
**Expected Outcome**: Generated code matches the new APIs and validations pass.
|
||||
**Validation**: `buf generate`, `go test ./internal/markdown ./server/router/api/v1/...`, `pnpm lint`, `pnpm build`
|
||||
|
||||
## Out-of-Scope Tasks
|
||||
|
||||
- Group/team mentions
|
||||
- Username alias migration
|
||||
- Email or push delivery for mentions
|
||||
- Watch/subscription semantics beyond explicit mentions
|
||||
@@ -0,0 +1,70 @@
|
||||
## Background & Context
|
||||
|
||||
SSO sign-in in memos currently treats the IdP-provided identifier as the local username. The identifier value comes from the OAuth2 UserInfo claim named in `FieldMapping.identifier`, while local usernames are validated by `validateUsername` against `base.UIDMatcher`. Real IdPs frequently emit identifiers such as email addresses, opaque subject IDs, or provider-specific account IDs that are valid authentication subjects but are not valid memos usernames.
|
||||
|
||||
The existing issue artifacts under `docs/plans/2026-04-21-sso-user-identity-linkage/` already scope a persistent linkage between SSO identities and local users. A broader review of upstream open source schemas now shows that similar systems converge on separating external identity from the local user row, but do not converge on one universal table name or one exact column set. That difference matters because the implementation problem is narrower than "copy one upstream schema exactly" and broader than "pick any new table name locally."
|
||||
|
||||
## Issue Statement
|
||||
|
||||
The SSO sign-in path in `server/router/api/v1/auth_service.go` resolves and creates users from `userInfo.Identifier` through `User.Username`, and no provider-scoped external identity record exists to resolve a local user independently of that username; as a result, provider-issued identifiers that are valid authentication subjects but invalid memos usernames fail the sign-in path, and the future persistence model still requires an explicit schema decision among several verified upstream identity-link patterns.
|
||||
|
||||
## Current State
|
||||
|
||||
**Sign-in path** — `server/router/api/v1/auth_service.go:124-173`
|
||||
- Lines 124-132 apply `identifier_filter` to `userInfo.Identifier`.
|
||||
- Lines 135-137 call `GetUser(FindUser{Username: &userInfo.Identifier})`.
|
||||
- Lines 151-159 create a new `store.User` with `Username: userInfo.Identifier`.
|
||||
- No persistent linkage record is read or written during SSO sign-in.
|
||||
|
||||
**Username validation** — `server/router/api/v1/user_resource_name.go:33-38`
|
||||
- `validateUsername` rejects empty strings, fully numeric usernames, and values that fail `base.UIDMatcher`.
|
||||
- `base.UIDMatcher` is defined in `internal/base/resource_name.go:5-6` as `^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$`.
|
||||
|
||||
**User model** — `store/user.go:26-77`
|
||||
- `store.User` contains `Username`, `Email`, `Nickname`, `AvatarURL`, and other local account fields.
|
||||
- `store.FindUser` supports lookup by `ID`, `Username`, `Email`, and related filters.
|
||||
- No external identity field exists on the user model.
|
||||
|
||||
**Current database schema** — `store/migration/sqlite/LATEST.sql:9-79`
|
||||
- `user` table (`lines 10-22`) stores `username` as a unique column and has no external identity column.
|
||||
- `idp` table (`lines 72-79`) stores `uid` as the stable identifier for an IdP instance.
|
||||
- The latest checked-in migration version is `0.27` under all three backends (`store/migration/sqlite/0.27`, `store/migration/postgres/0.27`, `store/migration/mysql/0.27`).
|
||||
|
||||
**IdP user info mapping** — `internal/idp/idp.go:3-8`, `internal/idp/oauth2/oauth2.go:105-129`
|
||||
- `IdentityProviderUserInfo` carries `Identifier`, `DisplayName`, `Email`, and `AvatarURL`.
|
||||
- `Identifier` is loaded from the configured claim and is required to be non-empty.
|
||||
- `DisplayName` falls back to `Identifier` when not mapped.
|
||||
|
||||
**No existing linkage persistence**
|
||||
- No `identity`, `identities`, `user_identity`, `external_login_user`, or similar structure exists anywhere under `store/`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Changing `UIDMatcher` or `validateUsername`.
|
||||
- Changing how `FieldMapping` maps OAuth2 claims into `IdentityProviderUserInfo`.
|
||||
- Changing how password-based local sign-in works.
|
||||
- Changing `identifier_filter` behavior.
|
||||
- Supporting IdP types other than `OAUTH2` in this issue.
|
||||
- Providing UI or API surfaces for linking or unlinking external identities.
|
||||
- Migrating or renaming existing usernames already stored in `user`.
|
||||
- Automatically linking pre-existing users whose current `User.Username` happens to match an IdP identifier.
|
||||
- Adding SCIM, directory sync, or per-group / multi-tenant IdP scoping.
|
||||
- Storing provider access tokens or profile payloads unless a live memos code path requires them.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. When a new SSO user's `userInfo.Identifier` does not yield a valid username, what value is used as the initial `User.Username`? (default: derive from `DisplayName`, then `Email`, then `Identifier`, normalizing to a valid username and retrying with a short suffix on collision)
|
||||
|
||||
2. Should an existing local user be linkable to an SSO identity after registration? (default: no — out of scope for this issue)
|
||||
|
||||
3. Should one local user be linkable to multiple external identities across different IdP instances? (default: yes — allow multiple rows per `user_id`, one per provider-scoped external identifier)
|
||||
|
||||
4. What schema vocabulary should represent the provider-scoped external identity record? (default: use table `user_identity` to match current memos table naming, with `provider` and `extern_uid` as the stored linkage fields)
|
||||
|
||||
5. Should the linkage schema store only lookup fields or also provider metadata such as tokens and raw profile data? (default: lookup fields only for this issue)
|
||||
|
||||
6. Should the linkage table be added across SQLite, PostgreSQL, and MySQL? (default: yes — mirror the existing migration strategy across all supported backends)
|
||||
|
||||
## Scope
|
||||
|
||||
**L** — the work still spans a new persistence structure across three database backends, store-layer types and driver implementations, sign-in path changes, username derivation behavior, and now an explicit design choice among several verified upstream schema patterns rather than a single assumed naming scheme.
|
||||
@@ -0,0 +1,173 @@
|
||||
## References
|
||||
|
||||
1. **GitLab `db/init_structure.sql` — `identities` table** (verified)
|
||||
https://gitlab.com/gitlab-org/gitlab/-/raw/master/db/init_structure.sql?ref_type=heads
|
||||
GitLab stores external identities in a separate `identities` table with fields including `extern_uid`, `provider`, `user_id`, `created_at`, and `updated_at`, plus provider-specific extensions such as `saml_provider_id`.
|
||||
|
||||
2. **Gitea `models/user/external_login_user.go` — `ExternalLoginUser`** (verified)
|
||||
https://raw.githubusercontent.com/go-gitea/gitea/main/models/user/external_login_user.go
|
||||
Gitea persists external account links separately from the user row. The core linkage fields are `ExternalID`, `LoginSourceID`, and `UserID`, with optional provider metadata and tokens.
|
||||
|
||||
3. **Discourse `app/models/user_associated_account.rb` — `user_associated_accounts`** (verified)
|
||||
https://raw.githubusercontent.com/discourse/discourse/main/app/models/user_associated_account.rb
|
||||
Discourse uses a dedicated association table with `provider_name`, `provider_uid`, `user_id`, timestamps, and JSONB metadata. It enforces uniqueness on both `(provider_name, provider_uid)` and `(provider_name, user_id)`.
|
||||
|
||||
4. **Keycloak `FederatedIdentityEntity.java` — `FEDERATED_IDENTITY`** (verified)
|
||||
https://raw.githubusercontent.com/keycloak/keycloak/main/model/jpa/src/main/java/org/keycloak/models/jpa/entities/FederatedIdentityEntity.java
|
||||
Keycloak models brokered identities separately with `USER_ID`, `IDENTITY_PROVIDER`, `FEDERATED_USER_ID`, `FEDERATED_USERNAME`, `REALM_ID`, and `TOKEN`, using provider-scoped lookup queries rather than local username lookup.
|
||||
|
||||
5. **Immich `user.table.ts` and initial migration — `users.oauthId`** (verified)
|
||||
https://raw.githubusercontent.com/immich-app/immich/main/server/src/schema/tables/user.table.ts
|
||||
https://raw.githubusercontent.com/immich-app/immich/main/server/src/schema/migrations/1744910873969-InitialMigration.ts
|
||||
Immich stores a single `oauthId` column directly on the `users` table instead of a separate linkage table.
|
||||
|
||||
6. **Mattermost `server/public/model/user.go` — `AuthData` and `AuthService` on `User`** (verified)
|
||||
https://raw.githubusercontent.com/mattermost/mattermost/master/server/public/model/user.go
|
||||
Mattermost stores external-auth linkage directly on the user model through `AuthData` and `AuthService`.
|
||||
|
||||
## Industry Baseline
|
||||
|
||||
There is no single universal table name for SSO identity linkage across open source systems. The consistent pattern is structural rather than nominal: systems that support multiple external providers or explicit account linking separate the external identity from the local username and from the core user row.
|
||||
|
||||
Three recurring schema families appear in the references:
|
||||
|
||||
- **Provider-scoped identity-link table**. GitLab `identities` and Gitea `ExternalLoginUser` both persist a provider discriminator, an external identifier, and a local `user_id` in a separate structure. This is the most direct fit when one local user may be linked to multiple IdPs.
|
||||
- **Association table with richer metadata**. Discourse `user_associated_accounts` uses the same provider-plus-external-id pattern, but also stores provider payloads and enforces one account per provider per user.
|
||||
- **User-row coupling**. Immich and Mattermost place the external identifier on the user row itself. This minimizes schema surface area, but it ties the user record to one external binding shape and reduces flexibility when multiple providers or multiple linked identities are in scope.
|
||||
|
||||
Keycloak's `FEDERATED_IDENTITY` falls in the separate-table family as well, but introduces an additional realm scope and stores broker-specific metadata because Keycloak is itself an identity broker rather than an application with a local username model.
|
||||
|
||||
Known trade-offs:
|
||||
|
||||
- Separate tables add one extra lookup during sign-in compared with `GetUser(Username)`, but they avoid coupling authentication subjects to user-facing usernames.
|
||||
- Richer association tables support future linking, unlinking, and metadata sync, but they increase write paths and retention of provider data.
|
||||
- User-row columns are simpler for one-provider or one-link systems, but they do not fit a product that already supports multiple configured IdP instances.
|
||||
|
||||
## Research Summary
|
||||
|
||||
- GitLab, Gitea, Discourse, and Keycloak all separate the external identity record from the local username.
|
||||
- The recurring core fields are a **provider scope**, an **external subject/UID**, and a **local user ID**. Exact names vary: `provider`, `login_source_id`, `provider_name`, `identity_provider`, `extern_uid`, `external_id`, `provider_uid`, `federated_user_id`.
|
||||
- Table naming is not standardized across projects. `identities` is common and recognizable, but `external_login_user`, `user_associated_accounts`, and `FEDERATED_IDENTITY` are also established patterns.
|
||||
- Single-column user-row designs such as Immich `oauthId` and Mattermost `AuthData` / `AuthService` are simpler, but they align with products that bind one external identity shape directly to the user row rather than a product that already has multiple `idp` records.
|
||||
- For memos, the verified references support a provider-scoped link table and do not support continuing to use `User.Username` as the external identity lookup key. They also suggest that `provider` and `extern_uid` are clearer linkage-field names than repo-specific names like `idp_id`, even if the table name itself follows memos naming conventions.
|
||||
|
||||
## Design Goals
|
||||
|
||||
1. **G1 — Username-independent lookup**: SSO sign-in resolves an existing user without requiring `userInfo.Identifier` to pass `validateUsername`. Verifiable: a sign-in flow with `Identifier = "jane@example.com"` or an opaque subject string reaches user lookup through the linkage table, not through `FindUser{Username: ...}`.
|
||||
|
||||
2. **G2 — Provider-scoped uniqueness**: Two configured IdP instances may issue the same external identifier string without colliding. Verifiable: uniqueness is enforced on the pair `(provider, extern_uid)`, not on `extern_uid` alone.
|
||||
|
||||
3. **G3 — No new external-auth column on `user`**: The local `user` table remains a local account record rather than the storage site for external identity subjects. Verifiable: migration files do not add `oauth_id`, `auth_data`, `provider_uid`, or similar columns to `user`.
|
||||
|
||||
4. **G4 — Repo-aligned table naming with reference-aligned linkage fields**: The new linkage schema follows memos table naming conventions while using linkage field names that map directly to upstream patterns. Verifiable: the design uses table `user_identity` together with fields `provider` and `extern_uid`.
|
||||
|
||||
5. **G5 — Minimal persistence surface for this issue**: The linkage record stores lookup fields required by sign-in and excludes provider payloads that no current memos code path reads. Verifiable: the initial schema contains no token, JSON payload, or raw-profile columns.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
All non-goals from `definition.md` apply. Additionally:
|
||||
|
||||
- Matching GitLab, Gitea, Discourse, or Keycloak field-for-field beyond the linkage concepts required by memos.
|
||||
- Adding SCIM-, SAML-, or realm-specific columns such as Keycloak `REALM_ID` or GitLab `saml_provider_id`.
|
||||
- Enforcing a one-provider-per-user rule like Discourse's `(provider_name, user_id)` uniqueness.
|
||||
- Storing provider tokens, raw payloads, or synchronization metadata in the initial linkage table.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
### 1. Use a separate `user_identity` table
|
||||
|
||||
Adopt the separate-table family used by GitLab `identities`, Gitea `ExternalLoginUser`, Discourse `user_associated_accounts`, and Keycloak `FEDERATED_IDENTITY`, while keeping the stored fields limited to what memos currently needs for sign-in (G1, G2, G3, G5). The table name should follow memos' existing singular / relationship-style naming pattern rather than copying GitLab's pluralized table name directly (G4).
|
||||
|
||||
Proposed logical schema:
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_identity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL, -- stores idp.uid
|
||||
extern_uid TEXT NOT NULL,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
UNIQUE (provider, extern_uid)
|
||||
);
|
||||
|
||||
CREATE INDEX user_identity_user_id_idx ON user_identity(user_id);
|
||||
```
|
||||
|
||||
Naming decisions:
|
||||
|
||||
- **`user_identity`** follows the repo's current table naming pattern. Existing memos tables are singular or singular relationship tables such as `user`, `memo`, `memo_relation`, `memo_share`, and `idp`, so `user_identity` is more consistent locally than GitLab's plural `identities` (G4).
|
||||
- **`provider`** is the stored `idp.uid`. This maps directly to GitLab `provider`, Keycloak `IDENTITY_PROVIDER`, Discourse `provider_name`, and Gitea's effective provider scope via `LoginSourceID`.
|
||||
- **`extern_uid`** uses GitLab's term for the provider-issued subject and avoids coupling the schema to a specific protocol word such as `sub`, `email`, or `external_id`.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- **`identities`**: recognizable from GitLab, but inconsistent with the repo's current schema naming pattern.
|
||||
- **`user_identity` with `idp_id`**: workable on naming, but less reference-aligned on the linkage fields than `provider` / `extern_uid`.
|
||||
- **Add `oauth_id`-style column(s) to `user`**: simpler, but inconsistent with memos supporting multiple `idp` rows and weaker on G2 and G3, matching the Immich and Mattermost trade-off rather than the multi-provider references.
|
||||
|
||||
No foreign key is proposed from `provider` to `idp.uid` in the initial design. The current memos migrations do not rely heavily on cross-backend foreign-key behavior, and the linkage record must remain stable even if IdP rows are reworked at the application layer.
|
||||
|
||||
### 2. Add a dedicated store model and driver methods
|
||||
|
||||
Add a new store module for the linkage record instead of extending `store.User` (G3):
|
||||
|
||||
- `store/user_identity.go`
|
||||
- `store/db/sqlite/user_identity.go`
|
||||
- `store/db/postgres/user_identity.go`
|
||||
- `store/db/mysql/user_identity.go`
|
||||
|
||||
The store type should mirror the lookup schema:
|
||||
|
||||
- `UserIdentity`: `ID`, `UserID`, `Provider`, `ExternUID`, `CreatedTs`, `UpdatedTs`
|
||||
- `FindUserIdentity`: `Provider`, `ExternUID`, `UserID`
|
||||
|
||||
Only create and read operations are required in this issue. Update and delete paths are deferred because no current sign-in flow needs them (G5). The create path must surface provider/external-UID uniqueness conflicts so the sign-in flow can reconcile concurrent first-login races instead of leaving an unlinked local user behind.
|
||||
|
||||
### 3. Route SSO lookup through the linkage record
|
||||
|
||||
Change the `auth_service.SignIn` SSO branch so that, after `identifier_filter` passes, the flow is:
|
||||
|
||||
1. Resolve the current IdP instance from `idp.uid`.
|
||||
2. Query `user_identity` by `(provider = idp.uid, extern_uid = userInfo.Identifier)`.
|
||||
3. On hit, load the local user by `user_id`.
|
||||
4. On miss, apply the existing registration gate, derive a valid local username from display-oriented fields, and execute local user creation plus `user_identity` insertion in a single transaction.
|
||||
5. If the `user_identity` insert loses a race on the unique `(provider, extern_uid)` key, discard the provisional linkage result, re-read `user_identity`, and continue sign-in using the winning row's `user_id`.
|
||||
|
||||
This directly addresses the current runtime coupling in `server/router/api/v1/auth_service.go:135-169` and satisfies G1 and G2. `User.Username` becomes a local account attribute again instead of the SSO subject store, and the miss path becomes idempotent under concurrent first sign-ins for the same external identity.
|
||||
|
||||
### 4. Keep username derivation separate from external identity persistence
|
||||
|
||||
The initial local username for new SSO-created users should be derived from user-facing profile data rather than copied from `extern_uid`:
|
||||
|
||||
- first choice: `DisplayName`
|
||||
- fallback: `Email`
|
||||
- fallback: `Identifier`
|
||||
|
||||
Normalization and collision handling remain local username concerns, not identity-link concerns. This keeps the `user_identity` table focused on lookup semantics (G1, G5) and avoids implying that external identifiers must resemble usernames.
|
||||
|
||||
### 5. Versioned migrations across all three backends
|
||||
|
||||
Add a new migration version after `0.27` for SQLite, PostgreSQL, and MySQL. Update `LATEST.sql` for each backend to include the `user_identity` table and its `user_id` index.
|
||||
|
||||
The migrations should preserve the same logical fields across backends:
|
||||
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `provider`
|
||||
- `extern_uid`
|
||||
- `created_ts`
|
||||
- `updated_ts`
|
||||
- unique key on `(provider, extern_uid)`
|
||||
- secondary index on `user_id`
|
||||
|
||||
The implementation derived from these migrations should treat `(provider, extern_uid)` as both the lookup key and the concurrency guard for first login. A uniqueness conflict on that pair is a recoverable race outcome, not an unrecoverable error path.
|
||||
|
||||
### 6. Document the pattern families explicitly in the issue docs
|
||||
|
||||
Keep the issue docs explicit that memos is selecting one member of a broader family of established designs:
|
||||
|
||||
- GitLab / Gitea / Keycloak / Discourse: separate identity record
|
||||
- Immich / Mattermost: user-row external-auth fields
|
||||
|
||||
That distinction matters because the design choice is not "copy GitLab exactly"; it is "use the multi-provider separate-identity family, keep memos-compatible table naming, and adopt upstream-aligned linkage fields where they improve recognizability" (G4).
|
||||
@@ -0,0 +1,105 @@
|
||||
## Execution Log
|
||||
|
||||
### T1: Add `user_identity` migrations + LATEST.sql updates
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- Created: `store/migration/sqlite/0.28/00__user_identity.sql`
|
||||
- Created: `store/migration/postgres/0.28/00__user_identity.sql`
|
||||
- Created: `store/migration/mysql/0.28/00__user_identity.sql`
|
||||
- Modified: `store/migration/sqlite/LATEST.sql`
|
||||
- Modified: `store/migration/postgres/LATEST.sql`
|
||||
- Modified: `store/migration/mysql/LATEST.sql`
|
||||
**Validation**:
|
||||
- `rg 'CREATE TABLE \`?user_identity\`?' store/migration` — PASS (hits in all 6 expected files).
|
||||
- `rg 'UNIQUE \(\`?provider\`?, \`?extern_uid\`?\)' store/migration` — PASS (6 hits).
|
||||
- `go build ./...` — PASS.
|
||||
**Path Corrections**: None.
|
||||
**Deviations**: None.
|
||||
|
||||
### T2: Add `store.UserIdentity` model, `Store` methods, and driver interface
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- Created: `store/user_identity.go`
|
||||
- Modified: `store/driver.go`
|
||||
**Validation**:
|
||||
- Interface-only build is expected to fail until T3–T5; deferred compile check to T5.
|
||||
- `rg 'CreateUserIdentity|ListUserIdentities' store/driver.go store/user_identity.go` — PASS (method declarations present in both files).
|
||||
**Path Corrections**: None.
|
||||
**Deviations**: None.
|
||||
|
||||
### T3: Implement SQLite driver for `user_identity`
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- Created: `store/db/sqlite/user_identity.go`
|
||||
**Validation**:
|
||||
- `go build ./store/db/sqlite/...` — PASS.
|
||||
**Path Corrections**: None.
|
||||
**Deviations**: None.
|
||||
|
||||
### T4: Implement Postgres driver for `user_identity`
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- Created: `store/db/postgres/user_identity.go`
|
||||
**Validation**:
|
||||
- `go build ./store/db/postgres/...` — PASS.
|
||||
**Path Corrections**: None.
|
||||
**Deviations**: None.
|
||||
|
||||
### T5: Implement MySQL driver for `user_identity`
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- Created: `store/db/mysql/user_identity.go`
|
||||
**Validation**:
|
||||
- `go build ./...` — PASS (whole repo compiles; all drivers satisfy the `Driver` interface).
|
||||
**Path Corrections**: None.
|
||||
**Deviations**: None.
|
||||
|
||||
### T6: Add store-layer tests for `user_identity`
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- Created: `store/test/user_identity_test.go`
|
||||
**Validation**:
|
||||
- `DRIVER=sqlite go test ./store/test/ -run TestUserIdentity -count=1 -v` — PASS:
|
||||
- `TestUserIdentityCreateAndGet` — PASS
|
||||
- `TestUserIdentityListByUserID` — PASS
|
||||
- `TestUserIdentityUniqueConflict` — PASS
|
||||
- `TestUserIdentitySameExternUIDDifferentProviders` — PASS
|
||||
**Path Corrections**: None.
|
||||
**Deviations**: None.
|
||||
|
||||
### T7: Add SSO username derivation helper
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- Created: `server/router/api/v1/sso_username.go`
|
||||
**Validation**:
|
||||
- `go build ./server/router/api/v1/...` — PASS.
|
||||
- `go vet ./server/router/api/v1/...` — PASS.
|
||||
**Path Corrections**: None.
|
||||
**Deviations**: None.
|
||||
|
||||
### T8: Route SSO sign-in through `user_identity` linkage
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**:
|
||||
- Modified: `server/router/api/v1/auth_service.go`
|
||||
- `SignIn` SSO branch now delegates user resolution to a new `resolveSSOUser` method.
|
||||
- `resolveSSOUser` does: `user_identity` lookup → hit path (load user by linked `user_id`); miss path (registration gate → `deriveSSOUsername` → create user → create linkage → race recovery on unique(provider, extern_uid)).
|
||||
- Added `isUserIdentityUniqueViolation` helper (string match on the three backends' unique-constraint error strings, matching the pattern in `memo_service.go:103–105`).
|
||||
**Validation**:
|
||||
- `go build ./...` — PASS.
|
||||
- `go vet ./...` — PASS.
|
||||
- `DRIVER=sqlite go test ./store/test/ -run TestUserIdentity -count=1` — PASS (regression check).
|
||||
**Path Corrections**:
|
||||
- The plan pseudocode referenced `identityProvider.UID`; the actual protobuf type `storepb.IdentityProvider` exposes the field as `Uid`. Used `identityProvider.Uid` in the implementation. No semantic deviation.
|
||||
**Deviations**: None.
|
||||
|
||||
## Completion Declaration
|
||||
|
||||
**All tasks completed successfully.**
|
||||
@@ -0,0 +1,328 @@
|
||||
## Task List
|
||||
|
||||
**Task Index**
|
||||
|
||||
> T1: Add `user_identity` migrations + LATEST.sql updates for all three backends [M] — T2: Add `store.UserIdentity` model and `Store` methods + driver interface [M] — T3: Implement SQLite driver for `user_identity` [M] — T4: Implement Postgres driver for `user_identity` [M] — T5: Implement MySQL driver for `user_identity` [M] — T6: Add store-layer tests for `user_identity` [M] — T7: Add SSO username derivation helper [M] — T8: Route SSO sign-in through `user_identity` linkage [L]
|
||||
|
||||
### T1: Add `user_identity` migrations + LATEST.sql updates [M]
|
||||
|
||||
**Objective**: Create the `user_identity` persistence structure across SQLite, Postgres, and MySQL, and reflect it in `LATEST.sql` for fresh installs (G1, G2, G3, G4, G5; design §1, §5).
|
||||
|
||||
**Size**: M (3 new migration files, 3 LATEST.sql edits; straightforward DDL).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/migration/sqlite/0.28/00__user_identity.sql`
|
||||
- Create: `store/migration/postgres/0.28/00__user_identity.sql`
|
||||
- Create: `store/migration/mysql/0.28/00__user_identity.sql`
|
||||
- Modify: `store/migration/sqlite/LATEST.sql`
|
||||
- Modify: `store/migration/postgres/LATEST.sql`
|
||||
- Modify: `store/migration/mysql/LATEST.sql`
|
||||
|
||||
**Implementation**:
|
||||
1. `store/migration/sqlite/0.28/00__user_identity.sql`:
|
||||
```sql
|
||||
CREATE TABLE user_identity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
extern_uid TEXT NOT NULL,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
UNIQUE (provider, extern_uid)
|
||||
);
|
||||
CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);
|
||||
```
|
||||
2. `store/migration/postgres/0.28/00__user_identity.sql`: same logical schema with Postgres types — `id SERIAL PRIMARY KEY`, `user_id INTEGER NOT NULL`, `provider TEXT NOT NULL`, `extern_uid TEXT NOT NULL`, `created_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())`, `updated_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())`, `UNIQUE(provider, extern_uid)`, plus `CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);`. Include a 2-line header comment describing the table purpose (pattern-match `04__memo_share.sql`).
|
||||
3. `store/migration/mysql/0.28/00__user_identity.sql`: same logical schema with MySQL syntax — backticked identifiers, `INT NOT NULL AUTO_INCREMENT PRIMARY KEY`, `VARCHAR(256)` for `provider`, `VARCHAR(256)` for `extern_uid` (so unique key fits within index limits), `BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP())` for timestamps, `UNIQUE(provider, extern_uid)`, plus `CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);`.
|
||||
4. Append a `-- user_identity` section to each `LATEST.sql` mirroring the corresponding migration file (schema only, same indentation style used by neighboring tables in that file).
|
||||
|
||||
**Boundaries**: Must NOT alter the `user` or `idp` tables; must NOT add FK from `user_identity.provider` to `idp.uid`; must NOT add columns beyond `id`, `user_id`, `provider`, `extern_uid`, `created_ts`, `updated_ts`.
|
||||
|
||||
**Dependencies**: None.
|
||||
|
||||
**Expected Outcome**: New migration files exist; `LATEST.sql` for each backend contains a `user_identity` table block and its `user_id` index.
|
||||
|
||||
**Validation**:
|
||||
- `rg -n "CREATE TABLE user_identity" store/migration` — expects one hit per backend in both the 0.28 migration and `LATEST.sql` (6 hits total).
|
||||
- `rg -n "UNIQUE ?\\(provider, extern_uid\\)" store/migration` — expects 6 hits total.
|
||||
- `go build ./...` — expects PASS (no code changes affect the build; confirms no stray syntax issues).
|
||||
|
||||
---
|
||||
|
||||
### T2: Add `store.UserIdentity` model, `Store` methods, and driver interface [M]
|
||||
|
||||
**Objective**: Provide a Go-level abstraction for the `user_identity` record with create/read operations wired through `store.Driver` (design §2, G3, G5).
|
||||
|
||||
**Size**: M (one new store file, one interface edit; simple CRUD-shaped code).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/user_identity.go`
|
||||
- Modify: `store/driver.go`
|
||||
|
||||
**Implementation**:
|
||||
1. `store/user_identity.go`:
|
||||
- Types:
|
||||
```go
|
||||
type UserIdentity struct {
|
||||
ID int32
|
||||
UserID int32
|
||||
Provider string
|
||||
ExternUID string
|
||||
CreatedTs int64
|
||||
UpdatedTs int64
|
||||
}
|
||||
|
||||
type FindUserIdentity struct {
|
||||
ID *int32
|
||||
UserID *int32
|
||||
Provider *string
|
||||
ExternUID *string
|
||||
}
|
||||
```
|
||||
- Store methods (thin passthroughs to driver):
|
||||
```go
|
||||
func (s *Store) CreateUserIdentity(ctx context.Context, create *UserIdentity) (*UserIdentity, error)
|
||||
func (s *Store) ListUserIdentities(ctx context.Context, find *FindUserIdentity) ([]*UserIdentity, error)
|
||||
func (s *Store) GetUserIdentity(ctx context.Context, find *FindUserIdentity) (*UserIdentity, error) // returns (nil, nil) on no match
|
||||
```
|
||||
- No update/delete methods in this issue (design §2: create/read only).
|
||||
2. `store/driver.go`: extend the `Driver` interface with:
|
||||
```go
|
||||
// UserIdentity model related methods.
|
||||
CreateUserIdentity(ctx context.Context, create *UserIdentity) (*UserIdentity, error)
|
||||
ListUserIdentities(ctx context.Context, find *FindUserIdentity) ([]*UserIdentity, error)
|
||||
```
|
||||
`GetUserIdentity` in `store` can be implemented locally by calling `ListUserIdentities` with `Limit`-free semantics and returning the first row, matching the `GetMemoShare`/`GetIdentityProvider` pattern (no new driver method required for "get").
|
||||
|
||||
**Boundaries**: Must NOT add fields to `store.User` or `store.UpdateUser`; must NOT add update/delete methods.
|
||||
|
||||
**Dependencies**: None (T3–T5 will satisfy the new interface methods).
|
||||
|
||||
**Expected Outcome**: `store.UserIdentity`, `FindUserIdentity`, and three `Store` methods exist; `Driver` interface declares the two new methods.
|
||||
|
||||
**Validation**:
|
||||
- `go build ./store/...` — expects FAIL until T3–T5 implement the interface on each driver. Record as expected; final pass comes at end of T5.
|
||||
- `rg -n "CreateUserIdentity|ListUserIdentities" store/driver.go store/user_identity.go` — expects method declarations in both files.
|
||||
|
||||
---
|
||||
|
||||
### T3: Implement SQLite driver for `user_identity` [M]
|
||||
|
||||
**Objective**: Implement `CreateUserIdentity` and `ListUserIdentities` for SQLite so the interface declared in T2 is satisfied (design §2).
|
||||
|
||||
**Size**: M (one new driver file; mirrors existing `memo_share.go` patterns).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/db/sqlite/user_identity.go`
|
||||
|
||||
**Implementation**:
|
||||
1. `CreateUserIdentity`:
|
||||
- Insert columns `user_id`, `provider`, `extern_uid` using `?` placeholders.
|
||||
- Use `RETURNING id, created_ts, updated_ts` to populate generated fields, same pattern as `store/db/sqlite/memo_share.go:24`.
|
||||
- Return the passed-in `create` struct with generated fields populated, or the error from `QueryRowContext(...).Scan(...)` (unique-constraint violation surfaces to caller unchanged).
|
||||
2. `ListUserIdentities`:
|
||||
- `where := []string{"1 = 1"}`; append clauses for `find.ID`, `find.UserID`, `find.Provider`, `find.ExternUID` when non-nil.
|
||||
- `SELECT id, user_id, provider, extern_uid, created_ts, updated_ts FROM user_identity WHERE ... ORDER BY id ASC`.
|
||||
- Scan rows into `[]*store.UserIdentity`; return `[]*store.UserIdentity{}` on no rows (not nil).
|
||||
|
||||
**Boundaries**: Must NOT introduce transaction helpers, upsert semantics, or extra scan columns.
|
||||
|
||||
**Dependencies**: T2.
|
||||
|
||||
**Expected Outcome**: SQLite driver compiles and returns populated rows.
|
||||
|
||||
**Validation**:
|
||||
- `go build ./store/db/sqlite/...` — expects PASS.
|
||||
|
||||
---
|
||||
|
||||
### T4: Implement Postgres driver for `user_identity` [M]
|
||||
|
||||
**Objective**: Mirror T3 for Postgres using `$N` placeholders and `SERIAL` semantics (design §2).
|
||||
|
||||
**Size**: M (one new driver file; mirrors `store/db/postgres/memo_share.go`).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/db/postgres/user_identity.go`
|
||||
|
||||
**Implementation**:
|
||||
- Same shape as T3, but:
|
||||
- Use `placeholder(n)` / `placeholders(n)` helpers from `store/db/postgres/common.go`.
|
||||
- Insert stmt `INSERT INTO user_identity (user_id, provider, extern_uid) VALUES (...) RETURNING id, created_ts, updated_ts`.
|
||||
- List query identical SQL shape to SQLite (no backticks in Postgres; match `memo_share.go` style).
|
||||
|
||||
**Boundaries**: Same as T3.
|
||||
|
||||
**Dependencies**: T2.
|
||||
|
||||
**Expected Outcome**: Postgres driver compiles.
|
||||
|
||||
**Validation**:
|
||||
- `go build ./store/db/postgres/...` — expects PASS.
|
||||
|
||||
---
|
||||
|
||||
### T5: Implement MySQL driver for `user_identity` [M]
|
||||
|
||||
**Objective**: Mirror T3/T4 for MySQL, using `LastInsertId()` + re-read pattern (MySQL's driver does not support `RETURNING`; design §2).
|
||||
|
||||
**Size**: M (one new driver file; mirrors `store/db/mysql/memo_share.go`).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/db/mysql/user_identity.go`
|
||||
|
||||
**Implementation**:
|
||||
- `CreateUserIdentity`:
|
||||
- `INSERT INTO user_identity (user_id, provider, extern_uid) VALUES (?, ?, ?)` via `ExecContext`.
|
||||
- Get `LastInsertId()`, re-fetch via `GetUserIdentity(... ID: &id)` helper (internal unexported `listUserIdentitiesByID` or reuse `ListUserIdentities` with `FindUserIdentity{ID: &id}` + take first result).
|
||||
- Mirror `memo_share.go` error-handling style (return `errors.Errorf("failed to create user identity")` when re-fetch returns nil, like memo_share does).
|
||||
- `ListUserIdentities`:
|
||||
- Same shape as T3, using backticked column names (`` `user_id` ``, `` `provider` ``, `` `extern_uid` ``) and `?` placeholders, matching the MySQL idiom used in `memo_share.go`.
|
||||
|
||||
**Boundaries**: Same as T3.
|
||||
|
||||
**Dependencies**: T2.
|
||||
|
||||
**Expected Outcome**: MySQL driver compiles; full repo builds.
|
||||
|
||||
**Validation**:
|
||||
- `go build ./...` — expects PASS (entire repo compiles with all drivers satisfying the `Driver` interface introduced in T2).
|
||||
|
||||
---
|
||||
|
||||
### T6: Add store-layer tests for `user_identity` [M]
|
||||
|
||||
**Objective**: Exercise create + read paths plus the `(provider, extern_uid)` uniqueness guard across the active driver (G2).
|
||||
|
||||
**Size**: M (one new test file; patterns match existing store tests).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/test/user_identity_test.go`
|
||||
|
||||
**Implementation**:
|
||||
1. `TestUserIdentityCreateAndGet`:
|
||||
- Create host user via `createTestingHostUser`.
|
||||
- `CreateUserIdentity` with `UserID=user.ID`, `Provider="idp-uid-1"`, `ExternUID="jane@example.com"`.
|
||||
- `GetUserIdentity` by `(Provider, ExternUID)` — assert match on `UserID`, `Provider`, `ExternUID`, non-zero `ID`, non-zero `CreatedTs`.
|
||||
2. `TestUserIdentityListByUserID`:
|
||||
- Create two identities under the same `UserID` with two different `Provider` values.
|
||||
- `ListUserIdentities` by `UserID` — assert length 2.
|
||||
3. `TestUserIdentityUniqueConflict`:
|
||||
- Insert one row with `(Provider="idp-A", ExternUID="sub-1")`.
|
||||
- Insert a second row with identical `(Provider, ExternUID)` for a different `UserID`.
|
||||
- Assert the second `CreateUserIdentity` returns a non-nil error (detection via `err != nil`; do not assert message since error strings differ per backend).
|
||||
4. `TestUserIdentitySameExternUIDDifferentProviders`:
|
||||
- Insert `(Provider="idp-A", ExternUID="sub-1")` and `(Provider="idp-B", ExternUID="sub-1")` under the same or different users.
|
||||
- Assert both inserts succeed (G2: uniqueness is scoped to the pair, not `extern_uid` alone).
|
||||
|
||||
**Boundaries**: Must NOT test SSO sign-in or auth service behavior; must NOT test migration contents beyond what `NewTestingStore` already executes.
|
||||
|
||||
**Dependencies**: T1–T5.
|
||||
|
||||
**Expected Outcome**: All four tests pass against SQLite.
|
||||
|
||||
**Validation**:
|
||||
- `go test ./store/test/ -run TestUserIdentity -count=1` — expects all 4 tests PASS.
|
||||
|
||||
---
|
||||
|
||||
### T7: Add SSO username derivation helper [M]
|
||||
|
||||
**Objective**: Produce a valid `User.Username` for new SSO-created users from profile fields, independent of `extern_uid` (design §4).
|
||||
|
||||
**Size**: M (one new file with helper + small unit test; self-contained logic).
|
||||
|
||||
**Files**:
|
||||
- Create: `server/router/api/v1/sso_username.go`
|
||||
|
||||
**Implementation**:
|
||||
1. `deriveSSOUsername(ctx context.Context, stores *store.Store, userInfo *idp.IdentityProviderUserInfo) (string, error)`:
|
||||
- Build ordered candidate list: `[userInfo.DisplayName, userInfo.Email, userInfo.Identifier]`, skipping empty values.
|
||||
- For each candidate:
|
||||
1. `base := normalizeToUsername(candidate)`
|
||||
2. If `validateUsername(base) == nil`:
|
||||
- If no existing user with `Username=base` (via `stores.GetUser(&FindUser{Username: &base})`), return `base`.
|
||||
- Else: try up to N=8 suffix retries `base + "-" + randomSuffix(6)`, where the trimmed base ensures total length ≤ 36. If a candidate passes `validateUsername` and is unique, return it.
|
||||
3. If all candidates are exhausted: fall back to a purely random username `"user-" + randomSuffix(10)` validated via `validateUsername`; retry up to 5 times before returning an error.
|
||||
4. `normalizeToUsername(s string) string`:
|
||||
- ASCII-fold / lowercase.
|
||||
- Replace every character not in `[a-zA-Z0-9]` with `-`.
|
||||
- Collapse consecutive `-` into one `-`.
|
||||
- Trim leading/trailing `-`.
|
||||
- Truncate to 36 chars, then re-trim trailing `-` so the string still ends in alphanumeric.
|
||||
- Return `""` if the result is empty or fully numeric (so the caller falls through to the next candidate).
|
||||
5. Use `internal/util.RandomString` for the random suffix (already imported by `auth_service.go`).
|
||||
|
||||
**Boundaries**: Must NOT modify `validateUsername` or `base.UIDMatcher`; must NOT write to `user_identity` or `user` directly; must NOT call `CreateUser`.
|
||||
|
||||
**Dependencies**: None.
|
||||
|
||||
**Expected Outcome**: New file `server/router/api/v1/sso_username.go` containing the exported-for-package helper `deriveSSOUsername` and internal `normalizeToUsername`.
|
||||
|
||||
**Validation**:
|
||||
- `go build ./server/router/api/v1/...` — expects PASS.
|
||||
- `go vet ./server/router/api/v1/...` — expects PASS.
|
||||
|
||||
---
|
||||
|
||||
### T8: Route SSO sign-in through `user_identity` linkage [L]
|
||||
|
||||
**Objective**: Replace the `FindUser{Username: &userInfo.Identifier}` lookup and `Username: userInfo.Identifier` user creation with `user_identity`-backed lookup and derived-username user creation, satisfying G1 and G2 end-to-end (design §3).
|
||||
|
||||
**Size**: L (non-trivial branching logic: lookup, miss path, registration gate, race recovery).
|
||||
|
||||
**Files**:
|
||||
- Modify: `server/router/api/v1/auth_service.go`
|
||||
|
||||
**Implementation** (in `SignIn`, SSO branch, replacing current lines ~124–173):
|
||||
|
||||
1. After `identifier_filter` check succeeds (existing `lines 124-133` unchanged), resolve the linkage:
|
||||
```go
|
||||
provider := identityProvider.Uid
|
||||
externUID := userInfo.Identifier
|
||||
existingIdentity, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
|
||||
Provider: &provider,
|
||||
ExternUID: &externUID,
|
||||
})
|
||||
// error handling → codes.Internal
|
||||
```
|
||||
2. **Hit path**: if `existingIdentity != nil`, load `s.Store.GetUser(ctx, &store.FindUser{ID: &existingIdentity.UserID})`; set `existingUser`; skip creation.
|
||||
3. **Miss path**: gate on `instanceGeneralSetting.DisallowUserRegistration` (reuse existing flow at current lines 143–149), then:
|
||||
1. `username, err := deriveSSOUsername(ctx, s.Store, userInfo)` — from T7. `codes.Internal` on error.
|
||||
2. Generate random password + bcrypt hash (unchanged from current lines 160–168).
|
||||
3. `user, err := s.Store.CreateUser(ctx, &store.User{Username: username, Role: store.RoleUser, Nickname: userInfo.DisplayName, Email: userInfo.Email, AvatarURL: userInfo.AvatarURL, PasswordHash: string(passwordHash)})`.
|
||||
4. `_, err := s.Store.CreateUserIdentity(ctx, &store.UserIdentity{UserID: user.ID, Provider: provider, ExternUID: externUID})`.
|
||||
5. **Race recovery**: if `CreateUserIdentity` returns an error whose message matches one of the known unique-constraint markers (`strings.Contains(err.Error(), "UNIQUE constraint failed")`, `"duplicate key"`, `"Duplicate entry")` — reusing the same pattern as `server/router/api/v1/memo_service.go:103–105`):
|
||||
- `_ = s.Store.DeleteUser(ctx, &store.DeleteUser{ID: user.ID})` (best-effort cleanup of the provisional local user).
|
||||
- Re-read the winning `user_identity` via `s.Store.GetUserIdentity(ctx, &FindUserIdentity{Provider: &provider, ExternUID: &externUID})`; if still nil, return `codes.Internal` (should not happen under correct semantics).
|
||||
- Load its user via `s.Store.GetUser(ctx, &FindUser{ID: &winner.UserID})`; set `existingUser`.
|
||||
6. On any other `CreateUserIdentity` error: best-effort `DeleteUser` cleanup, then return `codes.Internal`.
|
||||
7. On full success: set `existingUser = user`.
|
||||
|
||||
4. Leave the remainder of `SignIn` (row-status check, `doSignIn`, response construction) untouched.
|
||||
|
||||
**Boundaries**: Must NOT touch the password-credentials branch; must NOT modify `identifier_filter` logic; must NOT touch `doSignIn`, `SignOut`, or `RefreshToken`; must NOT add new fields to `SignInRequest`/`SignInResponse`.
|
||||
|
||||
**Dependencies**: T2, T3, T6 minimum for SQLite confidence; T7 for the derivation helper.
|
||||
|
||||
**Expected Outcome**:
|
||||
- Sign-in with an IdP-issued identifier that fails `base.UIDMatcher` (e.g., `jane@example.com`) succeeds: a `user_identity` row is created, and the local `User.Username` is a derived valid username.
|
||||
- Repeat sign-in for the same `(provider, extern_uid)` pair loads the same user by linkage, not by username.
|
||||
- Two IdPs emitting the same `extern_uid` can each link to their own local users without colliding (G2).
|
||||
|
||||
**Validation**:
|
||||
- `go build ./...` — expects PASS.
|
||||
- `go vet ./...` — expects PASS.
|
||||
- `go test ./store/test/ -run TestUserIdentity -count=1` — expects PASS (T6 regression check; ensures no store-layer drift).
|
||||
|
||||
## Out-of-Scope Tasks
|
||||
|
||||
The following are explicitly deferred per `definition.md` / `design.md` and will NOT be attempted during this execution:
|
||||
|
||||
- UI or API surfaces for linking/unlinking external identities.
|
||||
- Update or delete paths for `user_identity` rows.
|
||||
- Backfill / migration of existing users whose current `Username` matches an IdP identifier.
|
||||
- Non-OAUTH2 IdP types.
|
||||
- Protobuf or API changes to `SignInRequest`/`SignInResponse`.
|
||||
- Adding foreign keys between `user_identity.provider` and `idp.uid`.
|
||||
- Running PostgreSQL or MySQL integration tests locally (validation commands only cover SQLite, which is the default `DRIVER` in `store/test/store.go`).
|
||||
Reference in New Issue
Block a user