feat: Implement document permission handling and sharing features
This commit is contained in:
@@ -234,3 +234,110 @@ func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDocumentPermission returns the user's permission level for a document
|
||||||
|
func (h *DocumentHandler) GetDocumentPermission(c *gin.Context) {
|
||||||
|
documentID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
respondBadRequest(c, "Invalid document ID format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 先检查文档是否存在 (Good practice)
|
||||||
|
_, err = h.store.GetDocument(documentID)
|
||||||
|
if err != nil {
|
||||||
|
respondNotFound(c, "document")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := auth.GetUserFromContext(c)
|
||||||
|
shareToken := c.Query("share")
|
||||||
|
|
||||||
|
// 定义两个临时变量,用来存两边的结果
|
||||||
|
var userPerm string // 存 document_shares 的结果
|
||||||
|
var tokenPerm string // 存 share_token 的结果
|
||||||
|
|
||||||
|
// ====================================================
|
||||||
|
// 步骤 A: 检查个人权限 (Base Permission)
|
||||||
|
// ====================================================
|
||||||
|
if userID != nil {
|
||||||
|
perm, err := h.store.GetUserPermission(c.Request.Context(), documentID, *userID)
|
||||||
|
if err != nil {
|
||||||
|
respondInternalError(c, "Failed to get user permission", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userPerm = perm
|
||||||
|
// ⚠️ 注意:如果 perm 是空,这里不报错!继续往下走!
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================
|
||||||
|
// 步骤 B: 检查 Token 权限 (Upgrade Permission)
|
||||||
|
// ====================================================
|
||||||
|
if shareToken != "" {
|
||||||
|
// 先验证 Token 是否有效
|
||||||
|
valid, err := h.store.ValidateShareToken(c.Request.Context(), documentID, shareToken)
|
||||||
|
if err != nil {
|
||||||
|
respondInternalError(c, "Failed to validate token", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有 Token 有效才去取权限
|
||||||
|
if valid {
|
||||||
|
p, err := h.store.GetShareLinkPermission(c.Request.Context(), documentID)
|
||||||
|
if err != nil {
|
||||||
|
respondInternalError(c, "Failed to get token permission", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenPerm = p
|
||||||
|
// 处理数据库老数据的 fallback
|
||||||
|
if tokenPerm == "" { tokenPerm = "view" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================
|
||||||
|
// 步骤 C: ⚡️ 权限合并与计算 (The Brain)
|
||||||
|
// ====================================================
|
||||||
|
|
||||||
|
finalPermission := ""
|
||||||
|
role := "viewer" // 默认角色
|
||||||
|
|
||||||
|
// 1. 如果是 Owner,无敌,直接返回
|
||||||
|
if userPerm == "owner" {
|
||||||
|
finalPermission = "edit"
|
||||||
|
role = "owner"
|
||||||
|
// 直接返回,不用看 Token 了
|
||||||
|
c.JSON(http.StatusOK, models.PermissionResponse{
|
||||||
|
Permission: finalPermission,
|
||||||
|
Role: role,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 比较 User 和 Token,取最大值
|
||||||
|
// 逻辑:只要任意一边给了 "edit",那就是 "edit"
|
||||||
|
if userPerm == "edit" || tokenPerm == "edit" {
|
||||||
|
finalPermission = "edit"
|
||||||
|
role = "editor"
|
||||||
|
} else if userPerm == "view" || tokenPerm == "view" {
|
||||||
|
finalPermission = "view"
|
||||||
|
role = "viewer"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================
|
||||||
|
// 步骤 D: 最终判决
|
||||||
|
// ====================================================
|
||||||
|
if finalPermission == "" {
|
||||||
|
// 既没个人权限,Token 也不对(或者没 Token)
|
||||||
|
if userID == nil {
|
||||||
|
respondUnauthorized(c, "Authentication required") // 没登录且没Token
|
||||||
|
} else {
|
||||||
|
respondForbidden(c, "You don't have permission") // 登录了但没权限
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.PermissionResponse{
|
||||||
|
Permission: finalPermission,
|
||||||
|
Role: role,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -243,6 +243,16 @@ func (h *ShareHandler) GetShareLink(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the permission for the share link
|
||||||
|
permission, err := h.store.GetShareLinkPermission(c.Request.Context(), documentID)
|
||||||
|
if err != nil {
|
||||||
|
respondInternalError(c, "Failed to get share link permission", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if permission == "" {
|
||||||
|
permission = "edit" // Default fallback
|
||||||
|
}
|
||||||
|
|
||||||
frontendURL := os.Getenv("FRONTEND_URL")
|
frontendURL := os.Getenv("FRONTEND_URL")
|
||||||
if frontendURL == "" {
|
if frontendURL == "" {
|
||||||
frontendURL = "http://localhost:5173"
|
frontendURL = "http://localhost:5173"
|
||||||
@@ -253,6 +263,7 @@ func (h *ShareHandler) GetShareLink(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"url": shareURL,
|
"url": shareURL,
|
||||||
"token": token,
|
"token": token,
|
||||||
|
"permission": permission,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,18 +114,35 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If authenticated with JWT, check document permissions
|
// Determine permission level
|
||||||
|
var permission string
|
||||||
if userID != nil {
|
if userID != nil {
|
||||||
canView, err := wsh.store.CanViewDocument(c.Request.Context(), documentID, *userID)
|
// Authenticated user - get their permission level
|
||||||
|
perm, err := wsh.store.GetUserPermission(c.Request.Context(), documentID, *userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error checking permissions: %v", err)
|
log.Printf("Error getting user permission: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !canView {
|
if perm == "" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this document"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this document"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
permission = perm
|
||||||
|
} else {
|
||||||
|
// Share token user - get share link permission
|
||||||
|
perm, err := wsh.store.GetShareLinkPermission(c.Request.Context(), documentID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting share link permission: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if perm == "" {
|
||||||
|
// Share link doesn't exist or document isn't public
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Invalid share link"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
permission = perm
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade connection
|
// Upgrade connection
|
||||||
@@ -135,9 +152,9 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create client with user information
|
// Create client with user information and permission
|
||||||
clientID := uuid.New().String()
|
clientID := uuid.New().String()
|
||||||
client := hub.NewClient(clientID, userID, userName, userAvatar, conn, wsh.hub, roomID)
|
client := hub.NewClient(clientID, userID, userName, userAvatar, permission, conn, wsh.hub, roomID)
|
||||||
|
|
||||||
// Register client
|
// Register client
|
||||||
wsh.hub.Register <- client
|
wsh.hub.Register <- client
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Client struct {
|
|||||||
UserID *uuid.UUID // Authenticated user ID (nil for public share access)
|
UserID *uuid.UUID // Authenticated user ID (nil for public share access)
|
||||||
UserName string // User's display name for presence
|
UserName string // User's display name for presence
|
||||||
UserAvatar *string // User's avatar URL for presence
|
UserAvatar *string // User's avatar URL for presence
|
||||||
|
Permission string // User's permission level: "owner", "edit", "view"
|
||||||
Conn *websocket.Conn
|
Conn *websocket.Conn
|
||||||
send chan []byte
|
send chan []byte
|
||||||
sendMu sync.Mutex
|
sendMu sync.Mutex
|
||||||
@@ -269,7 +270,9 @@ func (c *Client) ReadPump() {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if len(message) > 0 && message[0] == 1 {
|
||||||
|
log.Printf("DEBUG: 收到 Awareness (光标) 消息 from User %s Permission: %s", c.ID, c.Permission)
|
||||||
|
}
|
||||||
// ==========================================================
|
// ==========================================================
|
||||||
// 1. 偷听逻辑 (Sniff) - 必须放在转发之前!
|
// 1. 偷听逻辑 (Sniff) - 必须放在转发之前!
|
||||||
// ==========================================================
|
// ==========================================================
|
||||||
@@ -288,7 +291,32 @@ func (c *Client) ReadPump() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================
|
// ==========================================================
|
||||||
// 2. 转发逻辑 (Broadcast) - 恢复协作功能
|
// 2. 权限检查 - 只有编辑权限的用户才能广播消息
|
||||||
|
// ==========================================================
|
||||||
|
// ==========================================================
|
||||||
|
// 2. 权限检查 - 精细化拦截 (Fine-grained Permission Check)
|
||||||
|
// ==========================================================
|
||||||
|
if c.Permission == "view" {
|
||||||
|
// Yjs Protocol:
|
||||||
|
// message[0]: MessageType (0=Sync, 1=Awareness)
|
||||||
|
// message[1]: SyncMessageType (0=Step1, 1=Step2, 2=Update)
|
||||||
|
|
||||||
|
// 只有当消息是 "Sync Update" (修改文档) 时,才拦截
|
||||||
|
isSyncMessage := len(message) > 0 && message[0] == 0
|
||||||
|
isUpdateOp := len(message) > 1 && message[1] == 2
|
||||||
|
|
||||||
|
if isSyncMessage && isUpdateOp {
|
||||||
|
log.Printf("🛡️ [Security] Blocked unauthorized WRITE from view-only user: %s", c.ID)
|
||||||
|
continue // ❌ 拦截修改
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 放行:
|
||||||
|
// 1. Awareness (光标): message[0] == 1
|
||||||
|
// 2. SyncStep1/2 (握手加载文档): message[1] == 0 or 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// 3. 转发逻辑 (Broadcast) - 恢复协作功能
|
||||||
// ==========================================================
|
// ==========================================================
|
||||||
if messageType == websocket.BinaryMessage {
|
if messageType == websocket.BinaryMessage {
|
||||||
// 注意:这里要检查 channel 是否已满,避免阻塞导致 ReadPump 卡死
|
// 注意:这里要检查 channel 是否已满,避免阻塞导致 ReadPump 卡死
|
||||||
@@ -341,12 +369,13 @@ func (c *Client) WritePump() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func NewClient(id string, userID *uuid.UUID, userName string, userAvatar *string, conn *websocket.Conn, hub *Hub, roomID string) *Client {
|
func NewClient(id string, userID *uuid.UUID, userName string, userAvatar *string, permission string, conn *websocket.Conn, hub *Hub, roomID string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
ID: id,
|
ID: id,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
UserName: userName,
|
UserName: userName,
|
||||||
UserAvatar: userAvatar,
|
UserAvatar: userAvatar,
|
||||||
|
Permission: permission,
|
||||||
Conn: conn,
|
Conn: conn,
|
||||||
send: make(chan []byte, 1024),
|
send: make(chan []byte, 1024),
|
||||||
hub: hub,
|
hub: hub,
|
||||||
|
|||||||
@@ -28,3 +28,9 @@ type DocumentShareWithUser struct {
|
|||||||
DocumentShare
|
DocumentShare
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PermissionResponse represents the user's permission level for a document
|
||||||
|
type PermissionResponse struct {
|
||||||
|
Permission string `json:"permission"` // "view" or "edit"
|
||||||
|
Role string `json:"role"` // "owner", "editor", or "viewer"
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ type Store interface {
|
|||||||
ValidateShareToken(ctx context.Context, documentID uuid.UUID, token string) (bool, error)
|
ValidateShareToken(ctx context.Context, documentID uuid.UUID, token string) (bool, error)
|
||||||
RevokeShareToken(ctx context.Context, documentID uuid.UUID) error
|
RevokeShareToken(ctx context.Context, documentID uuid.UUID) error
|
||||||
GetShareToken(ctx context.Context, documentID uuid.UUID) (string, bool, error)
|
GetShareToken(ctx context.Context, documentID uuid.UUID) (string, bool, error)
|
||||||
|
GetUserPermission(ctx context.Context, documentID, userID uuid.UUID) (string, error)
|
||||||
|
GetShareLinkPermission(ctx context.Context, documentID uuid.UUID) (string, error)
|
||||||
|
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,16 +129,16 @@ func (s *PostgresStore) GenerateShareToken(ctx context.Context, documentID uuid.
|
|||||||
}
|
}
|
||||||
token := base64.URLEncoding.EncodeToString(tokenBytes)
|
token := base64.URLEncoding.EncodeToString(tokenBytes)
|
||||||
|
|
||||||
// Update document with share token
|
// Update document with share token and permission
|
||||||
query := `
|
query := `
|
||||||
UPDATE documents
|
UPDATE documents
|
||||||
SET share_token = $1, is_public = true, updated_at = NOW()
|
SET share_token = $1, share_permission = $2, is_public = true, updated_at = NOW()
|
||||||
WHERE id = $2
|
WHERE id = $3
|
||||||
RETURNING share_token
|
RETURNING share_token
|
||||||
`
|
`
|
||||||
|
|
||||||
var shareToken string
|
var shareToken string
|
||||||
err := s.db.QueryRowContext(ctx, query, token, documentID).Scan(&shareToken)
|
err := s.db.QueryRowContext(ctx, query, token, permission, documentID).Scan(&shareToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set share token: %w", err)
|
return "", fmt.Errorf("failed to set share token: %w", err)
|
||||||
}
|
}
|
||||||
@@ -198,3 +198,45 @@ func (s *PostgresStore) GetShareToken(ctx context.Context, documentID uuid.UUID)
|
|||||||
|
|
||||||
return token, true, nil
|
return token, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserPermission returns the permission level for a user on a document
|
||||||
|
// Returns "owner", "edit", "view", or "" if no access
|
||||||
|
func (s *PostgresStore) GetUserPermission(ctx context.Context, documentID, userID uuid.UUID) (string, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS(SELECT 1 FROM documents WHERE id = $1 AND owner_id = $2) THEN 'owner'
|
||||||
|
WHEN EXISTS(SELECT 1 FROM document_shares WHERE document_id = $1 AND user_id = $2 AND permission = 'edit') THEN 'edit'
|
||||||
|
WHEN EXISTS(SELECT 1 FROM document_shares WHERE document_id = $1 AND user_id = $2 AND permission = 'view') THEN 'view'
|
||||||
|
ELSE ''
|
||||||
|
END as permission
|
||||||
|
`
|
||||||
|
|
||||||
|
var permission string
|
||||||
|
err := s.db.QueryRowContext(ctx, query, documentID, userID).Scan(&permission)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get user permission: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return permission, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetShareLinkPermission returns the permission level for a public share link
|
||||||
|
// Returns the permission from documents.share_permission or "" if not public
|
||||||
|
func (s *PostgresStore) GetShareLinkPermission(ctx context.Context, documentID uuid.UUID) (string, error) {
|
||||||
|
query := `
|
||||||
|
SELECT COALESCE(share_permission, 'edit') FROM documents
|
||||||
|
WHERE id = $1 AND is_public = true AND share_token IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
var permission string
|
||||||
|
err := s.db.QueryRowContext(ctx, query, documentID).Scan(&permission)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get share link permission: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return permission, nil
|
||||||
|
}
|
||||||
12
backend/scripts/004_add_share_link_permission.sql
Normal file
12
backend/scripts/004_add_share_link_permission.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Migration: Add permission column for public share links
|
||||||
|
-- Dependencies: Run after 003_add_public_sharing.sql
|
||||||
|
-- Purpose: Store permission level (view/edit) for public share links
|
||||||
|
|
||||||
|
-- Add permission column to documents table
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS share_permission VARCHAR(20) DEFAULT 'edit' CHECK (share_permission IN ('view', 'edit'));
|
||||||
|
|
||||||
|
-- Create index for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_share_permission ON documents(share_permission) WHERE is_public = true;
|
||||||
|
|
||||||
|
-- Documentation
|
||||||
|
COMMENT ON COLUMN documents.share_permission IS 'Permission level for public share link: view (read-only) or edit (read-write). Defaults to edit for backward compatibility.';
|
||||||
@@ -13,6 +13,11 @@ export type CreateDocumentRequest = {
|
|||||||
type: "editor" | "kanban";
|
type: "editor" | "kanban";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PermissionResponse = {
|
||||||
|
permission: "view" | "edit";
|
||||||
|
role: "owner" | "editor" | "viewer";
|
||||||
|
}
|
||||||
|
|
||||||
export const documentsApi = {
|
export const documentsApi = {
|
||||||
// List all documents
|
// List all documents
|
||||||
list: async (): Promise<{ documents: DocumentType[]; total: number }> => {
|
list: async (): Promise<{ documents: DocumentType[]; total: number }> => {
|
||||||
@@ -67,4 +72,15 @@ export const documentsApi = {
|
|||||||
});
|
});
|
||||||
if (!response.ok) throw new Error("Failed to update document state");
|
if (!response.ok) throw new Error("Failed to update document state");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get user's permission for a document
|
||||||
|
getPermission: async (id: string, shareToken?: string): Promise<PermissionResponse> => {
|
||||||
|
const url = shareToken
|
||||||
|
? `${API_BASE_URL}/documents/${id}/permission?share=${shareToken}`
|
||||||
|
: `${API_BASE_URL}/documents/${id}/permission`;
|
||||||
|
|
||||||
|
const response = await authFetch(url);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch document permission");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -8,9 +8,12 @@ import Toolbar from "./Toolbar.tsx";
|
|||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
providers: YjsProviders;
|
providers: YjsProviders;
|
||||||
|
permission: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Editor = ({ providers }: EditorProps) => {
|
const Editor = ({ providers, permission }: EditorProps) => {
|
||||||
|
const isEditable = permission !== "view";
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
@@ -28,6 +31,7 @@ const Editor = ({ providers }: EditorProps) => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
content: "",
|
content: "",
|
||||||
|
editable: isEditable,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,8 +53,11 @@ const Editor = ({ providers }: EditorProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-container">
|
<div className="editor-container">
|
||||||
<Toolbar editor={editor} />
|
{isEditable && <Toolbar editor={editor} />}
|
||||||
<EditorContent editor={editor} className="editor-content" />
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className={`editor-content ${!isEditable ? 'view-only' : ''}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import './ShareModal.css';
|
|||||||
interface ShareModalProps {
|
interface ShareModalProps {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
currentPermission?: string;
|
||||||
|
currentRole?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShareModal({ documentId, onClose }: ShareModalProps) {
|
function ShareModal({ documentId, onClose, currentPermission, currentRole }: ShareModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'link'>('users');
|
const [activeTab, setActiveTab] = useState<'users' | 'link'>('users');
|
||||||
const [shares, setShares] = useState<DocumentShareWithUser[]>([]);
|
const [shares, setShares] = useState<DocumentShareWithUser[]>([]);
|
||||||
const [shareLink, setShareLink] = useState<ShareLink | null>(null);
|
const [shareLink, setShareLink] = useState<ShareLink | null>(null);
|
||||||
@@ -149,6 +151,31 @@ function ShareModal({ documentId, onClose }: ShareModalProps) {
|
|||||||
<button className="close-button" onClick={onClose}>×</button>
|
<button className="close-button" onClick={onClose}>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{currentRole && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
backgroundColor: currentRole === 'owner' ? '#e8f5e9' : currentPermission === 'edit' ? '#fff3e0' : '#e3f2fd',
|
||||||
|
borderRadius: '8px',
|
||||||
|
margin: '16px 0',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '18px' }}>
|
||||||
|
{currentRole === 'owner' ? '👑' : currentPermission === 'edit' ? '✏️' : '👁️'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{currentRole === 'owner' && 'You are the owner'}
|
||||||
|
{currentRole === 'editor' && 'You have edit access'}
|
||||||
|
{currentRole === 'viewer' && 'You have view-only access'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
<button
|
<button
|
||||||
className={`tab ${activeTab === 'users' ? 'active' : ''}`}
|
className={`tab ${activeTab === 'users' ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -7,15 +7,36 @@ import {
|
|||||||
type YjsProviders,
|
type YjsProviders,
|
||||||
} from "../lib/yjs";
|
} from "../lib/yjs";
|
||||||
import { useAutoSave } from "./useAutoSave";
|
import { useAutoSave } from "./useAutoSave";
|
||||||
|
import { documentsApi } from "../api/document";
|
||||||
|
|
||||||
export const useYjsDocument = (documentId: string, shareToken?: string) => {
|
export const useYjsDocument = (documentId: string, shareToken?: string) => {
|
||||||
const { user, token } = useAuth();
|
const { user, token } = useAuth();
|
||||||
const [providers, setProviders] = useState<YjsProviders | null>(null);
|
const [providers, setProviders] = useState<YjsProviders | null>(null);
|
||||||
const [synced, setSynced] = useState(false);
|
const [synced, setSynced] = useState(false);
|
||||||
|
const [permission, setPermission] = useState<string | null>(null);
|
||||||
|
const [role, setRole] = useState<string | null>(null);
|
||||||
|
|
||||||
// Enable auto-save when providers are ready
|
// Enable auto-save when providers are ready
|
||||||
useAutoSave(documentId, providers?.ydoc || null);
|
useAutoSave(documentId, providers?.ydoc || null);
|
||||||
|
|
||||||
|
// Fetch permission when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (!documentId) return;
|
||||||
|
|
||||||
|
const fetchPermission = async () => {
|
||||||
|
try {
|
||||||
|
const permData = await documentsApi.getPermission(documentId, shareToken);
|
||||||
|
setPermission(permData.permission);
|
||||||
|
setRole(permData.role);
|
||||||
|
console.log(`📋 Permission loaded: ${permData.role} (${permData.permission})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch permission:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPermission();
|
||||||
|
}, [documentId, shareToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wait for auth (unless we have a share token for public access)
|
// Wait for auth (unless we have a share token for public access)
|
||||||
if (!shareToken && (!user || !token)) {
|
if (!shareToken && (!user || !token)) {
|
||||||
@@ -73,6 +94,7 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
|
|||||||
color: getColorFromUserId(currentId),
|
color: getColorFromUserId(currentId),
|
||||||
avatar: currentAvatar,
|
avatar: currentAvatar,
|
||||||
});
|
});
|
||||||
|
console.log(currentAvatar)
|
||||||
|
|
||||||
// NEW: Add awareness event logging
|
// NEW: Add awareness event logging
|
||||||
const handleAwarenessChange = ({
|
const handleAwarenessChange = ({
|
||||||
@@ -154,5 +176,5 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
|
|||||||
}, [documentId, user, token, shareToken]);
|
}, [documentId, user, token, shareToken]);
|
||||||
|
|
||||||
|
|
||||||
return { providers, synced };
|
return { providers, synced, permission, role };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const EditorPage = () => {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const shareToken = searchParams.get('share') || undefined;
|
const shareToken = searchParams.get('share') || undefined;
|
||||||
const { providers, synced } = useYjsDocument(id!, shareToken);
|
const { providers, synced, permission, role } = useYjsDocument(id!, shareToken);
|
||||||
const [showShareModal, setShowShareModal] = useState(false);
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
|
|
||||||
if (!providers) {
|
if (!providers) {
|
||||||
@@ -24,6 +24,12 @@ const EditorPage = () => {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<button onClick={() => navigate("/")}>← Back to Home</button>
|
<button onClick={() => navigate("/")}>← Back to Home</button>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
{permission === "view" && (
|
||||||
|
<div className="view-only-badge" style={{ display: 'flex', alignItems: 'center', gap: '4px', padding: '4px 12px', backgroundColor: '#f0f0f0', borderRadius: '4px', fontSize: '14px' }}>
|
||||||
|
<span>👁️</span>
|
||||||
|
<span>View only</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="sync-status">
|
<div className="sync-status">
|
||||||
{synced ? "✓ Synced" : "⟳ Syncing..."}
|
{synced ? "✓ Synced" : "⟳ Syncing..."}
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +43,7 @@ const EditorPage = () => {
|
|||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
<div className="main-area">
|
<div className="main-area">
|
||||||
<Editor providers={providers} />
|
<Editor providers={providers} permission={permission} />
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
<UserList awareness={providers.awareness} />
|
<UserList awareness={providers.awareness} />
|
||||||
@@ -45,7 +51,12 @@ const EditorPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showShareModal && (
|
{showShareModal && (
|
||||||
<ShareModal documentId={id!} onClose={() => setShowShareModal(false)} />
|
<ShareModal
|
||||||
|
documentId={id!}
|
||||||
|
onClose={() => setShowShareModal(false)}
|
||||||
|
currentPermission={permission || undefined}
|
||||||
|
currentRole={role || undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user