mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +08:00
feat(shares)!: make landing shares public by default
Replace opt-in profile listings with an opt-out private flag and a unified privacy endpoint. BREAKING CHANGE: showOnProfile, listedAt, and the profile-listing endpoints are replaced by private and PUT /api/shares/:token/privacy.
This commit is contained in:
@@ -46,7 +46,7 @@ The product boundary is intentional: ZPan is a purpose-built S3-backed web drive
|
||||
- **S3 web drive** — Manage files, folders, previews, trash, quotas, and team workspaces on top of your own object storage
|
||||
- **Image hosting** — Upload via PicGo, PicList, uPic, ShareX, Flameshot, or API and get a stable URL instantly
|
||||
- **File sharing** — Publish share links with password, expiration, download limits, direct links, and save-to-drive flows
|
||||
- **Personal homepage** — Give each user a public `/u/username` page for curated shared files and folder-style browsing
|
||||
- **Personal homepage** — Give each user a public `/u/username` page for public shared files and folder-style browsing
|
||||
- **External access** — Mount files through WebDAV and run downloader workers for remote-download workflows
|
||||
|
||||
## Why ZPan?
|
||||
|
||||
@@ -1891,7 +1891,7 @@ type CreatedShare struct {
|
||||
DownloadLimit *int `json:"downloadLimit"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
Kind string `json:"kind"`
|
||||
ListedAt *string `json:"listedAt"`
|
||||
Private bool `json:"private"`
|
||||
Token string `json:"token"`
|
||||
Urls struct {
|
||||
Direct *string `json:"direct,omitempty"`
|
||||
@@ -2577,7 +2577,6 @@ type ShareListItem struct {
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
Id string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
ListedAt *string `json:"listedAt"`
|
||||
Matter struct {
|
||||
Dirtype int `json:"dirtype"`
|
||||
Name string `json:"name"`
|
||||
@@ -2585,6 +2584,7 @@ type ShareListItem struct {
|
||||
} `json:"matter"`
|
||||
MatterId string `json:"matterId"`
|
||||
OrgId string `json:"orgId"`
|
||||
Private bool `json:"private"`
|
||||
RecipientCount int `json:"recipientCount"`
|
||||
Status string `json:"status"`
|
||||
Token string `json:"token"`
|
||||
@@ -2609,9 +2609,9 @@ type ShareObjects struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ShareProfileListing defines model for ShareProfileListing.
|
||||
type ShareProfileListing struct {
|
||||
ListedAt *string `json:"listedAt"`
|
||||
// SharePrivacy defines model for SharePrivacy.
|
||||
type SharePrivacy struct {
|
||||
Private bool `json:"private"`
|
||||
}
|
||||
|
||||
// ShareView defines model for ShareView.
|
||||
@@ -4058,11 +4058,11 @@ type CreateShareJSONBody struct {
|
||||
Kind CreateShareJSONBodyKind `json:"kind"`
|
||||
MatterId string `json:"matterId"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
Private *bool `json:"private,omitempty"`
|
||||
Recipients *[]struct {
|
||||
RecipientEmail *openapi_types.Email `json:"recipientEmail,omitempty"`
|
||||
RecipientUserId *string `json:"recipientUserId,omitempty"`
|
||||
} `json:"recipients,omitempty"`
|
||||
ShowOnProfile *bool `json:"showOnProfile,omitempty"`
|
||||
}
|
||||
|
||||
// CreateShareJSONBodyKind defines parameters for CreateShare.
|
||||
@@ -4672,6 +4672,9 @@ type CreateShareJSONRequestBody CreateShareJSONBody
|
||||
// SaveShareJSONRequestBody defines body for SaveShare for application/json ContentType.
|
||||
type SaveShareJSONRequestBody SaveShareJSONBody
|
||||
|
||||
// PutSharePrivacyJSONRequestBody defines body for PutSharePrivacy for application/json ContentType.
|
||||
type PutSharePrivacyJSONRequestBody = SharePrivacy
|
||||
|
||||
// VerifySharePasswordJSONRequestBody defines body for VerifySharePassword for application/json ContentType.
|
||||
type VerifySharePasswordJSONRequestBody VerifySharePasswordJSONBody
|
||||
|
||||
@@ -5602,11 +5605,10 @@ type ClientInterface interface {
|
||||
|
||||
SaveShare(ctx context.Context, token string, body SaveShareJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// DeleteShareProfileListing request
|
||||
DeleteShareProfileListing(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
// PutSharePrivacyWithBody request with any body
|
||||
PutSharePrivacyWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// PutShareProfileListing request
|
||||
PutShareProfileListing(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
PutSharePrivacy(ctx context.Context, token string, body PutSharePrivacyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// VerifySharePasswordWithBody request with any body
|
||||
VerifySharePasswordWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
@@ -8456,8 +8458,8 @@ func (c *Client) SaveShare(ctx context.Context, token string, body SaveShareJSON
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteShareProfileListing(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewDeleteShareProfileListingRequest(c.Server, token)
|
||||
func (c *Client) PutSharePrivacyWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewPutSharePrivacyRequestWithBody(c.Server, token, contentType, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -8468,8 +8470,8 @@ func (c *Client) DeleteShareProfileListing(ctx context.Context, token string, re
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) PutShareProfileListing(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewPutShareProfileListingRequest(c.Server, token)
|
||||
func (c *Client) PutSharePrivacy(ctx context.Context, token string, body PutSharePrivacyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewPutSharePrivacyRequest(c.Server, token, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -15733,42 +15735,19 @@ func NewSaveShareRequestWithBody(server string, token string, contentType string
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewDeleteShareProfileListingRequest generates requests for DeleteShareProfileListing
|
||||
func NewDeleteShareProfileListingRequest(server string, token string) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
var pathParam0 string
|
||||
|
||||
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "token", token, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
|
||||
// NewPutSharePrivacyRequest calls the generic PutSharePrivacy builder with application/json body
|
||||
func NewPutSharePrivacyRequest(server string, token string, body PutSharePrivacyJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/api/shares/%s/profile-listing", pathParam0)
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
return NewPutSharePrivacyRequestWithBody(server, token, "application/json", bodyReader)
|
||||
}
|
||||
|
||||
// NewPutShareProfileListingRequest generates requests for PutShareProfileListing
|
||||
func NewPutShareProfileListingRequest(server string, token string) (*http.Request, error) {
|
||||
// NewPutSharePrivacyRequestWithBody generates requests for PutSharePrivacy with any type of body
|
||||
func NewPutSharePrivacyRequestWithBody(server string, token string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
var pathParam0 string
|
||||
@@ -15783,7 +15762,7 @@ func NewPutShareProfileListingRequest(server string, token string) (*http.Reques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/api/shares/%s/profile-listing", pathParam0)
|
||||
operationPath := fmt.Sprintf("/api/shares/%s/privacy", pathParam0)
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
@@ -15793,11 +15772,13 @@ func NewPutShareProfileListingRequest(server string, token string) (*http.Reques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut, queryURL.String(), nil)
|
||||
req, err := http.NewRequest(http.MethodPut, queryURL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
@@ -19866,11 +19847,10 @@ type ClientWithResponsesInterface interface {
|
||||
|
||||
SaveShareWithResponse(ctx context.Context, token string, body SaveShareJSONRequestBody, reqEditors ...RequestEditorFn) (*SaveShareResponse, error)
|
||||
|
||||
// DeleteShareProfileListingWithResponse request
|
||||
DeleteShareProfileListingWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*DeleteShareProfileListingResponse, error)
|
||||
// PutSharePrivacyWithBodyWithResponse request with any body
|
||||
PutSharePrivacyWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutSharePrivacyResponse, error)
|
||||
|
||||
// PutShareProfileListingWithResponse request
|
||||
PutShareProfileListingWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*PutShareProfileListingResponse, error)
|
||||
PutSharePrivacyWithResponse(ctx context.Context, token string, body PutSharePrivacyJSONRequestBody, reqEditors ...RequestEditorFn) (*PutSharePrivacyResponse, error)
|
||||
|
||||
// VerifySharePasswordWithBodyWithResponse request with any body
|
||||
VerifySharePasswordWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*VerifySharePasswordResponse, error)
|
||||
@@ -26391,16 +26371,17 @@ func (r SaveShareResponse) ContentType() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type DeleteShareProfileListingResponse struct {
|
||||
type PutSharePrivacyResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *SharePrivacy
|
||||
JSON400 *Error
|
||||
JSON403 *Error
|
||||
JSON404 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r DeleteShareProfileListingResponse) Status() string {
|
||||
func (r PutSharePrivacyResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
@@ -26408,7 +26389,7 @@ func (r DeleteShareProfileListingResponse) Status() string {
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r DeleteShareProfileListingResponse) StatusCode() int {
|
||||
func (r PutSharePrivacyResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
@@ -26416,40 +26397,7 @@ func (r DeleteShareProfileListingResponse) StatusCode() int {
|
||||
}
|
||||
|
||||
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
|
||||
func (r DeleteShareProfileListingResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type PutShareProfileListingResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *ShareProfileListing
|
||||
JSON400 *Error
|
||||
JSON403 *Error
|
||||
JSON404 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r PutShareProfileListingResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r PutShareProfileListingResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
|
||||
func (r PutShareProfileListingResponse) ContentType() string {
|
||||
func (r PutSharePrivacyResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
@@ -30974,22 +30922,21 @@ func (c *ClientWithResponses) SaveShareWithResponse(ctx context.Context, token s
|
||||
return ParseSaveShareResponse(rsp)
|
||||
}
|
||||
|
||||
// DeleteShareProfileListingWithResponse request returning *DeleteShareProfileListingResponse
|
||||
func (c *ClientWithResponses) DeleteShareProfileListingWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*DeleteShareProfileListingResponse, error) {
|
||||
rsp, err := c.DeleteShareProfileListing(ctx, token, reqEditors...)
|
||||
// PutSharePrivacyWithBodyWithResponse request with arbitrary body returning *PutSharePrivacyResponse
|
||||
func (c *ClientWithResponses) PutSharePrivacyWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutSharePrivacyResponse, error) {
|
||||
rsp, err := c.PutSharePrivacyWithBody(ctx, token, contentType, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseDeleteShareProfileListingResponse(rsp)
|
||||
return ParsePutSharePrivacyResponse(rsp)
|
||||
}
|
||||
|
||||
// PutShareProfileListingWithResponse request returning *PutShareProfileListingResponse
|
||||
func (c *ClientWithResponses) PutShareProfileListingWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*PutShareProfileListingResponse, error) {
|
||||
rsp, err := c.PutShareProfileListing(ctx, token, reqEditors...)
|
||||
func (c *ClientWithResponses) PutSharePrivacyWithResponse(ctx context.Context, token string, body PutSharePrivacyJSONRequestBody, reqEditors ...RequestEditorFn) (*PutSharePrivacyResponse, error) {
|
||||
rsp, err := c.PutSharePrivacy(ctx, token, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParsePutShareProfileListingResponse(rsp)
|
||||
return ParsePutSharePrivacyResponse(rsp)
|
||||
}
|
||||
|
||||
// VerifySharePasswordWithBodyWithResponse request with arbitrary body returning *VerifySharePasswordResponse
|
||||
@@ -41256,62 +41203,22 @@ func ParseSaveShareResponse(rsp *http.Response) (*SaveShareResponse, error) {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseDeleteShareProfileListingResponse parses an HTTP response from a DeleteShareProfileListingWithResponse call
|
||||
func ParseDeleteShareProfileListingResponse(rsp *http.Response) (*DeleteShareProfileListingResponse, error) {
|
||||
// ParsePutSharePrivacyResponse parses an HTTP response from a PutSharePrivacyWithResponse call
|
||||
func ParsePutSharePrivacyResponse(rsp *http.Response) (*PutSharePrivacyResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &DeleteShareProfileListingResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON400 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON403 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON404 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParsePutShareProfileListingResponse parses an HTTP response from a PutShareProfileListingWithResponse call
|
||||
func ParsePutShareProfileListingResponse(rsp *http.Response) (*PutShareProfileListingResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &PutShareProfileListingResponse{
|
||||
response := &PutSharePrivacyResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest ShareProfileListing
|
||||
var dest SharePrivacy
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ ZPan 是一个构建在 S3-compatible 存储之上的轻量级文件托管平台
|
||||
- **S3 网盘** — 在你自己的对象存储之上管理文件、文件夹、预览、回收站、配额和团队工作区
|
||||
- **图床** — 通过 PicGo、PicList、uPic、ShareX、Flameshot 或 API 上传,即刻获得稳定的 URL
|
||||
- **文件分享** — 发布带密码、过期时间、下载次数限制、直链以及转存到网盘的分享链接
|
||||
- **个人主页** — 为每个用户提供一个公开的 `/u/username` 页面,用于精选分享文件和文件夹式浏览
|
||||
- **个人主页** — 为每个用户提供一个公开的 `/u/username` 页面,用于展示公开分享和文件夹式浏览
|
||||
- **外部访问** — 通过 WebDAV 挂载文件,并运行下载节点以支持远程下载工作流
|
||||
|
||||
## 为什么选择 ZPan?
|
||||
|
||||
@@ -13,7 +13,7 @@ Shared file spaces for small teams with role-based permissions.
|
||||
|
||||
### User Share Homepage
|
||||
- Public profile URL — `zpan.app/u/username` or custom slug
|
||||
- Curated share list — user chooses which shares appear
|
||||
- Public share list — untargeted landing shares appear by default unless marked private
|
||||
- Directory browsing for shared folders
|
||||
|
||||
## User Scenarios
|
||||
|
||||
@@ -17,7 +17,7 @@ const folderShare = {
|
||||
rootRef: 'profile-folder-root',
|
||||
}
|
||||
|
||||
test('anonymous profile opens curated files and folders through the landing-share flow @desktop [spec: profile/share-flow]', async ({
|
||||
test('anonymous profile opens public files and folders through the landing-share flow @desktop [spec: profile/share-flow]', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('**/api/users/alice', (route) =>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX `shares_creator_listed_idx`;--> statement-breakpoint
|
||||
ALTER TABLE `shares` ADD `private` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX `shares_creator_private_created_idx` ON `shares` (`creator_id`,`private`,`created_at`);--> statement-breakpoint
|
||||
ALTER TABLE `shares` DROP COLUMN `listed_at`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -491,6 +491,13 @@
|
||||
"when": 1784850571453,
|
||||
"tag": "0070_profile-share-listing",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "6",
|
||||
"when": 1784869319265,
|
||||
"tag": "0071_share-privacy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,13 +34,6 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
if (input.kind === 'direct' && input.password) throw new CreateShareError('DIRECT_NO_PASSWORD')
|
||||
if (input.kind === 'direct' && input.recipients && input.recipients.length > 0)
|
||||
throw new CreateShareError('DIRECT_NO_RECIPIENTS')
|
||||
if (
|
||||
input.showOnProfile &&
|
||||
(input.kind !== 'landing' || (input.recipients != null && input.recipients.length > 0))
|
||||
) {
|
||||
throw new CreateShareError('PROFILE_LISTING_INELIGIBLE')
|
||||
}
|
||||
|
||||
const matter = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
@@ -65,7 +58,7 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
views: 0,
|
||||
downloads: 0,
|
||||
status: 'active',
|
||||
listedAt: input.showOnProfile ? now : null,
|
||||
private: input.private ?? false,
|
||||
createdAt: now,
|
||||
}
|
||||
|
||||
@@ -182,10 +175,10 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
return result.length > 0
|
||||
},
|
||||
|
||||
async setProfileListing(token: string, creatorId: string, listedAt: Date | null): Promise<boolean> {
|
||||
async setPrivacy(token: string, creatorId: string, isPrivate: boolean): Promise<boolean> {
|
||||
const result = await db
|
||||
.update(shares)
|
||||
.set({ listedAt })
|
||||
.set({ private: isPrivate })
|
||||
.where(and(eq(shares.token, token), eq(shares.creatorId, creatorId)))
|
||||
.returning({ id: shares.id })
|
||||
|
||||
@@ -208,7 +201,7 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
.where(
|
||||
and(
|
||||
eq(user.username, username),
|
||||
isNotNull(shares.listedAt),
|
||||
eq(shares.private, false),
|
||||
eq(shares.kind, 'landing'),
|
||||
eq(shares.status, 'active'),
|
||||
or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`),
|
||||
@@ -223,7 +216,7 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
)`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(shares.listedAt), desc(shares.id))
|
||||
.orderBy(desc(shares.createdAt), desc(shares.id))
|
||||
|
||||
return rows.map(({ dirtype, ...row }) => ({ ...row, isFolder: dirtype !== DirType.FILE }))
|
||||
},
|
||||
@@ -253,7 +246,7 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
views: shares.views,
|
||||
downloads: shares.downloads,
|
||||
status: shares.status,
|
||||
listedAt: shares.listedAt,
|
||||
private: shares.private,
|
||||
createdAt: shares.createdAt,
|
||||
matterName: matters.name,
|
||||
matterType: matters.type,
|
||||
@@ -311,7 +304,7 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
views: shares.views,
|
||||
downloads: shares.downloads,
|
||||
status: shares.status,
|
||||
listedAt: shares.listedAt,
|
||||
private: shares.private,
|
||||
createdAt: shares.createdAt,
|
||||
matterName: matters.name,
|
||||
matterType: matters.type,
|
||||
|
||||
+2
-2
@@ -565,12 +565,12 @@ export const shares = sqliteTable(
|
||||
views: integer('views').notNull().default(0),
|
||||
downloads: integer('downloads').notNull().default(0),
|
||||
status: text('status').notNull().default('active'), // 'active' | 'revoked'
|
||||
listedAt: integer('listed_at', { mode: 'timestamp' }),
|
||||
private: integer('private', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('shares_creator_status_created_idx').on(t.creatorId, t.status, t.createdAt),
|
||||
index('shares_creator_listed_idx').on(t.creatorId, t.listedAt),
|
||||
index('shares_creator_private_created_idx').on(t.creatorId, t.private, t.createdAt),
|
||||
index('shares_created_idx').on(t.createdAt),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -257,7 +257,7 @@ describe('POST /api/shares', () => {
|
||||
expect(body.downloadLimit).toBe(5)
|
||||
})
|
||||
|
||||
it('atomically lists an eligible landing share at creation time [spec: shares/create-profile-listing]', async () => {
|
||||
it('creates an eligible landing share as public by default [spec: shares/create-public]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -267,21 +267,19 @@ describe('POST /api/shares', () => {
|
||||
const res = await createShare(app, headers, {
|
||||
matterId: 'profile-create',
|
||||
kind: 'landing',
|
||||
showOnProfile: true,
|
||||
})
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as { token: string; listedAt: string | null }
|
||||
expect(body.listedAt).not.toBeNull()
|
||||
const body = (await res.json()) as { token: string; private: boolean }
|
||||
expect(body.private).toBe(false)
|
||||
const rows = await db
|
||||
.select({ listedAt: shares.listedAt, status: shares.status })
|
||||
.select({ private: shares.private, status: shares.status })
|
||||
.from(shares)
|
||||
.where(eq(shares.token, body.token))
|
||||
expect(rows[0]?.listedAt).toBeInstanceOf(Date)
|
||||
expect(rows[0]?.status).toBe('active')
|
||||
expect(rows[0]).toEqual({ private: false, status: 'active' })
|
||||
})
|
||||
|
||||
it('rejects forged profile listing on direct and recipient-targeted shares', async () => {
|
||||
it('accepts private creation without changing direct or targeted eligibility', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -291,23 +289,17 @@ describe('POST /api/shares', () => {
|
||||
const direct = await createShare(app, headers, {
|
||||
matterId: 'profile-ineligible',
|
||||
kind: 'direct',
|
||||
showOnProfile: true,
|
||||
private: true,
|
||||
})
|
||||
expect(direct.status).toBe(400)
|
||||
expect(((await direct.json()) as { error: { details: Array<{ reason: string }> } }).error.details[0]?.reason).toBe(
|
||||
'PROFILE_LISTING_INELIGIBLE',
|
||||
)
|
||||
expect(direct.status).toBe(201)
|
||||
|
||||
const targeted = await createShare(app, headers, {
|
||||
matterId: 'profile-ineligible',
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientEmail: 'private@example.com' }],
|
||||
showOnProfile: true,
|
||||
private: true,
|
||||
})
|
||||
expect(targeted.status).toBe(400)
|
||||
expect(
|
||||
((await targeted.json()) as { error: { details: Array<{ reason: string }> } }).error.details[0]?.reason,
|
||||
).toBe('PROFILE_LISTING_INELIGIBLE')
|
||||
expect(targeted.status).toBe(201)
|
||||
})
|
||||
|
||||
it('returns 400 with DIRECT_NO_RECIPIENTS when creating direct share with recipients [spec: shares/direct-no-recipients]', async () => {
|
||||
@@ -359,25 +351,23 @@ describe('POST /api/shares', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── PUT/DELETE /api/shares/:token/profile-listing ───────────────────────────
|
||||
// ─── PUT /api/shares/:token/privacy ──────────────────────────────────────────
|
||||
|
||||
function profileListingRequest(
|
||||
app: TestApp,
|
||||
token: string,
|
||||
method: 'PUT' | 'DELETE',
|
||||
headers?: Record<string, string>,
|
||||
) {
|
||||
return app.request(`/api/shares/${token}/profile-listing`, { method, headers })
|
||||
function privacyRequest(app: TestApp, token: string, isPrivate: boolean, headers?: Record<string, string>) {
|
||||
return app.request(`/api/shares/${token}/privacy`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ private: isPrivate }),
|
||||
})
|
||||
}
|
||||
|
||||
describe('share profile listing mutation', () => {
|
||||
describe('share privacy mutation', () => {
|
||||
it('requires authentication', async () => {
|
||||
const { app } = await createTestApp()
|
||||
expect((await profileListingRequest(app, 'unknown', 'PUT')).status).toBe(401)
|
||||
expect((await profileListingRequest(app, 'unknown', 'DELETE')).status).toBe(401)
|
||||
expect((await privacyRequest(app, 'unknown', true)).status).toBe(401)
|
||||
})
|
||||
|
||||
it('lets only the owner list and unlist, without revoking the landing share [spec: shares/profile-listing-owner] [spec: shares/profile-listing-authorization] [spec: shares/profile-unlist-preserves-access]', async () => {
|
||||
it('lets only the owner make a landing share private or public without revoking it [spec: shares/privacy-owner] [spec: shares/privacy-authorization] [spec: shares/privacy-preserves-access]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const ownerHeaders = await authedHeaders(app, `profile-owner-${nanoid()}@example.com`)
|
||||
await insertStorage(db)
|
||||
@@ -387,25 +377,26 @@ describe('share profile listing mutation', () => {
|
||||
const token = ((await created.json()) as { token: string }).token
|
||||
const otherHeaders = await authedHeaders(app, `profile-other-${nanoid()}@example.com`)
|
||||
|
||||
expect((await profileListingRequest(app, token, 'PUT', otherHeaders)).status).toBe(403)
|
||||
expect((await privacyRequest(app, token, true, otherHeaders)).status).toBe(403)
|
||||
|
||||
const listed = await profileListingRequest(app, token, 'PUT', ownerHeaders)
|
||||
expect(listed.status).toBe(200)
|
||||
expect(((await listed.json()) as { listedAt: string | null }).listedAt).not.toBeNull()
|
||||
const madePrivate = await privacyRequest(app, token, true, ownerHeaders)
|
||||
expect(madePrivate.status).toBe(200)
|
||||
expect(await madePrivate.json()).toEqual({ private: true })
|
||||
|
||||
const unlisted = await profileListingRequest(app, token, 'DELETE', ownerHeaders)
|
||||
expect(unlisted.status).toBe(204)
|
||||
const madePublic = await privacyRequest(app, token, false, ownerHeaders)
|
||||
expect(madePublic.status).toBe(200)
|
||||
expect(await madePublic.json()).toEqual({ private: false })
|
||||
const rows = await db
|
||||
.select({ listedAt: shares.listedAt, status: shares.status })
|
||||
.select({ private: shares.private, status: shares.status })
|
||||
.from(shares)
|
||||
.where(eq(shares.token, token))
|
||||
expect(rows[0]).toEqual({ listedAt: null, status: 'active' })
|
||||
expect(rows[0]).toEqual({ private: false, status: 'active' })
|
||||
|
||||
const landing = await app.request(`/api/shares/${token}`)
|
||||
expect(landing.status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects forged listing mutations for direct and recipient-targeted shares [spec: shares/profile-listing-ineligible]', async () => {
|
||||
it('rejects privacy mutations for direct and recipient-targeted shares [spec: shares/privacy-ineligible]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app, `profile-ineligible-owner-${nanoid()}@example.com`)
|
||||
await insertStorage(db)
|
||||
@@ -423,10 +414,10 @@ describe('share profile listing mutation', () => {
|
||||
const targetedToken = ((await targetedCreated.json()) as { token: string }).token
|
||||
|
||||
for (const token of [directToken, targetedToken]) {
|
||||
const res = await profileListingRequest(app, token, 'PUT', headers)
|
||||
const res = await privacyRequest(app, token, true, headers)
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0]?.reason).toBe('PROFILE_LISTING_INELIGIBLE')
|
||||
expect(body.error.details[0]?.reason).toBe('SHARE_PRIVACY_INELIGIBLE')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+18
-40
@@ -23,7 +23,7 @@ import {
|
||||
type ShareCreatorDto,
|
||||
type ShareViewerDto,
|
||||
saveShare,
|
||||
setProfileListing,
|
||||
setSharePrivacy,
|
||||
verifySharePassword,
|
||||
viewShare,
|
||||
} from '../usecases/share'
|
||||
@@ -117,7 +117,7 @@ const shareListItemSchema = z
|
||||
views: z.number().int(),
|
||||
downloads: z.number().int(),
|
||||
status: z.string(),
|
||||
listedAt: z.string().nullable(),
|
||||
private: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
matter: z.object({ name: z.string(), type: z.string(), dirtype: z.number().int() }),
|
||||
recipientCount: z.number().int(),
|
||||
@@ -129,7 +129,6 @@ function toShareListItemDTO(s: ShareListItem): z.infer<typeof shareListItemSchem
|
||||
return {
|
||||
...s,
|
||||
expiresAt: s.expiresAt ? s.expiresAt.toISOString() : null,
|
||||
listedAt: s.listedAt ? s.listedAt.toISOString() : null,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -145,7 +144,7 @@ const createdShareSchema = z
|
||||
urls: z.object({ landing: z.string().optional(), direct: z.string().optional() }),
|
||||
expiresAt: z.string().nullable(),
|
||||
downloadLimit: z.number().int().nullable(),
|
||||
listedAt: z.string().nullable(),
|
||||
private: z.boolean(),
|
||||
})
|
||||
.openapi('CreatedShare')
|
||||
|
||||
@@ -387,33 +386,21 @@ const revokeShareRoute = createRoute({
|
||||
},
|
||||
})
|
||||
|
||||
const profileListingSchema = z.object({ listedAt: z.string().nullable() }).openapi('ShareProfileListing')
|
||||
const sharePrivacySchema = z.object({ private: z.boolean() }).openapi('SharePrivacy')
|
||||
|
||||
const putProfileListingRoute = createRoute({
|
||||
operationId: 'putShareProfileListing',
|
||||
summary: 'Show a share on the owner public profile',
|
||||
const putSharePrivacyRoute = createRoute({
|
||||
operationId: 'putSharePrivacy',
|
||||
summary: 'Set whether a share is hidden from the owner public profile',
|
||||
tags: ['Shares'],
|
||||
method: 'put',
|
||||
path: '/{token}/profile-listing',
|
||||
request: { params: z.object({ token: z.string() }) },
|
||||
responses: {
|
||||
200: jsonContent(profileListingSchema, 'Profile listing'),
|
||||
400: errorResponse('Share is not eligible for profile listing'),
|
||||
403: errorResponse('Forbidden'),
|
||||
404: errorResponse('Not found'),
|
||||
path: '/{token}/privacy',
|
||||
request: {
|
||||
params: z.object({ token: z.string() }),
|
||||
...jsonBody(sharePrivacySchema),
|
||||
},
|
||||
})
|
||||
|
||||
const deleteProfileListingRoute = createRoute({
|
||||
operationId: 'deleteShareProfileListing',
|
||||
summary: 'Remove a share from the owner public profile',
|
||||
tags: ['Shares'],
|
||||
method: 'delete',
|
||||
path: '/{token}/profile-listing',
|
||||
request: { params: z.object({ token: z.string() }) },
|
||||
responses: {
|
||||
204: { description: 'Profile listing removed' },
|
||||
400: errorResponse('Share is not eligible for profile listing'),
|
||||
200: jsonContent(sharePrivacySchema, 'Share privacy'),
|
||||
400: errorResponse('Share does not have configurable privacy'),
|
||||
403: errorResponse('Forbidden'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
@@ -461,29 +448,20 @@ export const authedShares = authedApp
|
||||
urls: shareUrls(out.share.kind, out.share.token),
|
||||
expiresAt: out.share.expiresAt ? out.share.expiresAt.toISOString() : null,
|
||||
downloadLimit: out.share.downloadLimit,
|
||||
listedAt: out.share.listedAt ? out.share.listedAt.toISOString() : null,
|
||||
private: out.share.private,
|
||||
},
|
||||
201,
|
||||
)
|
||||
}
|
||||
throw out.error
|
||||
})
|
||||
.openapi(putProfileListingRoute, async (c) => {
|
||||
const out = await setProfileListing(c.get('deps'), {
|
||||
.openapi(putSharePrivacyRoute, async (c) => {
|
||||
const out = await setSharePrivacy(c.get('deps'), {
|
||||
token: c.req.valid('param').token,
|
||||
userId: c.get('userId')!,
|
||||
listed: true,
|
||||
private: c.req.valid('json').private,
|
||||
})
|
||||
if (out.ok) return c.json({ listedAt: out.listedAt?.toISOString() ?? null }, 200)
|
||||
throw out.error
|
||||
})
|
||||
.openapi(deleteProfileListingRoute, async (c) => {
|
||||
const out = await setProfileListing(c.get('deps'), {
|
||||
token: c.req.valid('param').token,
|
||||
userId: c.get('userId')!,
|
||||
listed: false,
|
||||
})
|
||||
if (out.ok) return c.body(null, 204)
|
||||
if (out.ok) return c.json({ private: out.private }, 200)
|
||||
throw out.error
|
||||
})
|
||||
.openapi(revokeShareRoute, async (c) => {
|
||||
|
||||
@@ -91,7 +91,7 @@ function createShare(app: TestApp, headers: Record<string, string>, body: Record
|
||||
}
|
||||
|
||||
describe('[CF] public profile shares', () => {
|
||||
it('lists a landing share selected at creation and unlisting leaves the share usable', async () => {
|
||||
it('lists a landing share by default and making it private leaves the share usable', async () => {
|
||||
const { app, db } = await buildApp()
|
||||
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
|
||||
const matterId = await insertMatter(db, owner.orgId, 'Public guide.txt')
|
||||
@@ -99,11 +99,10 @@ describe('[CF] public profile shares', () => {
|
||||
const creation = await createShare(app, owner.headers, {
|
||||
matterId,
|
||||
kind: 'landing',
|
||||
showOnProfile: true,
|
||||
})
|
||||
expect(creation.status).toBe(201)
|
||||
const created = (await creation.json()) as { token: string; listedAt: string | null }
|
||||
expect(created.listedAt).toEqual(expect.any(String))
|
||||
const created = (await creation.json()) as { token: string; private: boolean }
|
||||
expect(created.private).toBe(false)
|
||||
|
||||
const profile = await app.request(`/api/users/${owner.username}`)
|
||||
expect(profile.status).toBe(200)
|
||||
@@ -120,11 +119,12 @@ describe('[CF] public profile shares', () => {
|
||||
],
|
||||
})
|
||||
|
||||
const unlisted = await app.request(`/api/shares/${created.token}/profile-listing`, {
|
||||
method: 'DELETE',
|
||||
headers: owner.headers,
|
||||
const madePrivate = await app.request(`/api/shares/${created.token}/privacy`, {
|
||||
method: 'PUT',
|
||||
headers: { ...owner.headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ private: true }),
|
||||
})
|
||||
expect(unlisted.status).toBe(204)
|
||||
expect(madePrivate.status).toBe(200)
|
||||
|
||||
const afterUnlisting = await app.request(`/api/users/${owner.username}`)
|
||||
expect(((await afterUnlisting.json()) as { shares: unknown[] }).shares).toEqual([])
|
||||
@@ -133,7 +133,7 @@ describe('[CF] public profile shares', () => {
|
||||
expect(underlyingShare.status).toBe(200)
|
||||
})
|
||||
|
||||
it('requires authentication and ownership to change a profile listing', async () => {
|
||||
it('requires authentication and ownership to change share privacy', async () => {
|
||||
const { app, db } = await buildApp()
|
||||
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
|
||||
const other = await signUp(app, db, `profile-other-${nanoid(5)}`)
|
||||
@@ -145,18 +145,24 @@ describe('[CF] public profile shares', () => {
|
||||
kind: 'landing',
|
||||
})
|
||||
|
||||
const anonymous = await app.request(`/api/shares/${share.token}/profile-listing`, { method: 'PUT' })
|
||||
const anonymous = await app.request(`/api/shares/${share.token}/privacy`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ private: true }),
|
||||
})
|
||||
expect(anonymous.status).toBe(401)
|
||||
|
||||
const nonOwner = await app.request(`/api/shares/${share.token}/profile-listing`, {
|
||||
const nonOwner = await app.request(`/api/shares/${share.token}/privacy`, {
|
||||
method: 'PUT',
|
||||
headers: other.headers,
|
||||
headers: { ...other.headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ private: true }),
|
||||
})
|
||||
expect(nonOwner.status).toBe(403)
|
||||
|
||||
const ownerMutation = await app.request(`/api/shares/${share.token}/profile-listing`, {
|
||||
const ownerMutation = await app.request(`/api/shares/${share.token}/privacy`, {
|
||||
method: 'PUT',
|
||||
headers: owner.headers,
|
||||
headers: { ...owner.headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ private: false }),
|
||||
})
|
||||
expect(ownerMutation.status).toBe(200)
|
||||
|
||||
@@ -166,7 +172,7 @@ describe('[CF] public profile shares', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects forged listing requests and never exposes direct or recipient-targeted shares', async () => {
|
||||
it('rejects ineligible privacy requests and never exposes direct or recipient-targeted shares', async () => {
|
||||
const { app, db } = await buildApp()
|
||||
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
|
||||
const matterId = await insertMatter(db, owner.orgId, 'Privacy boundary.txt')
|
||||
@@ -176,7 +182,6 @@ describe('[CF] public profile shares', () => {
|
||||
orgId: owner.orgId,
|
||||
creatorId: owner.userId,
|
||||
kind: 'landing',
|
||||
showOnProfile: true,
|
||||
})
|
||||
const direct = await repo.create({
|
||||
matterId,
|
||||
@@ -192,30 +197,26 @@ describe('[CF] public profile shares', () => {
|
||||
recipients: [{ recipientEmail: 'recipient@example.com' }],
|
||||
})
|
||||
|
||||
const forgedListedAt = Date.now()
|
||||
await db.run(
|
||||
sql`UPDATE shares SET listed_at = ${forgedListedAt} WHERE token IN (${direct.token}, ${targeted.token})`,
|
||||
)
|
||||
|
||||
for (const token of [direct.token, targeted.token]) {
|
||||
const mutation = await app.request(`/api/shares/${token}/profile-listing`, {
|
||||
const mutation = await app.request(`/api/shares/${token}/privacy`, {
|
||||
method: 'PUT',
|
||||
headers: owner.headers,
|
||||
headers: { ...owner.headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ private: true }),
|
||||
})
|
||||
expect(mutation.status).toBe(400)
|
||||
}
|
||||
|
||||
for (const body of [
|
||||
{ matterId, kind: 'direct', showOnProfile: true },
|
||||
{ matterId, kind: 'direct', private: true },
|
||||
{
|
||||
matterId,
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientEmail: 'recipient@example.com' }],
|
||||
showOnProfile: true,
|
||||
private: true,
|
||||
},
|
||||
]) {
|
||||
const creation = await createShare(app, owner.headers, body)
|
||||
expect(creation.status).toBe(400)
|
||||
expect(creation.status).toBe(201)
|
||||
}
|
||||
|
||||
const profile = await app.request(`/api/users/${owner.username}`)
|
||||
@@ -224,7 +225,7 @@ describe('[CF] public profile shares', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('filters revoked, expired, exhausted, trashed, inactive, missing, and unlisted targets at read time', async () => {
|
||||
it('filters private, revoked, expired, exhausted, trashed, inactive, and missing targets at read time', async () => {
|
||||
const { app, db } = await buildApp()
|
||||
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
|
||||
const repo = createShareRepo(db)
|
||||
@@ -236,7 +237,6 @@ describe('[CF] public profile shares', () => {
|
||||
orgId: owner.orgId,
|
||||
creatorId: owner.userId,
|
||||
kind: 'landing',
|
||||
showOnProfile: true,
|
||||
...options,
|
||||
})
|
||||
return { matterId, share }
|
||||
@@ -249,12 +249,13 @@ describe('[CF] public profile shares', () => {
|
||||
const trashed = await listedShare('Trashed.txt')
|
||||
const inactive = await listedShare('Inactive.txt')
|
||||
const missing = await listedShare('Missing.txt')
|
||||
const unlistedMatterId = await insertMatter(db, owner.orgId, 'Unlisted.txt')
|
||||
const privateMatterId = await insertMatter(db, owner.orgId, 'Private.txt')
|
||||
await repo.create({
|
||||
matterId: unlistedMatterId,
|
||||
matterId: privateMatterId,
|
||||
orgId: owner.orgId,
|
||||
creatorId: owner.userId,
|
||||
kind: 'landing',
|
||||
private: true,
|
||||
})
|
||||
|
||||
await db.run(sql`UPDATE shares SET status = 'revoked' WHERE id = ${revoked.share.id}`)
|
||||
|
||||
@@ -613,7 +613,7 @@ async function insertProfileShare(
|
||||
token?: string
|
||||
kind?: 'landing' | 'direct'
|
||||
status?: 'active' | 'revoked'
|
||||
listed?: boolean
|
||||
private?: boolean
|
||||
expiresAt?: number | null
|
||||
downloadLimit?: number | null
|
||||
downloads?: number
|
||||
@@ -625,12 +625,12 @@ async function insertProfileShare(
|
||||
await db.run(sql`
|
||||
INSERT INTO shares (
|
||||
id, token, kind, matter_id, org_id, creator_id, password_hash, expires_at,
|
||||
download_limit, views, downloads, status, listed_at, created_at
|
||||
download_limit, views, downloads, status, private, created_at
|
||||
)
|
||||
VALUES (
|
||||
${id}, ${token}, ${opts.kind ?? 'landing'}, ${matterId}, ${orgId}, ${creatorId},
|
||||
NULL, ${opts.expiresAt ?? null}, ${opts.downloadLimit ?? null}, 0, ${opts.downloads ?? 0},
|
||||
${opts.status ?? 'active'}, ${opts.listed === false ? null : now}, ${now}
|
||||
${opts.status ?? 'active'}, ${opts.private ? 1 : 0}, ${now}
|
||||
)
|
||||
`)
|
||||
return { id, token }
|
||||
@@ -679,7 +679,7 @@ describe('GET /api/users/:username', () => {
|
||||
expect(body.shares).toEqual([])
|
||||
})
|
||||
|
||||
it('returns exactly the selected public landing shares without authentication [spec: profile/curated-shares]', async () => {
|
||||
it('returns public landing shares and hides private ones without authentication [spec: profile/public-shares]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { orgId } = await insertUser(db, {
|
||||
id: 'curated-user',
|
||||
@@ -688,12 +688,12 @@ describe('GET /api/users/:username', () => {
|
||||
})
|
||||
await insertProfileMatter(db, orgId, 'curated-file', { name: 'Public.txt' })
|
||||
await insertProfileMatter(db, orgId, 'curated-folder', { name: 'Photos', dirtype: 1 })
|
||||
await insertProfileMatter(db, orgId, 'unlisted-file', { name: 'Hidden.txt' })
|
||||
await insertProfileMatter(db, orgId, 'private-file', { name: 'Hidden.txt' })
|
||||
await insertProfileShare(db, 'curated-user', orgId, 'curated-file', { token: 'public-file' })
|
||||
await insertProfileShare(db, 'curated-user', orgId, 'curated-folder', { token: 'public-folder' })
|
||||
await insertProfileShare(db, 'curated-user', orgId, 'unlisted-file', {
|
||||
await insertProfileShare(db, 'curated-user', orgId, 'private-file', {
|
||||
token: 'hidden-file',
|
||||
listed: false,
|
||||
private: true,
|
||||
})
|
||||
|
||||
const res = await app.request('/api/users/curated')
|
||||
|
||||
@@ -368,11 +368,11 @@ const APP_SCHEMA_SQL = `
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
downloads INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
listed_at INTEGER,
|
||||
private INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS shares_creator_status_created_idx ON shares(creator_id, status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS shares_creator_listed_idx ON shares(creator_id, listed_at);
|
||||
CREATE INDEX IF NOT EXISTS shares_creator_private_created_idx ON shares(creator_id, private, created_at);
|
||||
CREATE TABLE IF NOT EXISTS share_recipients (
|
||||
id TEXT PRIMARY KEY,
|
||||
share_id TEXT NOT NULL,
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface ShareRecord {
|
||||
views: number
|
||||
downloads: number
|
||||
status: string
|
||||
listedAt: Date | null
|
||||
private: boolean
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface ShareListItem {
|
||||
views: number
|
||||
downloads: number
|
||||
status: string
|
||||
listedAt: Date | null
|
||||
private: boolean
|
||||
createdAt: Date
|
||||
matter: { name: string; type: string; dirtype: number }
|
||||
recipientCount: number
|
||||
@@ -61,14 +61,7 @@ export type ShareResolution =
|
||||
// Thrown by createShare on invalid share-shape combinations. Carries a stable
|
||||
// code the http layer maps to a 400/404.
|
||||
export class CreateShareError extends Error {
|
||||
constructor(
|
||||
public code:
|
||||
| 'MATTER_NOT_FOUND'
|
||||
| 'DIRECT_NO_FOLDER'
|
||||
| 'DIRECT_NO_PASSWORD'
|
||||
| 'DIRECT_NO_RECIPIENTS'
|
||||
| 'PROFILE_LISTING_INELIGIBLE',
|
||||
) {
|
||||
constructor(public code: 'MATTER_NOT_FOUND' | 'DIRECT_NO_FOLDER' | 'DIRECT_NO_PASSWORD' | 'DIRECT_NO_RECIPIENTS') {
|
||||
super(code)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +76,7 @@ export interface ShareRepo {
|
||||
listRecipientUserIds(shareId: string): Promise<string[]>
|
||||
revokeByMatter(matterId: string): Promise<void>
|
||||
revokeByToken(token: string, creatorId: string): Promise<boolean>
|
||||
setProfileListing(token: string, creatorId: string, listedAt: Date | null): Promise<boolean>
|
||||
setPrivacy(token: string, creatorId: string, isPrivate: boolean): Promise<boolean>
|
||||
listPublicProfileShares(username: string, now: Date): Promise<PublicProfileShare[]>
|
||||
listForApi(
|
||||
creatorId: string,
|
||||
|
||||
+13
-36
@@ -443,7 +443,7 @@ export type CreatedShare = {
|
||||
kind: string
|
||||
expiresAt: Date | null
|
||||
downloadLimit: number | null
|
||||
listedAt: Date | null
|
||||
private: boolean
|
||||
}
|
||||
|
||||
export type CreateShareOutcome = { ok: true; share: CreatedShare } | { ok: false; error: AppError }
|
||||
@@ -455,10 +455,6 @@ const CREATE_SHARE_ERRORS: Record<CreateShareError['code'], AppError> = {
|
||||
DIRECT_NO_FOLDER: badRequest('Direct shares cannot be folders', 'DIRECT_NO_FOLDER'),
|
||||
DIRECT_NO_PASSWORD: badRequest('Direct shares cannot have a password', 'DIRECT_NO_PASSWORD'),
|
||||
DIRECT_NO_RECIPIENTS: badRequest('Direct shares cannot have recipients', 'DIRECT_NO_RECIPIENTS'),
|
||||
PROFILE_LISTING_INELIGIBLE: badRequest(
|
||||
'Only untargeted landing shares can be shown on a profile',
|
||||
'PROFILE_LISTING_INELIGIBLE',
|
||||
),
|
||||
}
|
||||
|
||||
export async function createShare(
|
||||
@@ -487,7 +483,7 @@ export async function createShare(
|
||||
expiresAt,
|
||||
downloadLimit: input.downloadLimit,
|
||||
recipients: input.recipients,
|
||||
showOnProfile: input.showOnProfile,
|
||||
private: input.private,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof CreateShareError) return { ok: false, error: CREATE_SHARE_ERRORS[err.code] }
|
||||
@@ -515,59 +511,40 @@ export async function createShare(
|
||||
kind: share.kind,
|
||||
expiresAt: share.expiresAt,
|
||||
downloadLimit: share.downloadLimit,
|
||||
listedAt: share.listedAt,
|
||||
private: share.private,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PUT/DELETE /:token/profile-listing — owner-curated profile state ───────
|
||||
// ─── PUT /:token/privacy — owner-controlled profile visibility ──────────────
|
||||
|
||||
export type SetProfileListingParams = {
|
||||
export type SetSharePrivacyParams = {
|
||||
token: string
|
||||
userId: string
|
||||
listed: boolean
|
||||
now?: Date
|
||||
private: boolean
|
||||
}
|
||||
|
||||
export type SetProfileListingOutcome = { ok: true; listedAt: Date | null } | { ok: false; error: AppError }
|
||||
export type SetSharePrivacyOutcome = { ok: true; private: boolean } | { ok: false; error: AppError }
|
||||
|
||||
export async function setProfileListing(
|
||||
deps: ShareDeps,
|
||||
params: SetProfileListingParams,
|
||||
): Promise<SetProfileListingOutcome> {
|
||||
const { token, userId, listed, now = new Date() } = params
|
||||
export async function setSharePrivacy(deps: ShareDeps, params: SetSharePrivacyParams): Promise<SetSharePrivacyOutcome> {
|
||||
const { token, userId, private: isPrivate } = params
|
||||
const resolved = await deps.share.resolveByToken(token)
|
||||
if (resolved.status === 'not_found' || resolved.status === 'revoked') {
|
||||
return { ok: false, error: notFound() }
|
||||
}
|
||||
|
||||
const { share, matter, recipients } = resolved
|
||||
const { share, recipients } = resolved
|
||||
if (share.creatorId !== userId) return { ok: false, error: forbidden() }
|
||||
if (share.kind !== 'landing' || recipients.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: badRequest('Only untargeted landing shares can be shown on a profile', 'PROFILE_LISTING_INELIGIBLE'),
|
||||
error: badRequest('Only untargeted landing shares have configurable privacy', 'SHARE_PRIVACY_INELIGIBLE'),
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
listed &&
|
||||
(resolved.status === 'matter_trashed' ||
|
||||
matter.status !== 'active' ||
|
||||
matter.purgedAt != null ||
|
||||
(share.expiresAt != null && share.expiresAt <= now) ||
|
||||
(share.downloadLimit != null && share.downloads >= share.downloadLimit))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: badRequest('Unavailable shares cannot be shown on a profile', 'PROFILE_LISTING_UNAVAILABLE'),
|
||||
}
|
||||
}
|
||||
|
||||
const listedAt = listed ? now : null
|
||||
const updated = await deps.share.setProfileListing(token, userId, listedAt)
|
||||
const updated = await deps.share.setPrivacy(token, userId, isPrivate)
|
||||
if (!updated) return { ok: false, error: notFound() }
|
||||
return { ok: true, listedAt }
|
||||
return { ok: true, private: isPrivate }
|
||||
}
|
||||
|
||||
export function listPublicProfileShares(deps: ShareDeps, username: string, now = new Date()) {
|
||||
|
||||
@@ -26,10 +26,10 @@ export const createShareSchema = z.object({
|
||||
expiresAt: z.date().optional(),
|
||||
downloadLimit: z.number().int().positive().optional(),
|
||||
recipients: z.array(shareRecipientSchema).optional(),
|
||||
showOnProfile: z.boolean().optional(),
|
||||
private: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export type CreateShareInput = z.infer<typeof createShareSchema>
|
||||
export type CreateShareInput = z.input<typeof createShareSchema>
|
||||
|
||||
export const listSharesQuerySchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
@@ -45,10 +45,10 @@ export const createShareRequestSchema = z.object({
|
||||
expiresAt: z.string().datetime({ offset: true }).optional(),
|
||||
downloadLimit: z.number().int().positive().optional(),
|
||||
recipients: z.array(shareRecipientSchema).optional(),
|
||||
showOnProfile: z.boolean().optional(),
|
||||
private: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export type CreateShareRequest = z.infer<typeof createShareRequestSchema>
|
||||
export type CreateShareRequest = z.input<typeof createShareRequestSchema>
|
||||
|
||||
export const shareObjectItemSchema = z.object({
|
||||
ref: z.string(),
|
||||
|
||||
@@ -476,7 +476,7 @@ export interface Share {
|
||||
views: number
|
||||
downloads: number
|
||||
status: 'active' | 'revoked'
|
||||
listedAt: string | null
|
||||
private: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
Feature: Public profiles
|
||||
Each user has a public profile page listing owner-curated public landing
|
||||
shares, reachable without authentication.
|
||||
Each user has a public profile page listing public landing shares that have
|
||||
not been marked private, reachable without authentication.
|
||||
|
||||
@profile/user-not-found @api
|
||||
Scenario: An unknown user id has no profile
|
||||
@@ -26,26 +26,26 @@ Feature: Public profiles
|
||||
When their profile is requested
|
||||
Then their user info is returned
|
||||
|
||||
@profile/curated-shares @api
|
||||
Scenario: A profile returns only selected public landing shares
|
||||
Given selected and unselected public landing shares owned by a user
|
||||
@profile/public-shares @api
|
||||
Scenario: A profile returns public landing shares and hides private ones
|
||||
Given public and private landing shares owned by a user
|
||||
When their profile is requested without authentication
|
||||
Then exactly the selected shares are returned
|
||||
Then exactly the public shares are returned
|
||||
|
||||
@profile/privacy-boundaries @api
|
||||
Scenario: Private share modes never appear on a profile
|
||||
Given forged selected direct and recipient-targeted shares
|
||||
Given direct and recipient-targeted shares
|
||||
When the owner's profile is requested
|
||||
Then neither private share is returned
|
||||
|
||||
@profile/availability-filtering @api
|
||||
Scenario: Unavailable selected shares disappear at read time
|
||||
Given selected revoked, expired, exhausted, trashed, purged, draft, and missing-target shares
|
||||
Scenario: Unavailable public shares disappear at read time
|
||||
Given public revoked, expired, exhausted, trashed, purged, draft, and missing-target shares
|
||||
When the owner's profile is requested
|
||||
Then none of the unavailable shares are returned
|
||||
|
||||
@profile/share-flow @journey
|
||||
Scenario: Listed files and folders use the landing-share flow
|
||||
Given selected public file and folder landing shares
|
||||
Given public file and folder landing shares
|
||||
When a visitor opens either item from the profile
|
||||
Then the existing share landing page handles file access and folder navigation
|
||||
|
||||
+19
-19
@@ -69,34 +69,34 @@ Feature: Shares
|
||||
When the share is created
|
||||
Then the limit is stored
|
||||
|
||||
@shares/create-profile-listing @api
|
||||
Scenario: An eligible landing share is listed at creation time
|
||||
@shares/create-public @api
|
||||
Scenario: An eligible landing share is public by default
|
||||
Given an authenticated user creating an untargeted landing share
|
||||
When they choose to show it on their personal homepage
|
||||
Then the share and its profile listing are created atomically
|
||||
When they do not enable private sharing
|
||||
Then the share appears on their public profile
|
||||
|
||||
@shares/profile-listing-owner @api
|
||||
Scenario: An owner lists and unlists an eligible share
|
||||
@shares/privacy-owner @api
|
||||
Scenario: An owner changes an eligible share between public and private
|
||||
Given an owner's untargeted landing share
|
||||
When they add and remove its profile listing
|
||||
Then only the listing state changes
|
||||
When they enable and disable private sharing
|
||||
Then only the privacy state changes
|
||||
|
||||
@shares/profile-listing-authorization @api
|
||||
Scenario: Another user cannot change a profile listing
|
||||
@shares/privacy-authorization @api
|
||||
Scenario: Another user cannot change share privacy
|
||||
Given a landing share owned by another user
|
||||
When a non-owner tries to change its profile listing
|
||||
When a non-owner tries to change its privacy
|
||||
Then the API responds 403
|
||||
|
||||
@shares/profile-listing-ineligible @api
|
||||
Scenario: Direct and recipient-targeted shares cannot be listed
|
||||
@shares/privacy-ineligible @api
|
||||
Scenario: Direct and recipient-targeted shares do not have configurable privacy
|
||||
Given direct and recipient-targeted shares
|
||||
When forged profile-listing requests are submitted
|
||||
Then the API responds 400 PROFILE_LISTING_INELIGIBLE
|
||||
When privacy requests are submitted
|
||||
Then the API responds 400 SHARE_PRIVACY_INELIGIBLE
|
||||
|
||||
@shares/profile-unlist-preserves-access @api
|
||||
Scenario: Unlisting does not revoke a share
|
||||
Given a listed public landing share
|
||||
When its owner removes the profile listing
|
||||
@shares/privacy-preserves-access @api
|
||||
Scenario: Making a share private does not revoke it
|
||||
Given a public landing share
|
||||
When its owner enables private sharing
|
||||
Then its original landing URL remains usable
|
||||
|
||||
@shares/create-notify-best-effort @api
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Feature: Curated profile shares
|
||||
|
||||
## Requirements
|
||||
|
||||
- While a user creates an untargeted landing share, when they select “Show on personal homepage”, the system shall atomically create the share with a profile listing.
|
||||
- While an authenticated owner views sent shares, when they list or unlist an eligible landing share, the system shall update only its profile-listing state.
|
||||
- While an anonymous visitor requests a public profile, the system shall return only that owner’s currently listed and accessible untargeted landing shares.
|
||||
- While a visitor opens a listed item, the system shall use the existing `/s/:token` landing and folder-navigation flow.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend
|
||||
|
||||
- Add an optional homepage switch to the existing share-creation dialog. It is available only for untargeted landing shares and resets when another mode is selected.
|
||||
- Add list/unlist controls to eligible rows on the authenticated sent Shares page.
|
||||
- Render concrete file/folder cards on `/u/:username`, linked only to `/s/:token`.
|
||||
- Add a public-homepage link to the secondary profile settings page.
|
||||
- Surface mutation failures through the existing toast error pattern.
|
||||
|
||||
### Backend
|
||||
|
||||
- Add nullable `shares.listedAt`; existing shares remain unlisted.
|
||||
- Generate the migration with `pnpm db:generate`.
|
||||
- Extend share creation with `showOnProfile?: boolean`, persisted atomically with the share.
|
||||
- Model the owner-controlled selection as `PUT` and `DELETE /api/shares/:token/profile-listing`.
|
||||
- Return the updated listing state from `PUT`; make `DELETE` idempotent for an owned eligible share.
|
||||
- Remove the unused `GET /api/users/:username/objects` placeholder.
|
||||
- Define the public profile response with a shared Zod schema and inferred wire types.
|
||||
- Query curated shares using an inner join to the target and read-time predicates for listing, owner, landing kind, no recipients, active status, unexpired limit, remaining downloads, active target, and non-trashed/non-purged target.
|
||||
|
||||
### Security
|
||||
|
||||
- Require authentication on listing mutations and scope writes by both token and creator ID.
|
||||
- Revalidate landing kind and the absence of recipients on the server; forged direct or targeted requests return a stable validation error.
|
||||
- Never expose password hashes, internal IDs, organization IDs, recipient data, or raw object locations in public profile output.
|
||||
- Select public response columns explicitly and order listings deterministically by `listedAt` then share ID.
|
||||
- Unlisting changes only `listedAt`; it never revokes the share.
|
||||
- Existing share landing/password/download/folder checks remain the sole public object-access path.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
- [x] Add schema field, generated migration, port/repository operations, and shared schemas.
|
||||
- [x] Add creation and profile-listing use cases plus REST routes.
|
||||
- [x] Replace profile placeholders with the curated read model and remove `/objects`.
|
||||
- [x] Add dialog, Shares page, public profile, settings-link, and locale changes.
|
||||
- [x] Add Node, Cloudflare, wrapper, React, OpenAPI, and browser acceptance tests.
|
||||
- [ ] Run focused verification, full quality gates, CI, and branch-preview journeys.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Feature: Public profile shares
|
||||
|
||||
## Requirements
|
||||
|
||||
- While a user creates an untargeted landing share, the system shall make it public by default unless they enable “Private share”.
|
||||
- While an authenticated owner views sent shares, when they change an eligible landing share between public and private, the system shall update only its privacy state.
|
||||
- While an anonymous visitor requests a public profile, the system shall return only that owner’s accessible, non-private, untargeted landing shares.
|
||||
- While a visitor opens a public item, the system shall use the existing `/s/:token` landing and folder-navigation flow.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend
|
||||
|
||||
- Add a “Private share” switch to the existing share-creation dialog. It is off by default, available only for untargeted landing shares, and resets when another mode is selected.
|
||||
- Add public/private controls to eligible rows on the authenticated sent Shares page.
|
||||
- Render concrete file/folder cards on `/u/:username`, linked only to `/s/:token`.
|
||||
- Add a public-homepage link to the secondary profile settings page.
|
||||
- Surface mutation failures through the existing toast error pattern.
|
||||
|
||||
### Backend
|
||||
|
||||
- Add non-null `shares.private` with a default of `false`; existing shares become public.
|
||||
- Generate the migration with `pnpm db:generate`.
|
||||
- Extend share creation with `private?: boolean`, persisted atomically with the share.
|
||||
- Model the owner-controlled state as `PUT /api/shares/:token/privacy` with `{ private: boolean }`.
|
||||
- Return the updated privacy state from `PUT`.
|
||||
- Remove the unused `GET /api/users/:username/objects` placeholder.
|
||||
- Define the public profile response with a shared Zod schema and inferred wire types.
|
||||
- Query public shares using an inner join to the target and read-time predicates for `private = false`, owner, landing kind, no recipients, active status, unexpired limit, remaining downloads, active target, and non-trashed/non-purged target.
|
||||
|
||||
### Security
|
||||
|
||||
- Require authentication on privacy mutations and scope writes by both token and creator ID.
|
||||
- Revalidate landing kind and the absence of recipients on the server; direct or targeted privacy requests return a stable validation error.
|
||||
- Never expose password hashes, internal IDs, organization IDs, recipient data, or raw object locations in public profile output.
|
||||
- Select public response columns explicitly and order shares deterministically by `createdAt` then share ID.
|
||||
- Changing privacy never revokes the share; anyone with its URL can still use the existing access flow.
|
||||
- Existing share landing/password/download/folder checks remain the sole public object-access path.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
- [x] Add schema field, generated migration, port/repository operations, and shared schemas.
|
||||
- [x] Add creation and privacy use cases plus REST routes.
|
||||
- [x] Replace profile placeholders with the public-share read model and remove `/objects`.
|
||||
- [x] Add dialog, Shares page, public profile, settings-link, and locale changes.
|
||||
- [x] Add Node, Cloudflare, wrapper, React, OpenAPI, and browser acceptance tests.
|
||||
- [ ] Run focused verification, full quality gates, CI, and branch-preview journeys.
|
||||
@@ -75,7 +75,7 @@ beforeEach(() => {
|
||||
urls: { landing: '/s/share-token' },
|
||||
expiresAt: null,
|
||||
downloadLimit: null,
|
||||
listedAt: '2026-07-23T00:00:00.000Z',
|
||||
private: false,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,11 +84,11 @@ afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ShareDialog profile listing selection', () => {
|
||||
it('sends showOnProfile when the owner enables it for a landing share', async () => {
|
||||
describe('ShareDialog privacy selection', () => {
|
||||
it('sends private when the owner enables it for a landing share', async () => {
|
||||
renderDialog()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'share.showOnProfile' }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'share.private' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'share.createButton' }))
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -96,19 +96,19 @@ describe('ShareDialog profile listing selection', () => {
|
||||
expect.objectContaining({
|
||||
matterId: 'matter-1',
|
||||
kind: 'landing',
|
||||
showOnProfile: true,
|
||||
private: true,
|
||||
}),
|
||||
expect.anything(),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('omits showOnProfile unless the owner enables the control', async () => {
|
||||
it('omits private unless the owner enables the control', async () => {
|
||||
renderDialog()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'share.createButton' }))
|
||||
|
||||
await waitFor(() => expect(createShare).toHaveBeenCalled())
|
||||
expect(vi.mocked(createShare).mock.calls[0]?.[0]).not.toHaveProperty('showOnProfile')
|
||||
expect(vi.mocked(createShare).mock.calls[0]?.[0]).not.toHaveProperty('private')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,7 +118,7 @@ const makeResult = (
|
||||
urls: { landing: '/s/tok' },
|
||||
expiresAt: null,
|
||||
downloadLimit: null,
|
||||
listedAt: null,
|
||||
private: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DirType } from '@shared/constants'
|
||||
import type { CreateShareRequest } from '@shared/schemas'
|
||||
import type { StorageObject } from '@shared/types'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { CheckCircle2, Copy, File, Folder, House, KeyRound, Share2, TriangleAlert, X } from 'lucide-react'
|
||||
import { CheckCircle2, Copy, File, Folder, KeyRound, Lock, Share2, TriangleAlert, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -79,7 +79,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
|
||||
const [customExpires, setCustomExpires] = useState('')
|
||||
const [limitOption, setLimitOption] = useState('unlimited')
|
||||
const [customLimit, setCustomLimit] = useState('')
|
||||
const [showOnProfile, setShowOnProfile] = useState(false)
|
||||
const [privateShare, setPrivateShare] = useState(false)
|
||||
const [result, setResult] = useState<CreateShareResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -93,7 +93,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
|
||||
setCustomExpires('')
|
||||
setLimitOption('unlimited')
|
||||
setCustomLimit('')
|
||||
setShowOnProfile(false)
|
||||
setPrivateShare(false)
|
||||
setResult(null)
|
||||
}, [open])
|
||||
|
||||
@@ -147,7 +147,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
|
||||
if (isTargetedMode(mode) && chips.length > 0) {
|
||||
body.recipients = chips.filter((c) => c.valid).map((c) => ({ recipientEmail: c.value }))
|
||||
}
|
||||
if (mode === 'page' && showOnProfile) body.showOnProfile = true
|
||||
if (mode === 'page' && privateShare) body.private = true
|
||||
mutation.mutate(body)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
|
||||
isFolder={isFolder}
|
||||
onChange={(next) => {
|
||||
setMode(next)
|
||||
setShowOnProfile(false)
|
||||
setPrivateShare(false)
|
||||
setPasswordEnabled(false)
|
||||
setPassword('')
|
||||
if (!isTargetedMode(next)) {
|
||||
@@ -220,16 +220,16 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
|
||||
<div className="flex min-h-11 items-start justify-between gap-4 rounded-md border bg-background p-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<House className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="share-profile-listing">{t('share.showOnProfile')}</Label>
|
||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="private-share">{t('share.private')}</Label>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t('share.showOnProfileHint')}</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t('share.privateHint')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-profile-listing"
|
||||
id="private-share"
|
||||
className="mt-0.5"
|
||||
checked={showOnProfile}
|
||||
onCheckedChange={setShowOnProfile}
|
||||
checked={privateShare}
|
||||
onCheckedChange={setPrivateShare}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1475,11 +1475,11 @@
|
||||
"shares.revokeConfirm": "Revoke share for {{name}}? Anyone with the link will lose access immediately. This cannot be undone.",
|
||||
"shares.revokeSuccess": "Share revoked",
|
||||
"shares.revokeError": "Failed to revoke share",
|
||||
"shares.listOnProfile": "Show on public profile",
|
||||
"shares.unlistFromProfile": "Remove from public profile",
|
||||
"shares.profileListSuccess": "Share added to your public profile",
|
||||
"shares.profileUnlistSuccess": "Share removed from your public profile",
|
||||
"shares.profileListingError": "Failed to update public profile listing",
|
||||
"shares.makePublic": "Make public",
|
||||
"shares.makePrivate": "Make private",
|
||||
"shares.makePublicSuccess": "Share is now visible on your public profile",
|
||||
"shares.makePrivateSuccess": "Share is now hidden from your public profile",
|
||||
"shares.privacyError": "Failed to update share privacy",
|
||||
"shares.emptyState": "No shares yet",
|
||||
"shares.emptyStateHint": "Right-click a file to create your first share",
|
||||
"shares.boxSent": "My shares",
|
||||
@@ -1515,8 +1515,8 @@
|
||||
"share.recipientsHint": "These email addresses get targeted access, still subject to expiry and download limits",
|
||||
"share.password": "Password",
|
||||
"share.passwordHint": "Only access pages support password protection. Anyone with the link must enter the password first.",
|
||||
"share.showOnProfile": "Show on personal homepage",
|
||||
"share.showOnProfileHint": "Add this public access page to your curated profile.",
|
||||
"share.private": "Private share",
|
||||
"share.privateHint": "Hide this share from your public profile. Anyone with the URL can still access it.",
|
||||
"share.generatePassword": "Generate",
|
||||
"share.expires": "Expires",
|
||||
"share.expires1d": "1 day",
|
||||
|
||||
@@ -1475,11 +1475,11 @@
|
||||
"shares.revokeConfirm": "撤销 {{name}} 的分享?任何人将立即失去访问权限,此操作不可撤回。",
|
||||
"shares.revokeSuccess": "分享已撤销",
|
||||
"shares.revokeError": "撤销分享失败",
|
||||
"shares.listOnProfile": "显示在公开主页",
|
||||
"shares.unlistFromProfile": "从公开主页移除",
|
||||
"shares.profileListSuccess": "分享已添加到公开主页",
|
||||
"shares.profileUnlistSuccess": "分享已从公开主页移除",
|
||||
"shares.profileListingError": "更新公开主页展示失败",
|
||||
"shares.makePublic": "设为公开",
|
||||
"shares.makePrivate": "设为私密",
|
||||
"shares.makePublicSuccess": "分享现已显示在公开主页",
|
||||
"shares.makePrivateSuccess": "分享现已从公开主页隐藏",
|
||||
"shares.privacyError": "更新分享隐私设置失败",
|
||||
"shares.emptyState": "暂无分享",
|
||||
"shares.emptyStateHint": "右键点击文件即可创建第一个分享",
|
||||
"shares.boxSent": "我的分享",
|
||||
@@ -1515,8 +1515,8 @@
|
||||
"share.recipientsHint": "系统会为这些邮箱创建定向访问入口,仍受有效期和下载限额约束",
|
||||
"share.password": "密码",
|
||||
"share.passwordHint": "仅访问页支持密码保护。拿到链接的人需要输入密码后才能访问。",
|
||||
"share.showOnProfile": "显示在个人主页",
|
||||
"share.showOnProfileHint": "将这个公开访问页添加到你的精选主页。",
|
||||
"share.private": "私密分享",
|
||||
"share.privateHint": "不在公开主页显示;拥有链接的人仍然可以访问。",
|
||||
"share.generatePassword": "生成",
|
||||
"share.expires": "过期时间",
|
||||
"share.expires1d": "1天",
|
||||
|
||||
+14
-30
@@ -94,7 +94,6 @@ import {
|
||||
listQuotas,
|
||||
listReceivedShares,
|
||||
listShareObjects,
|
||||
listShareOnProfile,
|
||||
listShares,
|
||||
listSiteInvitations,
|
||||
listStorages,
|
||||
@@ -129,9 +128,9 @@ import {
|
||||
saveShareToDrive,
|
||||
sendDownloaderHeartbeat,
|
||||
serverEventsUrl,
|
||||
setSharePrivacy,
|
||||
testEmail,
|
||||
transferObject,
|
||||
unlistShareFromProfile,
|
||||
updateAnnouncement,
|
||||
updateDownloader,
|
||||
updateDownloaderCreditBilling,
|
||||
@@ -2708,61 +2707,46 @@ describe('api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('profile share listing', () => {
|
||||
it('lists a share with PUT on the exact resource path', async () => {
|
||||
const payload = { listedAt: '2026-07-23T12:00:00.000Z' }
|
||||
describe('share privacy', () => {
|
||||
it('updates privacy with PUT on the exact resource path', async () => {
|
||||
const payload = { private: true }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
await expect(listShareOnProfile('tok123')).resolves.toEqual(payload)
|
||||
await expect(setSharePrivacy('tok123', true)).resolves.toEqual(payload)
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/shares/tok123/profile-listing')
|
||||
expect(url).toBe('/api/shares/tok123/privacy')
|
||||
expect(init.method).toBe('PUT')
|
||||
expect(init.body).toBeUndefined()
|
||||
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
||||
expect(body).toEqual({ private: true })
|
||||
})
|
||||
|
||||
it('surfaces listing errors', async () => {
|
||||
it('surfaces privacy errors', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
||||
await expect(listShareOnProfile('tok123')).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
|
||||
it('unlists a share with DELETE on the exact resource path', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
|
||||
|
||||
await expect(unlistShareFromProfile('tok123')).resolves.toBeUndefined()
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/shares/tok123/profile-listing')
|
||||
expect(init.method).toBe('DELETE')
|
||||
expect(init.body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces unlisting errors', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Not found' }, false, 404))
|
||||
await expect(unlistShareFromProfile('missing')).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(setSharePrivacy('tok123', true)).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createShare', () => {
|
||||
it('posts the profile selection flag to the exact share collection path', async () => {
|
||||
it('posts the privacy flag to the exact share collection path', async () => {
|
||||
const payload = {
|
||||
token: 'tok123',
|
||||
kind: 'landing' as const,
|
||||
urls: { landing: 'https://zpan.io/s/tok123' },
|
||||
expiresAt: null,
|
||||
downloadLimit: null,
|
||||
listedAt: '2026-07-23T12:00:00.000Z',
|
||||
private: true,
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await createShare({ matterId: 'obj-1', kind: 'landing', showOnProfile: true })
|
||||
const result = await createShare({ matterId: 'obj-1', kind: 'landing', private: true })
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/shares')
|
||||
expect(init.method).toBe('POST')
|
||||
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
||||
expect(body).toEqual({ matterId: 'obj-1', kind: 'landing', showOnProfile: true })
|
||||
expect(body).toEqual({ matterId: 'obj-1', kind: 'landing', private: true })
|
||||
const headers =
|
||||
init.headers instanceof Headers ? init.headers : new Headers(init.headers as Record<string, string>)
|
||||
expect(headers.get('Content-Type')).toContain('application/json')
|
||||
|
||||
+5
-7
@@ -996,12 +996,10 @@ export function revokeShare(token: string) {
|
||||
return unwrap<ShareView>(authedSharesApi[':token'].status.$put({ param: { token }, json: { status: 'revoked' } }))
|
||||
}
|
||||
|
||||
export function listShareOnProfile(token: string) {
|
||||
return unwrap<{ listedAt: string | null }>(authedSharesApi[':token']['profile-listing'].$put({ param: { token } }))
|
||||
}
|
||||
|
||||
export function unlistShareFromProfile(token: string) {
|
||||
return discard(authedSharesApi[':token']['profile-listing'].$delete({ param: { token } }))
|
||||
export function setSharePrivacy(token: string, isPrivate: boolean) {
|
||||
return unwrap<{ private: boolean }>(
|
||||
authedSharesApi[':token'].privacy.$put({ param: { token }, json: { private: isPrivate } }),
|
||||
)
|
||||
}
|
||||
|
||||
export interface CreateShareResult {
|
||||
@@ -1010,7 +1008,7 @@ export interface CreateShareResult {
|
||||
urls: { landing?: string; direct?: string }
|
||||
expiresAt: string | null
|
||||
downloadLimit: number | null
|
||||
listedAt: string | null
|
||||
private: boolean
|
||||
}
|
||||
|
||||
export function createShare(data: CreateShareRequest) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ShareListItem } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listReceivedShares, listShareOnProfile, listShares, revokeShare, unlistShareFromProfile } from '@/lib/api'
|
||||
import { listReceivedShares, listShares, revokeShare, setSharePrivacy } from '@/lib/api'
|
||||
import { SharesPage } from './index'
|
||||
|
||||
const router = vi.hoisted(() => ({
|
||||
@@ -47,10 +47,9 @@ vi.mock('@/components/shares/revoke-confirm-dialog', () => ({
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
listReceivedShares: vi.fn(),
|
||||
listShareOnProfile: vi.fn(),
|
||||
listShares: vi.fn(),
|
||||
revokeShare: vi.fn(),
|
||||
unlistShareFromProfile: vi.fn(),
|
||||
setSharePrivacy: vi.fn(),
|
||||
}))
|
||||
|
||||
function share(overrides: Partial<ShareListItem>): ShareListItem {
|
||||
@@ -66,7 +65,7 @@ function share(overrides: Partial<ShareListItem>): ShareListItem {
|
||||
views: 2,
|
||||
downloads: 1,
|
||||
status: 'active',
|
||||
listedAt: null,
|
||||
private: false,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
matter: {
|
||||
name: 'Public file.pdf',
|
||||
@@ -79,12 +78,12 @@ function share(overrides: Partial<ShareListItem>): ShareListItem {
|
||||
}
|
||||
|
||||
const shares = [
|
||||
share({ token: 'unlisted-token', matter: { name: 'Unlisted.pdf', type: 'application/pdf', dirtype: 0 } }),
|
||||
share({ token: 'public-token', matter: { name: 'Public.pdf', type: 'application/pdf', dirtype: 0 } }),
|
||||
share({
|
||||
id: 'share-2',
|
||||
token: 'listed-token',
|
||||
listedAt: '2026-07-23T01:00:00.000Z',
|
||||
matter: { name: 'Listed folder', type: 'folder', dirtype: 1 },
|
||||
token: 'private-token',
|
||||
private: true,
|
||||
matter: { name: 'Private folder', type: 'folder', dirtype: 1 },
|
||||
}),
|
||||
share({
|
||||
id: 'share-3',
|
||||
@@ -106,8 +105,7 @@ function renderPage() {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listShares).mockResolvedValue({ items: shares, total: shares.length, page: 1, pageSize: 20 })
|
||||
vi.mocked(listReceivedShares).mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 20 })
|
||||
vi.mocked(listShareOnProfile).mockResolvedValue({ listedAt: '2026-07-23T02:00:00.000Z' })
|
||||
vi.mocked(unlistShareFromProfile).mockResolvedValue(undefined)
|
||||
vi.mocked(setSharePrivacy).mockResolvedValue({ private: true })
|
||||
vi.mocked(revokeShare).mockResolvedValue({} as never)
|
||||
})
|
||||
|
||||
@@ -116,39 +114,41 @@ afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('authenticated Shares profile listing actions', () => {
|
||||
it('lists an eligible landing share and unlists an already listed share', async () => {
|
||||
describe('authenticated Shares privacy actions', () => {
|
||||
it('makes a public share private and a private share public', async () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Unlisted.pdf')).toBeTruthy()
|
||||
fireEvent.click(screen.getAllByTitle('shares.listOnProfile')[0])
|
||||
await waitFor(() => expect(listShareOnProfile).toHaveBeenCalledWith('unlisted-token'))
|
||||
expect(await screen.findByText('Public.pdf')).toBeTruthy()
|
||||
const publicButton = screen
|
||||
.getAllByTitle('shares.makePrivate')
|
||||
.find((button) => button.closest('tr')?.textContent?.includes('Public.pdf'))
|
||||
fireEvent.click(publicButton!)
|
||||
await waitFor(() => expect(setSharePrivacy).toHaveBeenCalledWith('public-token', true))
|
||||
|
||||
fireEvent.click(screen.getByTitle('shares.unlistFromProfile'))
|
||||
await waitFor(() => expect(unlistShareFromProfile).toHaveBeenCalledWith('listed-token'))
|
||||
fireEvent.click(screen.getByTitle('shares.makePublic'))
|
||||
await waitFor(() => expect(setSharePrivacy).toHaveBeenCalledWith('private-token', false))
|
||||
})
|
||||
|
||||
it('disables profile listing for an ineligible direct share', async () => {
|
||||
it('disables privacy changes for an ineligible direct share', async () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Direct file.pdf')).toBeTruthy()
|
||||
const listButtons = screen.getAllByTitle('shares.listOnProfile')
|
||||
const directButton = listButtons.find((button) => button.closest('tr')?.textContent?.includes('Direct file.pdf'))
|
||||
const privacyButtons = screen.getAllByTitle('shares.makePrivate')
|
||||
const directButton = privacyButtons.find((button) => button.closest('tr')?.textContent?.includes('Direct file.pdf'))
|
||||
|
||||
expect(directButton?.hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(directButton!)
|
||||
expect(listShareOnProfile).not.toHaveBeenCalled()
|
||||
expect(setSharePrivacy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps unlisting available when an already listed landing share has expired', async () => {
|
||||
const expiredListed = share({
|
||||
token: 'expired-listed-token',
|
||||
it('keeps privacy changes available when a landing share has expired', async () => {
|
||||
const expiredPublic = share({
|
||||
token: 'expired-public-token',
|
||||
expiresAt: '2000-01-01T00:00:00.000Z',
|
||||
listedAt: '1999-12-01T00:00:00.000Z',
|
||||
matter: { name: 'Expired listed.pdf', type: 'application/pdf', dirtype: 0 },
|
||||
matter: { name: 'Expired public.pdf', type: 'application/pdf', dirtype: 0 },
|
||||
})
|
||||
vi.mocked(listShares).mockResolvedValue({
|
||||
items: [expiredListed],
|
||||
items: [expiredPublic],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
@@ -156,10 +156,10 @@ describe('authenticated Shares profile listing actions', () => {
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Expired listed.pdf')).toBeTruthy()
|
||||
const unlistButton = screen.getByTitle('shares.unlistFromProfile')
|
||||
expect(unlistButton.hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(unlistButton)
|
||||
await waitFor(() => expect(unlistShareFromProfile).toHaveBeenCalledWith('expired-listed-token'))
|
||||
expect(await screen.findByText('Expired public.pdf')).toBeTruthy()
|
||||
const privateButton = screen.getByTitle('shares.makePrivate')
|
||||
expect(privateButton.hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(privateButton)
|
||||
await waitFor(() => expect(setSharePrivacy).toHaveBeenCalledWith('expired-public-token', true))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { ChevronDown, ClipboardCopy, FileIcon, FolderIcon, House, HousePlus, Share2, XCircle } from 'lucide-react'
|
||||
import { ChevronDown, ClipboardCopy, FileIcon, FolderIcon, Globe2, Lock, Share2, XCircle } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -18,14 +18,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useClipboard } from '@/hooks/use-clipboard'
|
||||
import {
|
||||
listReceivedShares,
|
||||
listShareOnProfile,
|
||||
listShares,
|
||||
revokeShare,
|
||||
type ShareListItem,
|
||||
unlistShareFromProfile,
|
||||
} from '@/lib/api'
|
||||
import { listReceivedShares, listShares, revokeShare, type ShareListItem, setSharePrivacy } from '@/lib/api'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/shares/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -53,20 +46,8 @@ function computeDisplayStatus(share: ShareListItem): 'active' | 'revoked' | 'exp
|
||||
return 'active'
|
||||
}
|
||||
|
||||
function canListOnProfile(share: ShareListItem): boolean {
|
||||
return (
|
||||
share.kind === 'landing' &&
|
||||
share.recipientCount === 0 &&
|
||||
computeDisplayStatus(share) === 'active' &&
|
||||
(share.downloadLimit == null || share.downloads < share.downloadLimit)
|
||||
)
|
||||
}
|
||||
|
||||
function canChangeProfileListing(share: ShareListItem): boolean {
|
||||
if (share.listedAt != null) {
|
||||
return share.kind === 'landing' && share.recipientCount === 0 && share.status !== 'revoked'
|
||||
}
|
||||
return canListOnProfile(share)
|
||||
function canChangePrivacy(share: ShareListItem): boolean {
|
||||
return share.kind === 'landing' && share.recipientCount === 0 && share.status !== 'revoked'
|
||||
}
|
||||
|
||||
export function SharesPage() {
|
||||
@@ -99,16 +80,14 @@ export function SharesPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const profileListingMutation = useMutation({
|
||||
mutationFn: async ({ token, listed }: { token: string; listed: boolean }) => {
|
||||
if (listed) await listShareOnProfile(token)
|
||||
else await unlistShareFromProfile(token)
|
||||
},
|
||||
const privacyMutation = useMutation({
|
||||
mutationFn: ({ token, private: isPrivate }: { token: string; private: boolean }) =>
|
||||
setSharePrivacy(token, isPrivate),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shares'] })
|
||||
toast.success(t(variables.listed ? 'shares.profileListSuccess' : 'shares.profileUnlistSuccess'))
|
||||
toast.success(t(variables.private ? 'shares.makePrivateSuccess' : 'shares.makePublicSuccess'))
|
||||
},
|
||||
onError: () => toast.error(t('shares.profileListingError')),
|
||||
onError: () => toast.error(t('shares.privacyError')),
|
||||
})
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
@@ -273,12 +252,8 @@ export function SharesPage() {
|
||||
copy(url, 'shares.urlCopied')
|
||||
}}
|
||||
onRevoke={() => setRevokeTarget(share)}
|
||||
onToggleProfileListing={() =>
|
||||
profileListingMutation.mutate({ token: share.token, listed: share.listedAt == null })
|
||||
}
|
||||
profileListingPending={
|
||||
profileListingMutation.isPending && profileListingMutation.variables?.token === share.token
|
||||
}
|
||||
onTogglePrivacy={() => privacyMutation.mutate({ token: share.token, private: !share.private })}
|
||||
privacyPending={privacyMutation.isPending && privacyMutation.variables?.token === share.token}
|
||||
/>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
@@ -355,16 +330,16 @@ function ShareTableRow({
|
||||
onRowClick,
|
||||
onCopyUrl,
|
||||
onRevoke,
|
||||
onToggleProfileListing,
|
||||
profileListingPending,
|
||||
onTogglePrivacy,
|
||||
privacyPending,
|
||||
}: {
|
||||
share: ShareListItem
|
||||
displayStatus: 'active' | 'revoked' | 'expired'
|
||||
onRowClick: () => void
|
||||
onCopyUrl: () => void
|
||||
onRevoke: () => void
|
||||
onToggleProfileListing: () => void
|
||||
profileListingPending: boolean
|
||||
onTogglePrivacy: () => void
|
||||
privacyPending: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -431,11 +406,11 @@ function ShareTableRow({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={!canChangeProfileListing(share) || profileListingPending}
|
||||
onClick={onToggleProfileListing}
|
||||
title={t(share.listedAt ? 'shares.unlistFromProfile' : 'shares.listOnProfile')}
|
||||
disabled={!canChangePrivacy(share) || privacyPending}
|
||||
onClick={onTogglePrivacy}
|
||||
title={t(share.private ? 'shares.makePublic' : 'shares.makePrivate')}
|
||||
>
|
||||
{share.listedAt ? <House /> : <HousePlus />}
|
||||
{share.private ? <Globe2 /> : <Lock />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={onCopyUrl} title={t('shares.copyUrl')}>
|
||||
<ClipboardCopy />
|
||||
|
||||
@@ -63,7 +63,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('public user homepage', () => {
|
||||
it('loads the requested profile and links curated files and folders to the landing-share flow', async () => {
|
||||
it('loads the requested profile and links public files and folders to the landing-share flow', async () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Alice')).toBeTruthy()
|
||||
@@ -73,7 +73,7 @@ describe('public user homepage', () => {
|
||||
expect(screen.queryByText('profile.noShares')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the empty state when the profile has no curated shares', async () => {
|
||||
it('shows the empty state when the profile has no public shares', async () => {
|
||||
vi.mocked(getProfile).mockResolvedValue({ ...profile, shares: [] })
|
||||
|
||||
renderPage()
|
||||
|
||||
Reference in New Issue
Block a user