mirror of
https://github.com/btouchard/ackify.git
synced 2026-02-11 00:08:37 -06:00
fix: restrict signature list visibility to document owner/admin
- Signatures endpoint now returns only user's own signature for non-owner/admin - Owner/admin can see all signatures, others see only their own (if signed) - Added signatureCount to FindOrCreateDocument response for public count display - Frontend shows signature count header even when detailed list is restricted Closes #20
This commit is contained in:
@@ -324,6 +324,9 @@ func (h *Handler) HandleGetDocument(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// HandleGetDocumentSignatures handles GET /api/v1/documents/{docId}/signatures
|
||||
// Returns the detailed signature list only for document owner or admin.
|
||||
// For authenticated users who are not owner/admin, returns only their own signature (if they signed).
|
||||
// Non-authenticated users receive an empty list (the count remains available via DocumentDTO).
|
||||
func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "docId")
|
||||
if docID == "" {
|
||||
@@ -333,6 +336,24 @@ func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Req
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Check if user can view the detailed list
|
||||
user, authenticated := shared.GetUserFromContext(ctx)
|
||||
|
||||
// Retrieve document to get CreatedBy
|
||||
doc, err := h.documentService.GetByDocID(ctx, docID)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to get document", "doc_id", docID, "error", err.Error())
|
||||
shared.WriteInternalError(w)
|
||||
return
|
||||
}
|
||||
if doc == nil {
|
||||
shared.WriteError(w, http.StatusNotFound, shared.ErrCodeNotFound, "Document not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Owner/Admin can see all signatures
|
||||
canViewAll := authenticated && user != nil && h.authorizer.CanManageDocument(ctx, user.Email, doc.CreatedBy)
|
||||
|
||||
signatures, err := h.signatureService.GetDocumentSignatures(ctx, docID)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to get signatures",
|
||||
@@ -342,13 +363,28 @@ func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to DTOs
|
||||
dtos := make([]SignatureDTO, len(signatures))
|
||||
for i := range signatures {
|
||||
dtos[i] = signatureToDTO(signatures[i])
|
||||
// If owner/admin, return all signatures
|
||||
if canViewAll {
|
||||
dtos := make([]SignatureDTO, len(signatures))
|
||||
for i := range signatures {
|
||||
dtos[i] = signatureToDTO(signatures[i])
|
||||
}
|
||||
shared.WriteJSON(w, http.StatusOK, dtos)
|
||||
return
|
||||
}
|
||||
|
||||
shared.WriteJSON(w, http.StatusOK, dtos)
|
||||
// For authenticated users (not owner/admin), return only their own signature if they signed
|
||||
if authenticated && user != nil {
|
||||
for _, sig := range signatures {
|
||||
if sig.UserEmail == user.Email {
|
||||
shared.WriteJSON(w, http.StatusOK, []SignatureDTO{signatureToDTO(sig)})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-authenticated or user hasn't signed → return empty list
|
||||
shared.WriteJSON(w, http.StatusOK, []SignatureDTO{})
|
||||
}
|
||||
|
||||
// PublicExpectedSigner represents an expected signer in public API (minimal info)
|
||||
@@ -358,6 +394,8 @@ type PublicExpectedSigner struct {
|
||||
}
|
||||
|
||||
// HandleGetExpectedSigners handles GET /api/v1/documents/{docId}/expected-signers
|
||||
// Returns the expected signers list only for document owner or admin.
|
||||
// Non-authorized users receive an empty list.
|
||||
func (h *Handler) HandleGetExpectedSigners(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
docID := chi.URLParam(r, "docId")
|
||||
@@ -367,6 +405,29 @@ func (h *Handler) HandleGetExpectedSigners(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user can view the detailed list
|
||||
user, authenticated := shared.GetUserFromContext(ctx)
|
||||
|
||||
// Retrieve document to get CreatedBy
|
||||
doc, err := h.documentService.GetByDocID(ctx, docID)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Failed to get document", "doc_id", docID, "error", err.Error())
|
||||
shared.WriteInternalError(w)
|
||||
return
|
||||
}
|
||||
if doc == nil {
|
||||
shared.WriteError(w, http.StatusNotFound, shared.ErrCodeNotFound, "Document not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// If not authenticated or not authorized → return empty list
|
||||
canViewDetails := authenticated && user != nil && h.authorizer.CanManageDocument(ctx, user.Email, doc.CreatedBy)
|
||||
|
||||
if !canViewDetails {
|
||||
shared.WriteJSON(w, http.StatusOK, []PublicExpectedSigner{})
|
||||
return
|
||||
}
|
||||
|
||||
// Get expected signers (public version - without internal notes/metadata)
|
||||
signers, err := h.documentService.ListExpectedSigners(ctx, docID)
|
||||
if err != nil {
|
||||
@@ -421,6 +482,7 @@ type FindOrCreateDocumentResponse struct {
|
||||
VerifyChecksum bool `json:"verifyChecksum"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
IsNew bool `json:"isNew"`
|
||||
SignatureCount int `json:"signatureCount"`
|
||||
// Storage fields for uploaded documents
|
||||
StorageKey string `json:"storageKey,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
@@ -463,6 +525,12 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
|
||||
"doc_id", existingDoc.DocID,
|
||||
"reference", ref)
|
||||
|
||||
// Get signature count
|
||||
signatureCount := 0
|
||||
if sigs, err := h.signatureService.GetDocumentSignatures(ctx, existingDoc.DocID); err == nil {
|
||||
signatureCount = len(sigs)
|
||||
}
|
||||
|
||||
response := FindOrCreateDocumentResponse{
|
||||
DocID: existingDoc.DocID,
|
||||
URL: existingDoc.URL,
|
||||
@@ -476,6 +544,7 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
|
||||
VerifyChecksum: existingDoc.VerifyChecksum,
|
||||
CreatedAt: existingDoc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
IsNew: false,
|
||||
SignatureCount: signatureCount,
|
||||
StorageKey: existingDoc.StorageKey,
|
||||
MimeType: existingDoc.MimeType,
|
||||
}
|
||||
@@ -518,7 +587,7 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
|
||||
"reference", ref,
|
||||
"user_email", user.Email)
|
||||
|
||||
// Build response
|
||||
// Build response (new document has 0 signatures)
|
||||
response := FindOrCreateDocumentResponse{
|
||||
DocID: doc.DocID,
|
||||
URL: doc.URL,
|
||||
@@ -532,6 +601,7 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
|
||||
VerifyChecksum: doc.VerifyChecksum,
|
||||
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
IsNew: isNew,
|
||||
SignatureCount: 0,
|
||||
StorageKey: doc.StorageKey,
|
||||
MimeType: doc.MimeType,
|
||||
}
|
||||
|
||||
@@ -501,9 +501,16 @@ func TestHandler_HandleFindOrCreateDocument_FindExisting(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mockSigService := &mockSignatureService{
|
||||
getDocumentSignaturesFunc: func(ctx context.Context, docID string) ([]*models.Signature, error) {
|
||||
return []*models.Signature{testSignature}, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := &Handler{
|
||||
documentService: mockDocService,
|
||||
authorizer: newMockAuthorizer([]string{}, false),
|
||||
documentService: mockDocService,
|
||||
signatureService: mockSigService,
|
||||
authorizer: newMockAuthorizer([]string{}, false),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/find-or-create?ref=https://example.com/doc.pdf", nil)
|
||||
@@ -521,6 +528,7 @@ func TestHandler_HandleFindOrCreateDocument_FindExisting(t *testing.T) {
|
||||
|
||||
assert.Equal(t, testDoc.DocID, wrapper.Data.DocID)
|
||||
assert.False(t, wrapper.Data.IsNew, "Should not be new since document was found")
|
||||
assert.Equal(t, 1, wrapper.Data.SignatureCount, "Should have 1 signature")
|
||||
}
|
||||
|
||||
func TestHandler_HandleFindOrCreateDocument_CreateNew(t *testing.T) {
|
||||
|
||||
@@ -251,8 +251,13 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
|
||||
// Read-only document endpoints
|
||||
r.Get("/", documentsHandler.HandleListDocuments)
|
||||
r.Get("/{docId}", documentsHandler.HandleGetDocument)
|
||||
r.Get("/{docId}/signatures", documentsHandler.HandleGetDocumentSignatures)
|
||||
r.Get("/{docId}/expected-signers", documentsHandler.HandleGetExpectedSigners)
|
||||
|
||||
// Signatures and expected-signers: detailed list restricted to owner/admin
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apiMiddleware.OptionalAuth)
|
||||
r.Get("/{docId}/signatures", documentsHandler.HandleGetDocumentSignatures)
|
||||
r.Get("/{docId}/expected-signers", documentsHandler.HandleGetExpectedSigners)
|
||||
})
|
||||
|
||||
// Find or create document by reference (public for embed support, but with optional auth)
|
||||
r.Group(func(r chi.Router) {
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- Document info and signatures -->
|
||||
<div v-else-if="documentData" class="max-w-2xl mx-auto">
|
||||
<!-- Document header with signatures -->
|
||||
<div v-if="documentData.signatures.length > 0">
|
||||
<!-- Document header with signatures (only shown if user has access to view signatures) -->
|
||||
<div v-if="documentData.signatures && documentData.signatures.length > 0">
|
||||
<!-- Header Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 sm:p-5 mb-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
|
||||
@@ -604,8 +604,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Existing Confirmations (Below document on mobile, here on desktop) -->
|
||||
<div v-if="documentSignatures.length > 0" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-100 dark:border-slate-700">
|
||||
<!-- Show section if there are signatures (count from document, even if user can't see details) -->
|
||||
<div v-if="currentDocument.signatureCount > 0" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<div class="p-4 sm:p-6" :class="{ 'border-b border-slate-100 dark:border-slate-700': documentSignatures.length > 0 }">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Users :size="20" class="text-blue-600 dark:text-blue-400" />
|
||||
@@ -613,12 +614,13 @@ onMounted(async () => {
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('sign.confirmations.title') }}</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ t('sign.confirmations.count', { count: documentSignatures.length }, documentSignatures.length) }}
|
||||
{{ t('sign.confirmations.count', { count: currentDocument.signatureCount }, currentDocument.signatureCount) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 sm:p-6">
|
||||
<!-- Only show detailed list if user has access (owner/admin) -->
|
||||
<div v-if="documentSignatures.length > 0" class="p-4 sm:p-6">
|
||||
<SignatureList
|
||||
:signatures="documentSignatures"
|
||||
:loading="loadingSignatures"
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface FindOrCreateDocumentResponse {
|
||||
verifyChecksum: boolean
|
||||
createdAt: string
|
||||
isNew: boolean // true if created, false if found
|
||||
signatureCount: number
|
||||
// Storage fields for uploaded documents
|
||||
storageKey?: string
|
||||
mimeType?: string
|
||||
|
||||
Reference in New Issue
Block a user