This commit is contained in:
Benjamin
2025-12-27 22:16:54 +01:00
parent e4521d87c7
commit c2c096dd3c
49 changed files with 5866 additions and 1397 deletions

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@ client_secret*.json
/docker.secret
/samples/
/docs/reports/
/prompts/

View File

@@ -45,22 +45,18 @@ func main() {
"build_date", BuildDate,
"telemetry", cfg.Telemetry)
// Initialize DB
db, err := database.InitDB(ctx, database.Config{DSN: cfg.Database.DSN})
if err != nil {
log.Fatalf("failed to initialize database: %v", err)
}
// Initialize tenant provider
tenantProvider, err := tenant.NewSingleTenantProviderWithContext(ctx, db)
if err != nil {
log.Fatalf("failed to initialize tenant provider: %v", err)
}
// Create OAuth session repository
oauthSessionRepo := database.NewOAuthSessionRepository(db, tenantProvider)
// Create OAuth service (internal infrastructure)
var oauthService *auth.OauthService
if cfg.Auth.OAuthEnabled || cfg.Auth.MagicLinkEnabled {
oauthService = auth.NewOAuthService(auth.Config{
@@ -79,10 +75,7 @@ func main() {
})
}
// Create OAuth provider adapter
oauthProvider := webauth.NewOAuthProvider(oauthService, cfg.Auth.OAuthEnabled)
// Create Authorizer
authorizer := webauth.NewSimpleAuthorizer(cfg.App.AdminEmails, cfg.App.OnlyAdminCanCreate)
// === Build Server ===
@@ -97,7 +90,6 @@ func main() {
log.Fatalf("Failed to create server: %v", err)
}
// Start server
go func() {
log.Printf("Community Edition server starting on %s", server.GetAddr())
if err := server.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -105,7 +97,6 @@ func main() {
}
}()
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

View File

@@ -157,7 +157,6 @@ func ensureAppRole(db *sql.DB) error {
return nil
}
// Check if role exists
var exists bool
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'ackify_app')").Scan(&exists)
if err != nil {
@@ -165,14 +164,12 @@ func ensureAppRole(db *sql.DB) error {
}
if exists {
// Update password to ensure it matches environment
_, err = db.Exec(fmt.Sprintf("ALTER ROLE ackify_app WITH PASSWORD '%s'", escapePassword(password)))
if err != nil {
return fmt.Errorf("failed to update ackify_app password: %w", err)
}
log.Println("ackify_app role exists, password updated")
} else {
// Create the role with all necessary attributes
createSQL := fmt.Sprintf(`
CREATE ROLE ackify_app WITH
LOGIN

View File

@@ -23,6 +23,9 @@ type documentRepository interface {
List(ctx context.Context, limit, offset int) ([]*models.Document, error)
Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error)
Count(ctx context.Context, searchQuery string) (int, error)
ListByCreatedBy(ctx context.Context, createdBy string, limit, offset int) ([]*models.Document, error)
SearchByCreatedBy(ctx context.Context, createdBy, searchQuery string, limit, offset int) ([]*models.Document, error)
CountByCreatedBy(ctx context.Context, createdBy, searchQuery string) (int, error)
}
type docExpectedSignerRepository interface {
@@ -497,3 +500,18 @@ func (s *DocumentService) ListExpectedSigners(ctx context.Context, docID string)
}
return s.expectedSignerRepo.ListByDocID(ctx, docID)
}
// ListByCreatedBy retrieves a paginated list of documents created by a specific user
func (s *DocumentService) ListByCreatedBy(ctx context.Context, createdBy string, limit, offset int) ([]*models.Document, error) {
return s.repo.ListByCreatedBy(ctx, createdBy, limit, offset)
}
// SearchByCreatedBy performs a search query across documents created by a specific user
func (s *DocumentService) SearchByCreatedBy(ctx context.Context, createdBy, query string, limit, offset int) ([]*models.Document, error) {
return s.repo.SearchByCreatedBy(ctx, createdBy, query, limit, offset)
}
// CountByCreatedBy returns the total number of documents created by a specific user
func (s *DocumentService) CountByCreatedBy(ctx context.Context, createdBy, searchQuery string) (int, error) {
return s.repo.CountByCreatedBy(ctx, createdBy, searchQuery)
}

View File

@@ -16,6 +16,10 @@ type Document struct {
Checksum string `json:"checksum" db:"checksum"`
ChecksumAlgorithm string `json:"checksum_algorithm" db:"checksum_algorithm"`
Description string `json:"description" db:"description"`
ReadMode string `json:"read_mode" db:"read_mode"`
AllowDownload bool `json:"allow_download" db:"allow_download"`
RequireFullRead bool `json:"require_full_read" db:"require_full_read"`
VerifyChecksum bool `json:"verify_checksum" db:"verify_checksum"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
CreatedBy string `json:"created_by" db:"created_by"`
@@ -29,6 +33,10 @@ type DocumentInput struct {
Checksum string `json:"checksum"`
ChecksumAlgorithm string `json:"checksum_algorithm"`
Description string `json:"description"`
ReadMode string `json:"read_mode"`
AllowDownload *bool `json:"allow_download"`
RequireFullRead *bool `json:"require_full_read"`
VerifyChecksum *bool `json:"verify_checksum"`
}
// HasChecksum returns true if the document has a checksum configured

View File

@@ -31,9 +31,9 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod
}
query := `
INSERT INTO documents (tenant_id, doc_id, title, url, checksum, checksum_algorithm, description, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at
INSERT INTO documents (tenant_id, doc_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
`
// Use NULL for empty checksum fields to avoid constraint violation
@@ -46,6 +46,26 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod
checksumAlgorithm = "SHA-256"
}
// Handle read_mode with default
readMode := input.ReadMode
if readMode == "" {
readMode = "integrated"
}
// Handle boolean defaults
allowDownload := true
if input.AllowDownload != nil {
allowDownload = *input.AllowDownload
}
requireFullRead := false
if input.RequireFullRead != nil {
requireFullRead = *input.RequireFullRead
}
verifyChecksum := true
if input.VerifyChecksum != nil {
verifyChecksum = *input.VerifyChecksum
}
doc := &models.Document{}
err = dbctx.GetQuerier(ctx, r.db).QueryRowContext(
ctx,
@@ -57,6 +77,10 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod
checksum,
checksumAlgorithm,
input.Description,
readMode,
allowDownload,
requireFullRead,
verifyChecksum,
createdBy,
).Scan(
&doc.DocID,
@@ -66,6 +90,10 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod
&doc.Checksum,
&doc.ChecksumAlgorithm,
&doc.Description,
&doc.ReadMode,
&doc.AllowDownload,
&doc.RequireFullRead,
&doc.VerifyChecksum,
&doc.CreatedAt,
&doc.UpdatedAt,
&doc.CreatedBy,
@@ -84,7 +112,7 @@ func (r *DocumentRepository) Create(ctx context.Context, docID string, input mod
// RLS policy automatically filters by tenant_id
func (r *DocumentRepository) GetByDocID(ctx context.Context, docID string) (*models.Document, error) {
query := `
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
FROM documents
WHERE doc_id = $1 AND deleted_at IS NULL
`
@@ -98,6 +126,10 @@ func (r *DocumentRepository) GetByDocID(ctx context.Context, docID string) (*mod
&doc.Checksum,
&doc.ChecksumAlgorithm,
&doc.Description,
&doc.ReadMode,
&doc.AllowDownload,
&doc.RequireFullRead,
&doc.VerifyChecksum,
&doc.CreatedAt,
&doc.UpdatedAt,
&doc.CreatedBy,
@@ -126,7 +158,7 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re
case "url":
// Search by URL field (excluding soft-deleted)
query = `
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
FROM documents
WHERE url = $1 AND deleted_at IS NULL
LIMIT 1
@@ -136,7 +168,7 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re
case "path":
// Search by URL field (paths are also stored in url field, excluding soft-deleted)
query = `
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
FROM documents
WHERE url = $1 AND deleted_at IS NULL
LIMIT 1
@@ -146,7 +178,7 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re
case "reference":
// Search by doc_id (excluding soft-deleted)
query = `
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
FROM documents
WHERE doc_id = $1 AND deleted_at IS NULL
LIMIT 1
@@ -166,6 +198,10 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re
&doc.Checksum,
&doc.ChecksumAlgorithm,
&doc.Description,
&doc.ReadMode,
&doc.AllowDownload,
&doc.RequireFullRead,
&doc.VerifyChecksum,
&doc.CreatedAt,
&doc.UpdatedAt,
&doc.CreatedBy,
@@ -200,9 +236,9 @@ func (r *DocumentRepository) FindByReference(ctx context.Context, ref string, re
func (r *DocumentRepository) Update(ctx context.Context, docID string, input models.DocumentInput) (*models.Document, error) {
query := `
UPDATE documents
SET title = $2, url = $3, checksum = $4, checksum_algorithm = $5, description = $6
SET title = $2, url = $3, checksum = $4, checksum_algorithm = $5, description = $6, read_mode = $7, allow_download = $8, require_full_read = $9, verify_checksum = $10
WHERE doc_id = $1 AND deleted_at IS NULL
RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at
RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
`
// Use empty string for empty checksum fields (table has NOT NULL DEFAULT '')
@@ -212,6 +248,26 @@ func (r *DocumentRepository) Update(ctx context.Context, docID string, input mod
checksumAlgorithm = "SHA-256" // Default algorithm
}
// Handle read_mode with default
readMode := input.ReadMode
if readMode == "" {
readMode = "integrated"
}
// Handle boolean defaults
allowDownload := true
if input.AllowDownload != nil {
allowDownload = *input.AllowDownload
}
requireFullRead := false
if input.RequireFullRead != nil {
requireFullRead = *input.RequireFullRead
}
verifyChecksum := true
if input.VerifyChecksum != nil {
verifyChecksum = *input.VerifyChecksum
}
doc := &models.Document{}
err := dbctx.GetQuerier(ctx, r.db).QueryRowContext(
ctx,
@@ -222,6 +278,10 @@ func (r *DocumentRepository) Update(ctx context.Context, docID string, input mod
checksum,
checksumAlgorithm,
input.Description,
readMode,
allowDownload,
requireFullRead,
verifyChecksum,
).Scan(
&doc.DocID,
&doc.TenantID,
@@ -230,6 +290,10 @@ func (r *DocumentRepository) Update(ctx context.Context, docID string, input mod
&doc.Checksum,
&doc.ChecksumAlgorithm,
&doc.Description,
&doc.ReadMode,
&doc.AllowDownload,
&doc.RequireFullRead,
&doc.VerifyChecksum,
&doc.CreatedAt,
&doc.UpdatedAt,
&doc.CreatedBy,
@@ -256,16 +320,20 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i
}
query := `
INSERT INTO documents (tenant_id, doc_id, title, url, checksum, checksum_algorithm, description, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO documents (tenant_id, doc_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (doc_id) DO UPDATE SET
title = EXCLUDED.title,
url = EXCLUDED.url,
checksum = EXCLUDED.checksum,
checksum_algorithm = EXCLUDED.checksum_algorithm,
description = EXCLUDED.description,
read_mode = EXCLUDED.read_mode,
allow_download = EXCLUDED.allow_download,
require_full_read = EXCLUDED.require_full_read,
verify_checksum = EXCLUDED.verify_checksum,
deleted_at = NULL
RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at
RETURNING doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
`
// Use empty string for empty checksum fields (table has NOT NULL DEFAULT '')
@@ -275,6 +343,26 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i
checksumAlgorithm = "SHA-256" // Default algorithm
}
// Handle read_mode with default
readMode := input.ReadMode
if readMode == "" {
readMode = "integrated"
}
// Handle boolean defaults
allowDownload := true
if input.AllowDownload != nil {
allowDownload = *input.AllowDownload
}
requireFullRead := false
if input.RequireFullRead != nil {
requireFullRead = *input.RequireFullRead
}
verifyChecksum := true
if input.VerifyChecksum != nil {
verifyChecksum = *input.VerifyChecksum
}
doc := &models.Document{}
err = dbctx.GetQuerier(ctx, r.db).QueryRowContext(
ctx,
@@ -286,6 +374,10 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i
checksum,
checksumAlgorithm,
input.Description,
readMode,
allowDownload,
requireFullRead,
verifyChecksum,
createdBy,
).Scan(
&doc.DocID,
@@ -295,6 +387,10 @@ func (r *DocumentRepository) CreateOrUpdate(ctx context.Context, docID string, i
&doc.Checksum,
&doc.ChecksumAlgorithm,
&doc.Description,
&doc.ReadMode,
&doc.AllowDownload,
&doc.RequireFullRead,
&doc.VerifyChecksum,
&doc.CreatedAt,
&doc.UpdatedAt,
&doc.CreatedBy,
@@ -336,7 +432,7 @@ func (r *DocumentRepository) Delete(ctx context.Context, docID string) error {
// RLS policy automatically filters by tenant_id
func (r *DocumentRepository) List(ctx context.Context, limit, offset int) ([]*models.Document, error) {
query := `
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
FROM documents
WHERE deleted_at IS NULL
ORDER BY created_at DESC
@@ -361,6 +457,10 @@ func (r *DocumentRepository) List(ctx context.Context, limit, offset int) ([]*mo
&doc.Checksum,
&doc.ChecksumAlgorithm,
&doc.Description,
&doc.ReadMode,
&doc.AllowDownload,
&doc.RequireFullRead,
&doc.VerifyChecksum,
&doc.CreatedAt,
&doc.UpdatedAt,
&doc.CreatedBy,
@@ -385,7 +485,7 @@ func (r *DocumentRepository) List(ctx context.Context, limit, offset int) ([]*mo
// RLS policy automatically filters by tenant_id
func (r *DocumentRepository) Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error) {
searchQuery := `
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, created_at, updated_at, created_by, deleted_at
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
FROM documents
WHERE deleted_at IS NULL
AND (
@@ -417,6 +517,10 @@ func (r *DocumentRepository) Search(ctx context.Context, query string, limit, of
&doc.Checksum,
&doc.ChecksumAlgorithm,
&doc.Description,
&doc.ReadMode,
&doc.AllowDownload,
&doc.RequireFullRead,
&doc.VerifyChecksum,
&doc.CreatedAt,
&doc.UpdatedAt,
&doc.CreatedBy,
@@ -482,3 +586,160 @@ func (r *DocumentRepository) Count(ctx context.Context, searchQuery string) (int
logger.Logger.Debug("Document count completed", "count", count, "search", searchQuery)
return count, nil
}
// ListByCreatedBy retrieves paginated documents created by a specific user (excluding soft-deleted)
// RLS policy automatically filters by tenant_id
func (r *DocumentRepository) ListByCreatedBy(ctx context.Context, createdBy string, limit, offset int) ([]*models.Document, error) {
query := `
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
FROM documents
WHERE deleted_at IS NULL AND created_by = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := dbctx.GetQuerier(ctx, r.db).QueryContext(ctx, query, createdBy, limit, offset)
if err != nil {
logger.Logger.Error("Failed to list documents by creator", "error", err.Error(), "created_by", createdBy)
return nil, fmt.Errorf("failed to list documents: %w", err)
}
defer rows.Close()
documents := []*models.Document{}
for rows.Next() {
doc := &models.Document{}
err := rows.Scan(
&doc.DocID,
&doc.TenantID,
&doc.Title,
&doc.URL,
&doc.Checksum,
&doc.ChecksumAlgorithm,
&doc.Description,
&doc.ReadMode,
&doc.AllowDownload,
&doc.RequireFullRead,
&doc.VerifyChecksum,
&doc.CreatedAt,
&doc.UpdatedAt,
&doc.CreatedBy,
&doc.DeletedAt,
)
if err != nil {
logger.Logger.Error("Failed to scan document row", "error", err.Error())
return nil, fmt.Errorf("failed to scan document: %w", err)
}
documents = append(documents, doc)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating documents: %w", err)
}
return documents, nil
}
// SearchByCreatedBy retrieves paginated documents matching search query created by a specific user (excluding soft-deleted)
// RLS policy automatically filters by tenant_id
func (r *DocumentRepository) SearchByCreatedBy(ctx context.Context, createdBy, searchQuery string, limit, offset int) ([]*models.Document, error) {
query := `
SELECT doc_id, tenant_id, title, url, checksum, checksum_algorithm, description, read_mode, allow_download, require_full_read, verify_checksum, created_at, updated_at, created_by, deleted_at
FROM documents
WHERE deleted_at IS NULL AND created_by = $1
AND (
doc_id ILIKE $2
OR title ILIKE $2
OR url ILIKE $2
OR description ILIKE $2
)
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
`
searchPattern := "%" + searchQuery + "%"
rows, err := dbctx.GetQuerier(ctx, r.db).QueryContext(ctx, query, createdBy, searchPattern, limit, offset)
if err != nil {
logger.Logger.Error("Failed to search documents by creator", "error", err.Error(), "created_by", createdBy, "query", searchQuery)
return nil, fmt.Errorf("failed to search documents: %w", err)
}
defer rows.Close()
documents := []*models.Document{}
for rows.Next() {
doc := &models.Document{}
err := rows.Scan(
&doc.DocID,
&doc.TenantID,
&doc.Title,
&doc.URL,
&doc.Checksum,
&doc.ChecksumAlgorithm,
&doc.Description,
&doc.ReadMode,
&doc.AllowDownload,
&doc.RequireFullRead,
&doc.VerifyChecksum,
&doc.CreatedAt,
&doc.UpdatedAt,
&doc.CreatedBy,
&doc.DeletedAt,
)
if err != nil {
logger.Logger.Error("Failed to scan document row", "error", err.Error())
return nil, fmt.Errorf("failed to scan document: %w", err)
}
documents = append(documents, doc)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating documents: %w", err)
}
logger.Logger.Debug("Document search by creator completed",
"created_by", createdBy,
"query", searchQuery,
"results", len(documents),
"limit", limit,
"offset", offset)
return documents, nil
}
// CountByCreatedBy returns the total number of documents created by a specific user (excluding soft-deleted)
func (r *DocumentRepository) CountByCreatedBy(ctx context.Context, createdBy, searchQuery string) (int, error) {
var query string
var args []interface{}
if searchQuery != "" {
query = `
SELECT COUNT(*)
FROM documents
WHERE deleted_at IS NULL AND created_by = $1
AND (
doc_id ILIKE $2
OR title ILIKE $2
OR url ILIKE $2
OR description ILIKE $2
)
`
searchPattern := "%" + searchQuery + "%"
args = []interface{}{createdBy, searchPattern}
} else {
query = `
SELECT COUNT(*)
FROM documents
WHERE deleted_at IS NULL AND created_by = $1
`
args = []interface{}{createdBy}
}
var count int
err := dbctx.GetQuerier(ctx, r.db).QueryRowContext(ctx, query, args...).Scan(&count)
if err != nil {
logger.Logger.Error("Failed to count documents by creator", "error", err.Error(), "created_by", createdBy, "search", searchQuery)
return 0, fmt.Errorf("failed to count documents: %w", err)
}
logger.Logger.Debug("Document count by creator completed", "count", count, "created_by", createdBy, "search", searchQuery)
return count, nil
}

View File

@@ -73,6 +73,10 @@ type DocumentResponse struct {
Checksum string `json:"checksum,omitempty"`
ChecksumAlgorithm string `json:"checksumAlgorithm,omitempty"`
Description string `json:"description"`
ReadMode string `json:"readMode"`
AllowDownload bool `json:"allowDownload"`
RequireFullRead bool `json:"requireFullRead"`
VerifyChecksum bool `json:"verifyChecksum"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
CreatedBy string `json:"createdBy"`
@@ -328,6 +332,10 @@ func toDocumentResponse(doc *models.Document) *DocumentResponse {
Checksum: doc.Checksum,
ChecksumAlgorithm: doc.ChecksumAlgorithm,
Description: doc.Description,
ReadMode: doc.ReadMode,
AllowDownload: doc.AllowDownload,
RequireFullRead: doc.RequireFullRead,
VerifyChecksum: doc.VerifyChecksum,
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: doc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
CreatedBy: doc.CreatedBy,
@@ -488,6 +496,10 @@ type UpdateDocumentMetadataRequest struct {
Checksum *string `json:"checksum,omitempty"`
ChecksumAlgorithm *string `json:"checksumAlgorithm,omitempty"`
Description *string `json:"description,omitempty"`
ReadMode *string `json:"readMode,omitempty"`
AllowDownload *bool `json:"allowDownload,omitempty"`
RequireFullRead *bool `json:"requireFullRead,omitempty"`
VerifyChecksum *bool `json:"verifyChecksum,omitempty"`
}
// HandleUpdateDocumentMetadata handles PUT /api/v1/admin/documents/{docId}/metadata
@@ -540,6 +552,18 @@ func (h *Handler) HandleUpdateDocumentMetadata(w http.ResponseWriter, r *http.Re
if req.Description != nil {
doc.Description = *req.Description
}
if req.ReadMode != nil {
doc.ReadMode = *req.ReadMode
}
if req.AllowDownload != nil {
doc.AllowDownload = *req.AllowDownload
}
if req.RequireFullRead != nil {
doc.RequireFullRead = *req.RequireFullRead
}
if req.VerifyChecksum != nil {
doc.VerifyChecksum = *req.VerifyChecksum
}
// Save document using CreateOrUpdate
input := models.DocumentInput{
@@ -548,6 +572,10 @@ func (h *Handler) HandleUpdateDocumentMetadata(w http.ResponseWriter, r *http.Re
Checksum: doc.Checksum,
ChecksumAlgorithm: doc.ChecksumAlgorithm,
Description: doc.Description,
ReadMode: doc.ReadMode,
AllowDownload: &doc.AllowDownload,
RequireFullRead: &doc.RequireFullRead,
VerifyChecksum: &doc.VerifyChecksum,
}
doc, err = h.adminService.UpdateDocumentMetadata(ctx, docID, input, user.Email)
if err != nil {

View File

@@ -178,6 +178,10 @@ func createTestDocument(docID string) *models.Document {
Checksum: "abc123",
ChecksumAlgorithm: "SHA-256",
Description: "Test description",
ReadMode: "integrated",
AllowDownload: true,
RequireFullRead: false,
VerifyChecksum: true,
CreatedAt: now,
UpdatedAt: now,
CreatedBy: "admin@example.com",

View File

@@ -28,6 +28,9 @@ type documentService interface {
GetByDocID(ctx context.Context, docID string) (*models.Document, error)
GetExpectedSignerStats(ctx context.Context, docID string) (*models.DocCompletionStats, error)
ListExpectedSigners(ctx context.Context, docID string) ([]*models.ExpectedSigner, error)
ListByCreatedBy(ctx context.Context, createdBy string, limit, offset int) ([]*models.Document, error)
SearchByCreatedBy(ctx context.Context, createdBy, query string, limit, offset int) ([]*models.Document, error)
CountByCreatedBy(ctx context.Context, createdBy, searchQuery string) (int, error)
}
// webhookPublisher defines minimal publish capability
@@ -106,7 +109,6 @@ type CreateDocumentResponse struct {
func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Check if user can create documents
user, authenticated := shared.GetUserFromContext(ctx)
userEmail := ""
if authenticated && user != nil {
@@ -127,7 +129,6 @@ func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) {
return
}
// Parse request body
var req CreateDocumentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logger.Logger.Warn("Invalid document creation request body",
@@ -137,7 +138,6 @@ func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) {
return
}
// Validate reference field
if req.Reference == "" {
logger.Logger.Warn("Document creation request missing reference field",
"remote_addr", r.RemoteAddr)
@@ -150,13 +150,11 @@ func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) {
"has_title", req.Title != "",
"remote_addr", r.RemoteAddr)
// Create document request
docRequest := services.CreateDocumentRequest{
Reference: req.Reference,
Title: req.Title,
}
// Create document
doc, err := h.documentService.CreateDocument(ctx, docRequest)
if err != nil {
logger.Logger.Error("Document creation failed in handler",
@@ -197,11 +195,9 @@ func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) {
func (h *Handler) HandleListDocuments(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse pagination and search parameters
pagination := shared.ParsePaginationParams(r, 20, 100)
searchQuery := r.URL.Query().Get("search")
// Fetch documents from service
var docs []*models.Document
var err error
@@ -228,7 +224,6 @@ func (h *Handler) HandleListDocuments(w http.ResponseWriter, r *http.Request) {
return
}
// Get total count of documents (with or without search filter)
totalCount, err := h.documentService.Count(ctx, searchQuery)
if err != nil {
logger.Logger.Warn("Failed to count documents, using result count",
@@ -237,7 +232,6 @@ func (h *Handler) HandleListDocuments(w http.ResponseWriter, r *http.Request) {
totalCount = len(docs)
}
// Convert to DTOs (enriched with counts)
documents := make([]DocumentDTO, 0, len(docs))
for _, doc := range docs {
dto := DocumentDTO{
@@ -248,12 +242,10 @@ func (h *Handler) HandleListDocuments(w http.ResponseWriter, r *http.Request) {
UpdatedAt: doc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
// Get signature count
if sigs, err := h.signatureService.GetDocumentSignatures(ctx, doc.DocID); err == nil {
dto.SignatureCount = len(sigs)
}
// Get expected signer count
if stats, err := h.documentService.GetExpectedSignerStats(ctx, doc.DocID); err == nil {
dto.ExpectedSignerCount = stats.ExpectedCount
}
@@ -274,7 +266,6 @@ func (h *Handler) HandleGetDocument(w http.ResponseWriter, r *http.Request) {
return
}
// Get document from service
doc, err := h.documentService.GetByDocID(ctx, docID)
if err != nil {
logger.Logger.Error("Failed to get document", "doc_id", docID, "error", err.Error())
@@ -286,7 +277,6 @@ func (h *Handler) HandleGetDocument(w http.ResponseWriter, r *http.Request) {
return
}
// Get signatures for the document
signatures, err := h.signatureService.GetDocumentSignatures(ctx, docID)
if err != nil {
logger.Logger.Error("Failed to get signatures", "doc_id", docID, "error", err.Error())
@@ -404,6 +394,10 @@ type FindOrCreateDocumentResponse struct {
Checksum string `json:"checksum,omitempty"`
ChecksumAlgorithm string `json:"checksumAlgorithm,omitempty"`
Description string `json:"description,omitempty"`
ReadMode string `json:"readMode"`
AllowDownload bool `json:"allowDownload"`
RequireFullRead bool `json:"requireFullRead"`
VerifyChecksum bool `json:"verifyChecksum"`
CreatedAt string `json:"createdAt"`
IsNew bool `json:"isNew"`
}
@@ -452,6 +446,10 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
Checksum: existingDoc.Checksum,
ChecksumAlgorithm: existingDoc.ChecksumAlgorithm,
Description: existingDoc.Description,
ReadMode: existingDoc.ReadMode,
AllowDownload: existingDoc.AllowDownload,
RequireFullRead: existingDoc.RequireFullRead,
VerifyChecksum: existingDoc.VerifyChecksum,
CreatedAt: existingDoc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
IsNew: false,
}
@@ -502,6 +500,10 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
Checksum: doc.Checksum,
ChecksumAlgorithm: doc.ChecksumAlgorithm,
Description: doc.Description,
ReadMode: doc.ReadMode,
AllowDownload: doc.AllowDownload,
RequireFullRead: doc.RequireFullRead,
VerifyChecksum: doc.VerifyChecksum,
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
IsNew: isNew,
}
@@ -522,3 +524,90 @@ func detectReferenceType(ref string) ReferenceType {
}
type ReferenceType string
// MyDocumentDTO represents a document with stats for the current user's documents list
type MyDocumentDTO struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url,omitempty"`
Description string `json:"description"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
SignatureCount int `json:"signatureCount"`
ExpectedSignerCount int `json:"expectedSignerCount"`
}
// HandleListMyDocuments handles GET /api/v1/users/me/documents
// Returns documents created by the current authenticated user
func (h *Handler) HandleListMyDocuments(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, authenticated := shared.GetUserFromContext(ctx)
if !authenticated || user == nil {
shared.WriteError(w, http.StatusUnauthorized, shared.ErrCodeUnauthorized, "Authentication required", nil)
return
}
pagination := shared.ParsePaginationParams(r, 20, 100)
searchQuery := r.URL.Query().Get("search")
var docs []*models.Document
var err error
if searchQuery != "" {
docs, err = h.documentService.SearchByCreatedBy(ctx, user.Email, searchQuery, pagination.PageSize, pagination.Offset)
logger.Logger.Debug("User document search request",
"user_email", user.Email,
"query", searchQuery,
"limit", pagination.PageSize,
"offset", pagination.Offset)
} else {
docs, err = h.documentService.ListByCreatedBy(ctx, user.Email, pagination.PageSize, pagination.Offset)
logger.Logger.Debug("User document list request",
"user_email", user.Email,
"limit", pagination.PageSize,
"offset", pagination.Offset)
}
if err != nil {
logger.Logger.Error("Failed to fetch user documents",
"user_email", user.Email,
"search", searchQuery,
"error", err.Error())
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to fetch documents", nil)
return
}
totalCount, err := h.documentService.CountByCreatedBy(ctx, user.Email, searchQuery)
if err != nil {
logger.Logger.Warn("Failed to count user documents, using result count",
"error", err.Error(),
"user_email", user.Email,
"search", searchQuery)
totalCount = len(docs)
}
documents := make([]MyDocumentDTO, 0, len(docs))
for _, doc := range docs {
dto := MyDocumentDTO{
ID: doc.DocID,
Title: doc.Title,
URL: doc.URL,
Description: doc.Description,
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: doc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if sigs, err := h.signatureService.GetDocumentSignatures(ctx, doc.DocID); err == nil {
dto.SignatureCount = len(sigs)
}
if stats, err := h.documentService.GetExpectedSignerStats(ctx, doc.DocID); err == nil {
dto.ExpectedSignerCount = stats.ExpectedCount
}
documents = append(documents, dto)
}
shared.WritePaginatedJSON(w, documents, pagination.Page, pagination.PageSize, totalCount)
}

View File

@@ -33,6 +33,10 @@ var (
Description: "Test description",
Checksum: "abc123",
ChecksumAlgorithm: "SHA-256",
ReadMode: "integrated",
AllowDownload: true,
RequireFullRead: false,
VerifyChecksum: true,
CreatedAt: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
CreatedBy: "user@example.com",

View File

@@ -20,6 +20,7 @@ import (
apiAuth "github.com/btouchard/ackify-ce/backend/internal/presentation/api/auth"
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/documents"
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/health"
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/proxy"
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared"
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/signatures"
"github.com/btouchard/ackify-ce/backend/internal/presentation/api/users"
@@ -53,6 +54,9 @@ type documentService interface {
GetByDocID(ctx context.Context, docID string) (*models.Document, error)
GetExpectedSignerStats(ctx context.Context, docID string) (*models.DocCompletionStats, error)
ListExpectedSigners(ctx context.Context, docID string) ([]*models.ExpectedSigner, error)
ListByCreatedBy(ctx context.Context, createdBy string, limit, offset int) ([]*models.Document, error)
SearchByCreatedBy(ctx context.Context, createdBy, query string, limit, offset int) ([]*models.Document, error)
CountByCreatedBy(ctx context.Context, createdBy, searchQuery string) (int, error)
}
// reminderService defines reminder operations
@@ -177,6 +181,7 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
cfg.Authorizer,
)
signaturesHandler := signatures.NewHandler(cfg.SignatureService, cfg.AdminService, cfg.WebhookPublisher)
proxyHandler := proxy.NewHandler(cfg.DocumentService)
// Public routes
r.Group(func(r chi.Router) {
@@ -186,6 +191,9 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
// CSRF token
r.Get("/csrf", authHandler.HandleGetCSRFToken)
// Proxy for streaming external documents (has its own rate limiting)
r.Get("/proxy", proxyHandler.HandleProxy)
// Auth endpoints
r.Route("/auth", func(r chi.Router) {
// Public endpoint to expose available authentication methods
@@ -251,6 +259,7 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
// User endpoints
r.Route("/users", func(r chi.Router) {
r.Get("/me", usersHandler.HandleGetCurrentUser)
r.Get("/me/documents", documentsHandler.HandleListMyDocuments)
})
// Signature endpoints

View File

@@ -37,7 +37,6 @@ type ErrorDetail struct {
Details map[string]interface{} `json:"details,omitempty"`
}
// WriteError writes a standardized error response
func WriteError(w http.ResponseWriter, statusCode int, code ErrorCode, message string, details map[string]interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
@@ -53,7 +52,6 @@ func WriteError(w http.ResponseWriter, statusCode int, code ErrorCode, message s
json.NewEncoder(w).Encode(response)
}
// WriteValidationError writes a validation error response
func WriteValidationError(w http.ResponseWriter, message string, fieldErrors map[string]string) {
details := make(map[string]interface{})
if fieldErrors != nil {
@@ -62,7 +60,6 @@ func WriteValidationError(w http.ResponseWriter, message string, fieldErrors map
WriteError(w, http.StatusBadRequest, ErrCodeValidation, message, details)
}
// WriteUnauthorized writes an unauthorized error response
func WriteUnauthorized(w http.ResponseWriter, message string) {
if message == "" {
message = "Authentication required"
@@ -70,7 +67,6 @@ func WriteUnauthorized(w http.ResponseWriter, message string) {
WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, message, nil)
}
// WriteForbidden writes a forbidden error response
func WriteForbidden(w http.ResponseWriter, message string) {
if message == "" {
message = "Access denied"
@@ -78,7 +74,6 @@ func WriteForbidden(w http.ResponseWriter, message string) {
WriteError(w, http.StatusForbidden, ErrCodeForbidden, message, nil)
}
// WriteNotFound writes a not found error response
func WriteNotFound(w http.ResponseWriter, resource string) {
message := "Resource not found"
if resource != "" {
@@ -87,7 +82,6 @@ func WriteNotFound(w http.ResponseWriter, resource string) {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, message, nil)
}
// WriteConflict writes a conflict error response
func WriteConflict(w http.ResponseWriter, message string) {
if message == "" {
message = "Resource conflict"
@@ -95,7 +89,6 @@ func WriteConflict(w http.ResponseWriter, message string) {
WriteError(w, http.StatusConflict, ErrCodeConflict, message, nil)
}
// WriteInternalError writes an internal server error response
func WriteInternalError(w http.ResponseWriter) {
WriteError(w, http.StatusInternalServerError, ErrCodeInternal, "An internal error occurred", nil)
}

View File

@@ -28,7 +28,6 @@ type PaginationParams struct {
Offset int `json:"-"`
}
// NewPaginationParams creates pagination parameters with default values
func NewPaginationParams(defaultPage, defaultPageSize, maxPageSize int) *PaginationParams {
if defaultPage < 1 {
defaultPage = 1
@@ -46,19 +45,15 @@ func NewPaginationParams(defaultPage, defaultPageSize, maxPageSize int) *Paginat
}
}
// ParsePaginationParams parses pagination parameters from HTTP request query string
// and validates them against min/max constraints
func ParsePaginationParams(r *http.Request, defaultPageSize, maxPageSize int) *PaginationParams {
params := NewPaginationParams(1, defaultPageSize, maxPageSize)
// Parse page parameter
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil && page > 0 {
params.Page = page
}
}
// Parse limit/page_size parameter (support both names)
pageSizeStr := r.URL.Query().Get("limit")
if pageSizeStr == "" {
pageSizeStr = r.URL.Query().Get("page_size")
@@ -69,9 +64,7 @@ func ParsePaginationParams(r *http.Request, defaultPageSize, maxPageSize int) *P
}
}
// Validate and calculate
params.Validate(maxPageSize)
return params
}
@@ -89,7 +82,6 @@ func (p *PaginationParams) Validate(maxPageSize int) {
p.Offset = (p.Page - 1) * p.PageSize
}
// WriteJSON writes a JSON response
func WriteJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
@@ -101,7 +93,6 @@ func WriteJSON(w http.ResponseWriter, statusCode int, data interface{}) {
json.NewEncoder(w).Encode(response)
}
// WriteJSONWithMeta writes a JSON response with metadata
func WriteJSONWithMeta(w http.ResponseWriter, statusCode int, data interface{}, meta map[string]interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
@@ -114,7 +105,6 @@ func WriteJSONWithMeta(w http.ResponseWriter, statusCode int, data interface{},
json.NewEncoder(w).Encode(response)
}
// WritePaginatedJSON writes a paginated JSON response
func WritePaginatedJSON(w http.ResponseWriter, data interface{}, page, limit, total int) {
totalPages := (total + limit - 1) / limit
if totalPages < 1 {

View File

@@ -91,34 +91,29 @@ type SignatureStatusResponse struct {
func (h *Handler) HandleCreateSignature(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user from context (set by RequireAuth middleware)
user, ok := shared.GetUserFromContext(ctx)
if !ok || user == nil {
shared.WriteUnauthorized(w, "Authentication required")
return
}
// Parse request body
var req CreateSignatureRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid request body", map[string]interface{}{"error": err.Error()})
return
}
// Validate document ID
if req.DocID == "" {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Document ID is required", nil)
return
}
// Create signature request
sigRequest := &models.SignatureRequest{
DocID: req.DocID,
User: user,
Referer: req.Referer,
}
// Create signature
err := h.signatureService.CreateSignature(ctx, sigRequest)
if err != nil {
if err == models.ErrSignatureAlreadyExists {
@@ -165,10 +160,8 @@ func (h *Handler) HandleCreateSignature(w http.ResponseWriter, r *http.Request)
}
}
// Get the created signature to return it
signature, err := h.signatureService.GetSignatureByDocAndUser(ctx, req.DocID, user)
if err != nil {
// Signature was created but we couldn't retrieve it
shared.WriteJSON(w, http.StatusCreated, map[string]interface{}{
"message": "Signature created successfully",
"docId": req.DocID,
@@ -176,7 +169,6 @@ func (h *Handler) HandleCreateSignature(w http.ResponseWriter, r *http.Request)
return
}
// Return the created signature
shared.WriteJSON(w, http.StatusCreated, h.toSignatureResponse(ctx, signature))
}
@@ -184,21 +176,18 @@ func (h *Handler) HandleCreateSignature(w http.ResponseWriter, r *http.Request)
func (h *Handler) HandleGetUserSignatures(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user from context
user, ok := shared.GetUserFromContext(ctx)
if !ok || user == nil {
shared.WriteUnauthorized(w, "Authentication required")
return
}
// Get user's signatures
signatures, err := h.signatureService.GetUserSignatures(ctx, user)
if err != nil {
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to fetch signatures", map[string]interface{}{"error": err.Error()})
return
}
// Convert to response format
response := make([]*SignatureResponse, 0, len(signatures))
for _, sig := range signatures {
response = append(response, h.toSignatureResponse(ctx, sig))
@@ -211,21 +200,18 @@ func (h *Handler) HandleGetUserSignatures(w http.ResponseWriter, r *http.Request
func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get document ID from URL
docID := chi.URLParam(r, "docId")
if docID == "" {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Document ID is required", nil)
return
}
// Get document signatures
signatures, err := h.signatureService.GetDocumentSignatures(ctx, docID)
if err != nil {
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to fetch signatures", map[string]interface{}{"error": err.Error()})
return
}
// Convert to response format
response := make([]*SignatureResponse, 0, len(signatures))
for _, sig := range signatures {
response = append(response, h.toSignatureResponse(ctx, sig))
@@ -238,28 +224,24 @@ func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Req
func (h *Handler) HandleGetSignatureStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user from context
user, ok := shared.GetUserFromContext(ctx)
if !ok || user == nil {
shared.WriteUnauthorized(w, "Authentication required")
return
}
// Get document ID from URL
docID := chi.URLParam(r, "docId")
if docID == "" {
shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Document ID is required", nil)
return
}
// Get signature status
status, err := h.signatureService.GetSignatureStatus(ctx, docID, user)
if err != nil {
shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Failed to fetch signature status", map[string]interface{}{"error": err.Error()})
return
}
// Convert to response format
response := SignatureStatusResponse{
DocID: status.DocID,
UserEmail: status.UserEmail,

View File

@@ -27,7 +27,6 @@ type OEmbedResponse struct {
// Returns oEmbed JSON for embedding Ackify signature widgets in external platforms
func HandleOEmbed(baseURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get the URL parameter
urlParam := r.URL.Query().Get("url")
if urlParam == "" {
logger.Logger.Warn("oEmbed request missing url parameter",
@@ -36,7 +35,6 @@ func HandleOEmbed(baseURL string) http.HandlerFunc {
return
}
// Parse the URL to extract doc parameter
parsedURL, err := url.Parse(urlParam)
if err != nil {
logger.Logger.Warn("oEmbed request with invalid url",
@@ -57,19 +55,15 @@ func HandleOEmbed(baseURL string) http.HandlerFunc {
return
}
// Build embed URL (points to the SPA embed view)
embedURL := baseURL + "/embed?doc=" + url.QueryEscape(docID)
// Check if referrer is provided (for tracking which platform is embedding)
referrer := parsedURL.Query().Get("referrer")
if referrer != "" {
embedURL += "&referrer=" + url.QueryEscape(referrer)
}
// Build iframe HTML
iframeHTML := `<iframe src="` + embedURL + `" width="100%" height="200" frameborder="0" style="border: 1px solid #ddd; border-radius: 6px;" allowtransparency="true"></iframe>`
// Create oEmbed response
response := OEmbedResponse{
Type: "rich",
Version: "1.0",
@@ -80,11 +74,9 @@ func HandleOEmbed(baseURL string) http.HandlerFunc {
Height: 200,
}
// Set response headers
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*") // Allow cross-origin requests for oEmbed
w.Header().Set("Access-Control-Allow-Origin", "*")
// Encode and send response
if err := json.NewEncoder(w).Encode(response); err != nil {
logger.Logger.Error("Failed to encode oEmbed response",
"doc_id", docID,
@@ -107,7 +99,6 @@ func ValidateOEmbedURL(urlStr string, baseURL string) bool {
return false
}
// Check if the URL belongs to this Ackify instance
baseURLParsed, err := url.Parse(baseURL)
if err != nil {
return false

View File

@@ -61,30 +61,26 @@ func DefaultOptions() ComputeOptions {
// Returns nil if the file cannot be processed (too large, wrong type, network error, SSRF blocked)
// The context is used for request cancellation and timeout propagation.
func ComputeRemoteChecksum(ctx context.Context, urlStr string, opts ComputeOptions) (*Result, error) {
// Check if context is already cancelled
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("context cancelled before checksum computation: %w", err)
}
// Validate URL scheme (only HTTPS allowed)
if !isValidURL(urlStr) {
logger.Logger.Info("Checksum: URL rejected - not HTTPS", "url", urlStr)
return nil, nil
}
// Parse URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
logger.Logger.Warn("Checksum: Failed to parse URL", "url", urlStr, "error", err.Error())
return nil, nil
}
// SSRF Protection: Block internal/private IPs (unless disabled for testing)
if !opts.SkipSSRFCheck && isBlockedHost(parsedURL.Hostname()) {
logger.Logger.Warn("Checksum: SSRF protection - blocked internal/private host", "host", parsedURL.Hostname())
return nil, nil
}
// Create HTTP client with timeout and redirect limits
client := &http.Client{
Timeout: time.Duration(opts.TimeoutMs) * time.Millisecond,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
@@ -99,7 +95,6 @@ func ComputeRemoteChecksum(ctx context.Context, urlStr string, opts ComputeOptio
},
}
// For testing only: disable TLS verification
if opts.InsecureSkipVerify {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
@@ -122,14 +117,12 @@ func ComputeRemoteChecksum(ctx context.Context, urlStr string, opts ComputeOptio
}
defer headResp.Body.Close()
// Check Content-Type
contentType := headResp.Header.Get("Content-Type")
if contentType != "" && !isAllowedContentType(contentType, opts.AllowedContentType) {
logger.Logger.Info("Checksum: Content-Type not allowed", "url", urlStr, "content_type", contentType)
return nil, nil
}
// Check Content-Length
contentLength := headResp.ContentLength
if contentLength > 0 && contentLength > opts.MaxBytes {
logger.Logger.Info("Checksum: File too large", "url", urlStr, "size", contentLength, "max", opts.MaxBytes)
@@ -187,7 +180,6 @@ func computeWithStreamedGET(ctx context.Context, client *http.Client, urlStr str
return nil, nil
}
// Check Content-Type again
contentType := getResp.Header.Get("Content-Type")
if contentType != "" && !isAllowedContentType(contentType, opts.AllowedContentType) {
logger.Logger.Info("Checksum: Content-Type not allowed (fallback)", "url", urlStr, "content_type", contentType)
@@ -208,7 +200,6 @@ func computeHashWithLimit(reader io.Reader, maxBytes int64, urlStr string) (*Res
return nil, nil
}
// Check if we exceeded the limit
if written > maxBytes {
logger.Logger.Info("Checksum: File exceeded size limit during streaming", "url", urlStr, "read", written, "max", maxBytes)
return nil, nil
@@ -260,7 +251,6 @@ func isAllowedContentType(contentType string, allowedTypes []string) bool {
// isBlockedHost checks if the hostname is a private/internal IP or localhost
func isBlockedHost(hostname string) bool {
// Check for localhost variations
if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" {
return true
}
@@ -273,7 +263,6 @@ func isBlockedHost(hostname string) bool {
return true
}
// Check if any resolved IP is private/internal
for _, ip := range ips {
if isPrivateIP(ip) {
return true
@@ -314,9 +303,7 @@ func isPrivateIP(ip net.IP) bool {
}
}
// Check for private IPv6 ranges
if ip.To4() == nil {
// IPv6
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}

View File

@@ -11,22 +11,18 @@ import (
// This is the default for Community Edition.
type NoLimitQuotaEnforcer struct{}
// NewNoLimitQuotaEnforcer creates a new no-limit quota enforcer.
func NewNoLimitQuotaEnforcer() *NoLimitQuotaEnforcer {
return &NoLimitQuotaEnforcer{}
}
// Check always returns nil (no quota limits).
func (e *NoLimitQuotaEnforcer) Check(_ context.Context, _ string, _ QuotaAction) error {
return nil
}
// Record is a no-op (nothing to track).
func (e *NoLimitQuotaEnforcer) Record(_ context.Context, _ string, _ QuotaAction) error {
return nil
}
// GetUsage returns unlimited usage metrics.
func (e *NoLimitQuotaEnforcer) GetUsage(_ context.Context, tenantID string) (*QuotaUsage, error) {
unlimited := UsageMetric{Used: 0, Limit: -1}
return &QuotaUsage{
@@ -46,12 +42,10 @@ var _ QuotaEnforcer = (*NoLimitQuotaEnforcer)(nil)
// This is the default for Community Edition.
type LogOnlyAuditLogger struct{}
// NewLogOnlyAuditLogger creates a new log-only audit logger.
func NewLogOnlyAuditLogger() *LogOnlyAuditLogger {
return &LogOnlyAuditLogger{}
}
// Log writes the audit event to the standard logger.
func (l *LogOnlyAuditLogger) Log(_ context.Context, event AuditEvent) error {
logger.Logger.Info("audit",
"action", event.Action,

View File

@@ -200,28 +200,22 @@ func (b *ServerBuilder) WithReminderService(service *services.ReminderAsyncServi
// Build constructs the server with all dependencies.
func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) {
// Validate required capability providers
if err := b.validateProviders(); err != nil {
return nil, err
}
// Set defaults for optional providers
b.setDefaultProviders()
// Initialize infrastructure
if err := b.initializeInfrastructure(); err != nil {
return nil, err
}
// Create repositories
repos := b.createRepositories()
// Initialize Telemetry if is enabled
if err := b.initializeTelemetry(ctx); err != nil {
return nil, err
}
// Initialize workers and services
whPublisher, whWorker, err := b.initializeWebhookSystem(repos)
if err != nil {
return nil, err
@@ -232,25 +226,17 @@ func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) {
return nil, err
}
// Initialize core services
b.initializeCoreServices(repos)
// Initialize MagicLink service and worker
magicLinkWorker := b.initializeMagicLinkService(ctx, repos)
// Initialize reminder service
b.initializeReminderService(repos)
// Initialize session worker
sessionWorker, err := b.initializeSessionWorker(repos)
if err != nil {
return nil, err
}
// Build router
router := b.buildRouter(repos, whPublisher)
// Create HTTP server
httpServer := &http.Server{
Addr: b.cfg.Server.ListenAddr,
Handler: handlers.RequestLogger(handlers.SecureHeaders(router)),
@@ -299,7 +285,6 @@ func (b *ServerBuilder) setDefaultProviders() {
func (b *ServerBuilder) initializeInfrastructure() error {
var err error
// Initialize Ed25519 signer if not provided
if b.signer == nil {
b.signer, err = crypto.NewEd25519Signer()
if err != nil {
@@ -307,7 +292,6 @@ func (b *ServerBuilder) initializeInfrastructure() error {
}
}
// Initialize i18n if not provided
if b.i18nService == nil {
localesDir := getLocalesDir()
b.i18nService, err = i18n.NewI18n(localesDir)
@@ -316,7 +300,6 @@ func (b *ServerBuilder) initializeInfrastructure() error {
}
}
// Initialize email sender if not provided
if b.emailSender == nil && b.cfg.Mail.Host != "" {
emailTemplatesDir := getTemplatesDir()
renderer := email.NewRenderer(emailTemplatesDir, b.cfg.App.BaseURL, b.cfg.App.Organisation,

View File

@@ -89,7 +89,6 @@ func serveIndexTemplate(w http.ResponseWriter, r *http.Request, file fs.File, ba
processedContent := strings.ReplaceAll(string(content), "__ACKIFY_BASE_URL__", baseURL)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_VERSION__", version)
// Convert boolean to string for JavaScript
oauthEnabledStr := "false"
if oauthEnabled {
oauthEnabledStr = "true"
@@ -158,8 +157,6 @@ func generateBasicMetaTags(docID string, baseURL string, signatureCount int) str
}
var metaTags strings.Builder
// Open Graph tags
metaTags.WriteString(fmt.Sprintf(`<meta property="og:title" content="%s" />`, html.EscapeString(title)))
metaTags.WriteString("\n ")
metaTags.WriteString(fmt.Sprintf(`<meta property="og:description" content="%s" />`, html.EscapeString(description)))

View File

@@ -20,7 +20,7 @@ describe('Test 3: Admin - Expected Signers Management', () => {
cy.contains('Administration', { timeout: 10000 }).should('be.visible')
// Step 3: Create new document
cy.get('input#newDocId, input#newDocIdMobile').first().type(docId)
cy.get('[data-testid="new-doc-input"]').type(docId)
cy.contains('button', 'Confirm').click()
// Step 4: Should redirect to document detail page
@@ -28,16 +28,16 @@ describe('Test 3: Admin - Expected Signers Management', () => {
cy.contains('Document').should('be.visible')
// Step 5: Add 3 expected signers
cy.contains('button', 'Add').click()
cy.get('[data-testid="open-add-signers-btn"]').click()
// Modal should appear
cy.contains('Add expected readers').should('be.visible')
cy.get('[data-testid="add-signers-modal"]').should('be.visible')
// Wait for modal to be fully rendered
cy.wait(500)
// Add signers (Name <email> format and plain email)
cy.get('textarea[placeholder*="Jane"]').type(
cy.get('[data-testid="signers-textarea"]').type(
'Alice Smith <alice@test.com>{enter}bob@test.com{enter}Charlie Brown <charlie@test.com>',
{ delay: 50 }
)
@@ -45,11 +45,11 @@ describe('Test 3: Admin - Expected Signers Management', () => {
// Wait a bit for Vue reactivity
cy.wait(300)
// Submit the form by clicking the submit button (find button of type submit)
cy.get('button[type="submit"]').contains('Add').click()
// Submit the form
cy.get('[data-testid="add-signers-btn"]').click()
// Wait for modal to close
cy.contains('Add expected readers', { timeout: 15000 }).should('not.exist')
cy.get('[data-testid="add-signers-modal"]', { timeout: 15000 }).should('not.exist')
// Step 6: Verify signers in table
cy.contains('alice@test.com', { timeout: 10000 }).should('be.visible')
@@ -74,27 +74,27 @@ describe('Test 3: Admin - Expected Signers Management', () => {
// Create document
const removeDocId = 'test-remove-signer-' + Date.now()
cy.get('input#newDocId, input#newDocIdMobile').first().type(removeDocId)
cy.contains('button', 'Confirm').click()
cy.get('[data-testid="new-doc-input"]').type(removeDocId)
cy.get('[data-testid="create-doc-btn"]').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${removeDocId}`)
// Add 2 signers
cy.contains('button', 'Add').first().click()
cy.get('[data-testid="open-add-signers-btn"]').click()
// Wait for modal to be fully rendered
cy.wait(500)
cy.get('textarea[placeholder*="Jane"]').type('alice@test.com{enter}bob@test.com', { delay: 50 })
cy.get('[data-testid="signers-textarea"]').type('alice@test.com{enter}bob@test.com', { delay: 50 })
// Wait a bit for Vue reactivity
cy.wait(300)
// Submit the form by clicking the submit button (find button of type submit)
cy.get('button[type="submit"]').contains('Add').click()
// Submit the form
cy.get('[data-testid="add-signers-btn"]').click()
// Wait for modal to close
cy.contains('Add expected readers', { timeout: 15000 }).should('not.exist')
cy.get('[data-testid="add-signers-modal"]', { timeout: 15000 }).should('not.exist')
// Verify 2 signers
cy.contains('alice@test.com', { timeout: 10000 }).should('be.visible')

View File

@@ -17,23 +17,23 @@ describe('Test 4: Admin - Email Reminders', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(docId)
cy.get('[data-testid="new-doc-input"]').type(docId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
// Step 2: Add 2 expected signers
cy.contains('button', 'Add').click()
cy.get('[data-testid="open-add-signers-btn"]').click()
// Wait for modal
cy.wait(500)
cy.get('textarea[placeholder*="Jane"]').type(`${alice}{enter}${bob}`, { delay: 50 })
cy.get('[data-testid="signers-textarea"]').type(`${alice}{enter}${bob}`, { delay: 50 })
// Wait for Vue reactivity
cy.wait(300)
// Submit form
cy.get('button[type="submit"]').contains('Add').click()
cy.get('[data-testid="add-signers-btn"]').click()
// Verify signers added
cy.contains(alice, { timeout: 10000 }).should('be.visible')

View File

@@ -16,16 +16,16 @@ describe('Test 7: Admin - Document Deletion', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(docId)
cy.get('[data-testid="new-doc-input"]').type(docId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
// Step 2: Add 2 expected signers
cy.contains('button', 'Add').click()
cy.get('[data-testid="open-add-signers-btn"]').click()
cy.wait(500)
cy.get('textarea[placeholder*="Jane"]').type(`alice@test.com\n${testUser}`, { delay: 50 })
cy.get('[data-testid="signers-textarea"]').type(`alice@test.com\n${testUser}`, { delay: 50 })
cy.wait(300)
cy.get('button[type="submit"]').contains('Add').click()
cy.get('[data-testid="add-signers-btn"]').click()
cy.contains('alice@test.com', { timeout: 10000 }).should('be.visible')
@@ -84,7 +84,7 @@ describe('Test 7: Admin - Document Deletion', () => {
cy.visit('/admin')
const safeDocId = 'safe-doc-' + Date.now()
cy.get('input#newDocId, input#newDocIdMobile').first().type(safeDocId)
cy.get('[data-testid="new-doc-input"]').type(safeDocId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${safeDocId}`)

View File

@@ -72,7 +72,7 @@ describe('Test 8: Admin Route Protection', () => {
// Create document first as admin
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(targetDoc)
cy.get('[data-testid="new-doc-input"]').type(targetDoc)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${targetDoc}`)

View File

@@ -19,17 +19,17 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(docId)
cy.get('[data-testid="new-doc-input"]').type(docId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
// ===== STEP 2: Admin adds 3 expected signers =====
cy.log('STEP 2: Admin adds 3 expected signers')
cy.contains('button', 'Add').click()
cy.get('[data-testid="open-add-signers-btn"]').click()
cy.wait(500)
cy.get('textarea[placeholder*="Jane"]').type(`${alice}\n${bob}\n${charlie}`, { delay: 50 })
cy.get('[data-testid="signers-textarea"]').type(`${alice}\n${bob}\n${charlie}`, { delay: 50 })
cy.wait(300)
cy.get('button[type="submit"]').contains('Add').click()
cy.get('[data-testid="add-signers-btn"]').click()
cy.contains(alice, { timeout: 10000 }).should('be.visible')
cy.contains(bob).should('be.visible')

View File

@@ -19,16 +19,16 @@ describe('Test 10: Unexpected Signatures Tracking', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(docId)
cy.get('[data-testid="new-doc-input"]').type(docId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
// Add alice and bob as expected signers
cy.contains('button', 'Add').click()
cy.get('[data-testid="open-add-signers-btn"]').click()
cy.wait(500)
cy.get('textarea[placeholder*="Jane"]').type(`${alice}\n${bob}`, { delay: 50 })
cy.get('[data-testid="signers-textarea"]').type(`${alice}\n${bob}`, { delay: 50 })
cy.wait(300)
cy.get('button[type="submit"]').contains('Add').click()
cy.get('[data-testid="add-signers-btn"]').click()
cy.contains(alice, { timeout: 10000 }).should('be.visible')
cy.contains(bob).should('be.visible')
@@ -115,16 +115,16 @@ describe('Test 10: Unexpected Signatures Tracking', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(multiDocId)
cy.get('[data-testid="new-doc-input"]').type(multiDocId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${multiDocId}`)
cy.contains('button', 'Add').click()
cy.get('[data-testid="open-add-signers-btn"]').click()
cy.wait(500)
cy.get('textarea[placeholder*="Jane"]').type(expected1, { delay: 50 })
cy.get('[data-testid="signers-textarea"]').type(expected1, { delay: 50 })
cy.wait(300)
cy.get('button[type="submit"]').contains('Add').click()
cy.get('[data-testid="add-signers-btn"]').click()
cy.contains(expected1, { timeout: 10000 }).should('be.visible')

View File

@@ -47,7 +47,6 @@ describe('Test 13: Embed Page Functionality', () => {
cy.visitWithLocale(`/embed?doc=${sharedDocId}`, 'en')
// Step 3: Should show document header with signature count (i18n: "confirmation")
cy.contains('Document', { timeout: 10000 }).should('be.visible')
cy.contains('confirmation', { timeout: 10000 }).should('be.visible')
// Step 4: Should show signature in list
@@ -58,7 +57,7 @@ describe('Test 13: Embed Page Functionality', () => {
cy.contains('a', 'Sign').should('have.attr', 'target', '_blank')
// Step 6: Verify signature date is displayed
cy.get('.text-xs.text-muted-foreground').should('exist')
cy.get('[data-testid="signature-date"]').should('exist')
})
it('should display multiple signatures', () => {
@@ -203,7 +202,7 @@ describe('Test 13: Embed Page Functionality', () => {
cy.contains('confirmation', { timeout: 10000 }).should('be.visible')
// Step 4: Verify signatures appear in the list
cy.get('.space-y-2 > div').should('have.length', 3)
cy.get('[data-testid="signature-item"]').should('have.length', 3)
// Step 5: Verify each signature has email and date
users.forEach((email) => {

View File

@@ -13,7 +13,7 @@ describe('Test 14: CSV Import Preview', () => {
// Step 1: Login as admin and create document
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(testDocId)
cy.get('[data-testid="new-doc-input"]').type(testDocId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${testDocId}`)
@@ -37,7 +37,7 @@ charlie@test.com,Charlie Brown,Design team`
// Step 5: Should show preview with summary (check for Valid label and count)
cy.contains('Valid', { timeout: 10000 }).should('be.visible')
cy.get('.text-green-600').contains('3').should('be.visible')
cy.get('.text-emerald-600').contains('3').should('be.visible')
// Step 6: Should show preview table with emails
cy.contains('alice@test.com').should('be.visible')
@@ -80,9 +80,9 @@ david@test.com,David New`
// Step 5: Should show preview with existing email detected
cy.contains('Valid', { timeout: 10000 }).should('be.visible')
cy.get('.text-green-600').contains('1').should('be.visible')
cy.get('.text-emerald-600').contains('1').should('be.visible')
cy.contains('Already exist').should('be.visible')
cy.get('.text-orange-600').contains('1').should('be.visible')
cy.get('.text-amber-600').contains('1').should('be.visible')
// Step 6: Should show existing email in preview with "Existing" badge
cy.contains('alice@test.com').should('be.visible')
@@ -108,7 +108,7 @@ david@test.com,David New`
// Step 1: Login and create new document
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(invalidCsvDocId)
cy.get('[data-testid="new-doc-input"]').type(invalidCsvDocId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${invalidCsvDocId}`)
@@ -133,13 +133,11 @@ missing-domain@,Missing Domain`
// Step 5: Should show preview with invalid emails detected
cy.contains('Valid', { timeout: 10000 }).should('be.visible')
cy.get('.text-green-600').contains('1').should('be.visible')
cy.get('.text-emerald-600').contains('1').should('be.visible')
cy.contains('Invalid').should('be.visible')
cy.get('.text-red-600').contains('3').should('be.visible')
// Step 6: Should show invalid emails with error indicators
cy.contains('invalid-email').should('be.visible')
cy.contains('Parse errors').should('be.visible')
// Step 6: Invalid count is shown (invalid emails not displayed in table)
// Step 7: Confirm import (should only import valid email)
cy.contains('button', 'Import 1 reader').click()
@@ -156,7 +154,7 @@ missing-domain@,Missing Domain`
// Step 1: Login and create new document
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(emptyDocId)
cy.get('[data-testid="new-doc-input"]').type(emptyDocId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${emptyDocId}`)
@@ -187,7 +185,7 @@ missing-domain@,Missing Domain`
// Step 1: Login and create new document
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(missingColDocId)
cy.get('[data-testid="new-doc-input"]').type(missingColDocId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${missingColDocId}`)
@@ -219,7 +217,7 @@ Bob Johnson,Development`
// Step 1: Login and create new document
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('input#newDocId, input#newDocIdMobile').first().type(largeCsvDocId)
cy.get('[data-testid="new-doc-input"]').type(largeCsvDocId)
cy.contains('button', 'Confirm').click()
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${largeCsvDocId}`)
@@ -244,7 +242,7 @@ Bob Johnson,Development`
// Step 6: Should show preview with all 50 valid emails
cy.contains('Valid', { timeout: 10000 }).should('be.visible')
cy.get('.text-green-600').contains('50').should('be.visible')
cy.get('.text-emerald-600').contains('50').should('be.visible')
// Step 7: Preview table should show some emails
cy.contains('user1@test.com').should('be.visible')
@@ -288,7 +286,7 @@ cancel2@test.com,Cancel User 2`
// Step 6: Wait for preview
cy.contains('Valid', { timeout: 10000 }).should('be.visible')
cy.get('.text-green-600').contains('2').should('be.visible')
cy.get('.text-emerald-600').contains('2').should('be.visible')
// Step 7: Click Cancel button
cy.contains('button', 'Cancel').click()
@@ -330,18 +328,17 @@ another-new@test.com,Another New,Should be imported`
// Step 5: Should show preview with accurate counts
cy.contains('Valid', { timeout: 10000 }).should('be.visible')
cy.get('.text-green-600').contains('2').should('be.visible')
cy.get('.text-emerald-600').contains('2').should('be.visible')
cy.contains('Already exist').should('be.visible')
cy.get('.text-orange-600').contains('1').should('be.visible')
cy.get('.text-amber-600').contains('1').should('be.visible')
cy.contains('Invalid').should('be.visible')
cy.get('.text-red-600').contains('1').should('be.visible')
// Step 6: Verify each category is displayed correctly
// Step 6: Verify valid and existing entries are displayed (invalid not shown in table)
cy.contains('new-user@test.com').should('be.visible')
cy.contains('another-new@test.com').should('be.visible')
cy.contains('alice@test.com').should('be.visible')
cy.contains('Existing').should('be.visible')
cy.contains('invalid-email').should('be.visible')
// Step 7: Confirm import
cy.contains('button', 'Import 2 reader').click()

View File

@@ -8,8 +8,11 @@
"name": "webapp",
"version": "0.0.0",
"dependencies": {
"@types/dompurify": "^3.0.5",
"axios": "^1.12.2",
"dompurify": "^3.3.1",
"lucide-vue-next": "^0.546.0",
"marked": "^17.0.1",
"pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"vue": "^3.5.22",
@@ -3710,6 +3713,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -3780,6 +3792,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
@@ -5876,6 +5894,15 @@
"node": ">=8"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -8231,6 +8258,18 @@
"node": ">=10"
}
},
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -19,8 +19,11 @@
"test:e2e:open": "cypress open"
},
"dependencies": {
"@types/dompurify": "^3.0.5",
"axios": "^1.12.2",
"dompurify": "^3.3.1",
"lucide-vue-next": "^0.546.0",
"marked": "^17.0.1",
"pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"vue": "^3.5.22",

View File

@@ -0,0 +1,323 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { documentService, type FindOrCreateDocumentResponse } from '@/services/documents'
import { extractError } from '@/services/http'
import {
ArrowRight,
ChevronDown,
ChevronUp,
Loader2,
FileText,
ExternalLink,
Eye,
Download,
ScrollText,
Hash,
ShieldCheck
} from 'lucide-vue-next'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Label from '@/components/ui/Label.vue'
import Textarea from '@/components/ui/Textarea.vue'
export interface DocumentCreateFormProps {
mode?: 'compact' | 'full'
redirectOnCreate?: boolean
}
const props = withDefaults(defineProps<DocumentCreateFormProps>(), {
mode: 'compact',
redirectOnCreate: true
})
const emit = defineEmits<{
created: [document: FindOrCreateDocumentResponse]
}>()
const { t } = useI18n()
const router = useRouter()
// Form state
const url = ref('')
const title = ref('')
const readMode = ref<'integrated' | 'external'>('integrated')
const allowDownload = ref(true)
const requireFullRead = ref(false)
const checksum = ref('')
const checksumAlgorithm = ref<'SHA-256' | 'SHA-512'>('SHA-256')
const verifyChecksum = ref(true)
const description = ref('')
// UI state
const optionsExpanded = ref(false)
const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
// Computed
const isValid = computed(() => url.value.trim().length > 0)
const showOptions = computed(() => props.mode === 'full' || optionsExpanded.value)
// Toggle options accordion (compact mode only)
function toggleOptions() {
if (props.mode === 'compact') {
optionsExpanded.value = !optionsExpanded.value
}
}
// Reset form
function resetForm() {
url.value = ''
title.value = ''
readMode.value = 'integrated'
allowDownload.value = true
requireFullRead.value = false
checksum.value = ''
checksumAlgorithm.value = 'SHA-256'
verifyChecksum.value = true
description.value = ''
optionsExpanded.value = false
errorMessage.value = null
}
// Submit form
async function handleSubmit() {
if (!isValid.value || isSubmitting.value) return
errorMessage.value = null
isSubmitting.value = true
try {
// Call the API to find or create document
// Note: The current API only accepts reference. Extended options would need backend updates.
const response = await documentService.findOrCreateDocument(url.value.trim())
if (props.redirectOnCreate) {
// Redirect to document page
await router.push(`/?doc=${response.docId}`)
} else {
// Emit event and reset form
emit('created', response)
resetForm()
}
} catch (error) {
errorMessage.value = extractError(error)
} finally {
isSubmitting.value = false
}
}
// Watch read mode to reset related options
watch(readMode, (newMode) => {
if (newMode === 'external') {
allowDownload.value = false
requireFullRead.value = false
}
})
</script>
<template>
<div class="document-create-form">
<!-- Error message -->
<div
v-if="errorMessage"
class="mb-4 rounded-lg bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900 p-4 text-sm text-red-800 dark:text-red-200"
>
{{ errorMessage }}
</div>
<!-- Main form -->
<div class="space-y-4">
<!-- URL input + Submit button -->
<div class="flex gap-3" :class="mode === 'full' ? 'flex-col sm:flex-row' : ''">
<div class="flex-1">
<Label v-if="mode === 'full'" for="doc-url" class="mb-1.5">
{{ t('documentCreateForm.url.label') }}
</Label>
<Input
id="doc-url"
v-model="url"
type="text"
:placeholder="t('documentCreateForm.url.placeholder')"
class="w-full"
:class="mode === 'full' ? 'h-11' : 'h-12'"
:disabled="isSubmitting"
@keyup.enter="handleSubmit"
/>
<p v-if="mode === 'full'" class="mt-1.5 text-xs text-slate-400 dark:text-slate-500">
{{ t('documentCreateForm.url.helper') }}
</p>
</div>
<div :class="mode === 'full' ? 'flex items-end' : ''">
<Button
@click="handleSubmit"
:size="mode === 'full' ? 'default' : 'lg'"
class="group whitespace-nowrap"
:class="mode === 'full' ? 'h-11' : 'h-12'"
:disabled="!isValid || isSubmitting"
>
<Loader2 v-if="isSubmitting" class="w-4 h-4 animate-spin" />
<template v-else>
<FileText class="w-4 h-4" />
<span class="ml-2">{{ t('documentCreateForm.submit') }}</span>
<ArrowRight :size="16" class="ml-2 transition-transform group-hover:translate-x-1" />
</template>
</Button>
</div>
</div>
<!-- Options accordion (compact mode) -->
<div v-if="mode === 'compact'" class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<button
type="button"
@click="toggleOptions"
class="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
>
<span>{{ t('documentCreateForm.options.title') }}</span>
<ChevronUp v-if="optionsExpanded" class="w-4 h-4" />
<ChevronDown v-else class="w-4 h-4" />
</button>
</div>
<!-- Options content -->
<div
v-if="showOptions"
class="space-y-4"
:class="mode === 'compact' ? 'border border-slate-200 dark:border-slate-700 rounded-lg p-4 -mt-2 border-t-0 rounded-t-none' : ''"
>
<!-- Title -->
<div>
<Label for="doc-title">{{ t('documentCreateForm.title.label') }}</Label>
<Input
id="doc-title"
v-model="title"
type="text"
:placeholder="t('documentCreateForm.title.placeholder')"
class="mt-1.5"
:disabled="isSubmitting"
/>
</div>
<!-- Read mode -->
<div>
<Label>{{ t('documentCreateForm.readMode.label') }}</Label>
<div class="mt-2 flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
v-model="readMode"
value="integrated"
class="w-4 h-4 text-blue-600 border-slate-300 focus:ring-blue-500"
:disabled="isSubmitting"
/>
<Eye class="w-4 h-4 text-slate-500" />
<span class="text-sm text-slate-700 dark:text-slate-300">
{{ t('documentCreateForm.readMode.integrated') }}
</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
v-model="readMode"
value="external"
class="w-4 h-4 text-blue-600 border-slate-300 focus:ring-blue-500"
:disabled="isSubmitting"
/>
<ExternalLink class="w-4 h-4 text-slate-500" />
<span class="text-sm text-slate-700 dark:text-slate-300">
{{ t('documentCreateForm.readMode.external') }}
</span>
</label>
</div>
</div>
<!-- Integrated mode options -->
<div v-if="readMode === 'integrated'" class="pl-4 border-l-2 border-blue-200 dark:border-blue-800 space-y-3">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="allowDownload"
class="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
:disabled="isSubmitting"
/>
<Download class="w-4 h-4 text-slate-500" />
<span class="text-sm text-slate-700 dark:text-slate-300">
{{ t('documentCreateForm.options.allowDownload') }}
</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="requireFullRead"
class="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
:disabled="isSubmitting"
/>
<ScrollText class="w-4 h-4 text-slate-500" />
<span class="text-sm text-slate-700 dark:text-slate-300">
{{ t('documentCreateForm.options.requireFullRead') }}
</span>
</label>
</div>
<!-- Checksum -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label for="doc-checksum">{{ t('documentCreateForm.checksum.label') }}</Label>
<div class="relative mt-1.5">
<Hash class="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
<Input
id="doc-checksum"
v-model="checksum"
type="text"
:placeholder="t('documentCreateForm.checksum.placeholder')"
class="pl-10 font-mono text-sm"
:disabled="isSubmitting"
/>
</div>
</div>
<div>
<Label for="doc-algorithm">{{ t('documentCreateForm.algorithm.label') }}</Label>
<select
id="doc-algorithm"
v-model="checksumAlgorithm"
class="mt-1.5 w-full px-4 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:disabled="isSubmitting"
>
<option value="SHA-256">SHA-256</option>
<option value="SHA-512">SHA-512</option>
</select>
</div>
</div>
<!-- Verify checksum -->
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="verifyChecksum"
class="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
:disabled="isSubmitting"
/>
<ShieldCheck class="w-4 h-4 text-slate-500" />
<span class="text-sm text-slate-700 dark:text-slate-300">
{{ t('documentCreateForm.options.verifyChecksum') }}
</span>
</label>
<!-- Description (full mode only) -->
<div v-if="mode === 'full'">
<Label for="doc-description">{{ t('documentCreateForm.description.label') }}</Label>
<Textarea
id="doc-description"
v-model="description"
:placeholder="t('documentCreateForm.description.placeholder')"
class="mt-1.5"
:rows="3"
:disabled="isSubmitting"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -21,20 +21,44 @@ const userMenuOpen = ref(false)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const isAdmin = computed(() => authStore.isAdmin)
const user = computed(() => authStore.user)
const canCreateDocuments = computed(() => authStore.canCreateDocuments)
const getLocalPart = (email: string): string => email.split('@')[0] || email
const isEmail = (str: string): boolean => str.includes('@')
const splitIntoWords = (str: string): string[] => str.split(/[.\-_\s]+/).filter(p => p.length > 0)
const capitalize = (str: string): string => str ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : ''
const displayName = computed(() => {
if (!user.value?.name && !user.value?.email) return ''
if (user.value.name && !isEmail(user.value.name)) return user.value.name
const localPart = getLocalPart(user.value.email || user.value.name || '')
return splitIntoWords(localPart).map(capitalize).join(' ')
})
// User initials for avatar
const userInitials = computed(() => {
if (!user.value?.name && !user.value?.email) return '?'
const name = user.value.name || user.value.email || ''
const parts = name.split(/[\s@]+/).filter(p => p.length > 0)
if (parts.length >= 2) {
const first = parts[0] ?? ''
const second = parts[1] ?? ''
if (first.length > 0 && second.length > 0) {
if (user.value.name && !isEmail(user.value.name)) {
const words = splitIntoWords(user.value.name)
const first = words[0]
const second = words[1]
if (words.length >= 2 && first && second) {
return (first.charAt(0) + second.charAt(0)).toUpperCase()
}
return user.value.name.slice(0, 2).toUpperCase()
}
return name.slice(0, 2).toUpperCase()
const localPart = getLocalPart(user.value.email || user.value.name || '')
const words = splitIntoWords(localPart)
const first = words[0]
const second = words[1]
if (words.length >= 2 && first && second) {
return (first.charAt(0) + second.charAt(0)).toUpperCase()
}
return localPart.charAt(0).toUpperCase()
})
const isActive = (path: string) => {
@@ -79,7 +103,8 @@ const closeUserMenu = () => {
</div>
<!-- Desktop Navigation -->
<div v-if="isAuthenticated" class="hidden md:flex md:items-center md:space-x-1">
<div class="hidden md:flex md:items-center md:space-x-1">
<!-- Home - always visible -->
<router-link
to="/"
:class="[
@@ -92,7 +117,9 @@ const closeUserMenu = () => {
{{ t('nav.home') }}
</router-link>
<!-- My confirmations - authenticated only -->
<router-link
v-if="isAuthenticated"
to="/signatures"
:class="[
'px-3 py-2 text-sm font-medium rounded-lg transition-colors',
@@ -103,6 +130,20 @@ const closeUserMenu = () => {
>
{{ t('nav.myConfirmations') }}
</router-link>
<!-- My documents - authenticated + can create -->
<router-link
v-if="isAuthenticated && canCreateDocuments"
to="/documents"
:class="[
'px-3 py-2 text-sm font-medium rounded-lg transition-colors',
isActive('/documents') || route.path.startsWith('/documents/')
? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
]"
>
{{ t('nav.myDocuments') }}
</router-link>
</div>
<!-- Right side: Language + Theme + Auth -->
@@ -122,7 +163,7 @@ const closeUserMenu = () => {
<div class="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-xs font-semibold text-slate-600 dark:text-slate-300">
{{ userInitials }}
</div>
<span class="text-slate-700 dark:text-slate-200 hidden lg:inline">{{ user?.name || user?.email?.split('@')[0] }}</span>
<span class="text-slate-700 dark:text-slate-200 hidden lg:inline">{{ displayName }}</span>
<ChevronDown :size="16" class="text-slate-400" />
</button>
@@ -146,7 +187,7 @@ const closeUserMenu = () => {
<div class="p-2">
<!-- User info -->
<div class="px-3 py-2 border-b border-slate-100 dark:border-slate-700 mb-2">
<p class="font-medium text-slate-900 dark:text-slate-100">{{ user?.name }}</p>
<p class="font-medium text-slate-900 dark:text-slate-100">{{ displayName }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400 truncate">{{ user?.email }}</p>
</div>
@@ -211,21 +252,22 @@ const closeUserMenu = () => {
>
<div v-if="mobileMenuOpen" class="md:hidden border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
<div class="space-y-1 px-4 pb-4 pt-2">
<!-- Home - always visible -->
<router-link
to="/"
@click="closeMobileMenu"
:class="[
'block rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
isActive('/')
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
]"
>
{{ t('nav.home') }}
</router-link>
<!-- Navigation links (authenticated) -->
<template v-if="isAuthenticated">
<router-link
to="/"
@click="closeMobileMenu"
:class="[
'block rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
isActive('/')
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
]"
>
{{ t('nav.home') }}
</router-link>
<router-link
to="/signatures"
@click="closeMobileMenu"
@@ -239,6 +281,20 @@ const closeUserMenu = () => {
{{ t('nav.myConfirmations') }}
</router-link>
<router-link
v-if="canCreateDocuments"
to="/documents"
@click="closeMobileMenu"
:class="[
'block rounded-lg px-3 py-2.5 text-base font-medium transition-colors',
isActive('/documents') || route.path.startsWith('/documents/')
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
]"
>
{{ t('nav.myDocuments') }}
</router-link>
<router-link
v-if="isAdmin"
to="/admin"
@@ -250,13 +306,13 @@ const closeUserMenu = () => {
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
]"
>
{{ t('nav.administration') }}
{{ t('nav.admin') }}
</router-link>
<!-- User section -->
<div class="border-t border-slate-200 dark:border-slate-700 pt-3 mt-3">
<div class="px-3 py-2 mb-2">
<p class="font-medium text-slate-900 dark:text-slate-100">{{ user?.name }}</p>
<p class="font-medium text-slate-900 dark:text-slate-100">{{ displayName }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ user?.email }}</p>
</div>
<button

View File

@@ -0,0 +1,447 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import {
useDocumentProxy,
isPdf,
isImage,
isSvg,
isHtml,
isMarkdown,
isText
} from '@/composables/useDocumentProxy'
import {
ZoomIn,
ZoomOut,
Download,
ExternalLink,
RefreshCw,
AlertCircle,
FileText,
Loader2
} from 'lucide-vue-next'
const props = defineProps<{
documentId: string
url: string
allowDownload?: boolean
requireFullRead?: boolean
}>()
const emit = defineEmits<{
readComplete: []
}>()
const { t } = useI18n()
// Document proxy composable
const documentIdRef = computed(() => props.documentId)
const urlRef = computed(() => props.url)
const { proxyUrl, contentType, isLoading, error, retry } = useDocumentProxy(documentIdRef, urlRef)
// Viewer state
const zoom = ref(100)
const currentPage = ref(1)
const totalPages = ref(1)
const readProgress = ref(0)
const hasCompletedRead = ref(false)
const contentContainer = ref<HTMLElement | null>(null)
const rawContent = ref<string>('')
const sanitizedContent = ref<string>('')
const contentLoading = ref(false)
const contentError = ref<string | null>(null)
// Zoom controls
const zoomLevels = [50, 75, 100, 125, 150, 175, 200]
const minZoom = 50
const maxZoom = 200
function zoomIn() {
const nextLevel = zoomLevels.find(level => level > zoom.value)
if (nextLevel) {
zoom.value = nextLevel
}
}
function zoomOut() {
const prevLevel = [...zoomLevels].reverse().find(level => level < zoom.value)
if (prevLevel) {
zoom.value = prevLevel
}
}
// Sanitize content with strict DOMPurify configuration for security
function sanitize(content: string): string {
return DOMPurify.sanitize(content, {
ALLOWED_TAGS: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr',
'ul', 'ol', 'li',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'blockquote', 'pre', 'code',
'a', 'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins',
'span', 'div', 'section', 'article', 'header', 'footer', 'nav', 'aside', 'main',
'img', 'figure', 'figcaption',
'dl', 'dt', 'dd',
'sup', 'sub', 'mark', 'small', 'abbr', 'cite', 'q',
'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse', 'g', 'defs', 'use', 'text', 'tspan'
],
ALLOWED_ATTR: [
'href', 'src', 'alt', 'title', 'class', 'id', 'name',
'width', 'height', 'style',
'target', 'rel',
'colspan', 'rowspan',
'viewBox', 'fill', 'stroke', 'stroke-width', 'd', 'cx', 'cy', 'r', 'x', 'y', 'rx', 'ry',
'x1', 'y1', 'x2', 'y2', 'points', 'transform', 'xmlns'
],
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'select', 'textarea'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onfocus', 'onblur', 'onchange', 'onsubmit', 'onkeydown', 'onkeyup', 'onkeypress'],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target'],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false
})
}
// Load text-based content (HTML, Markdown, Text, SVG)
async function loadTextContent() {
if (!proxyUrl.value) return
const type = contentType.value
if (!isHtml(type) && !isMarkdown(type) && !isText(type) && !isSvg(type)) {
return
}
try {
contentLoading.value = true
contentError.value = null
const response = await fetch(proxyUrl.value, {
credentials: 'include'
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
rawContent.value = await response.text()
// Process content based on type
if (isMarkdown(type)) {
const htmlFromMarkdown = await marked(rawContent.value)
sanitizedContent.value = sanitize(htmlFromMarkdown)
} else if (isHtml(type)) {
sanitizedContent.value = sanitize(rawContent.value)
} else if (isSvg(type)) {
sanitizedContent.value = sanitize(rawContent.value)
} else {
// Plain text - no sanitization needed, will be displayed in <pre>
sanitizedContent.value = rawContent.value
}
} catch (err) {
contentError.value = err instanceof Error ? err.message : t('documentViewer.error.loadFailed')
} finally {
contentLoading.value = false
}
}
// Watch content type changes to load text content
watch(contentType, (newType) => {
if (isHtml(newType) || isMarkdown(newType) || isText(newType) || isSvg(newType)) {
loadTextContent()
}
}, { immediate: true })
// Scroll progress tracking
function handleScroll(event: Event) {
if (!props.requireFullRead || hasCompletedRead.value) return
const target = event.target as HTMLElement
const scrollTop = target.scrollTop
const scrollHeight = target.scrollHeight
const clientHeight = target.clientHeight
// Calculate progress (0-100)
const maxScroll = scrollHeight - clientHeight
if (maxScroll <= 0) {
// Content fits without scrolling
readProgress.value = 100
if (!hasCompletedRead.value) {
hasCompletedRead.value = true
emit('readComplete')
}
return
}
const progress = Math.min(100, Math.round((scrollTop / maxScroll) * 100))
readProgress.value = progress
// Emit readComplete when reaching the bottom
if (progress >= 100 && !hasCompletedRead.value) {
hasCompletedRead.value = true
emit('readComplete')
}
}
// Download document
function downloadDocument() {
if (!proxyUrl.value) return
window.open(proxyUrl.value, '_blank')
}
// Open in new tab
function openInNewTab() {
if (!props.url) return
window.open(props.url, '_blank', 'noopener,noreferrer')
}
// Check if content fits without scrolling (for auto-completing read)
function checkContentFits() {
if (!props.requireFullRead || hasCompletedRead.value || !contentContainer.value) return
const el = contentContainer.value
if (el.scrollHeight <= el.clientHeight) {
readProgress.value = 100
hasCompletedRead.value = true
emit('readComplete')
}
}
onMounted(() => {
// Small delay to ensure content is rendered
setTimeout(checkContentFits, 500)
})
// Viewer component based on content type
const viewerType = computed((): 'pdf' | 'image' | 'svg' | 'html' | 'markdown' | 'text' | 'unsupported' => {
const type = contentType.value
if (isPdf(type)) return 'pdf'
if (isSvg(type)) return 'svg'
if (isImage(type)) return 'image'
if (isHtml(type)) return 'html'
if (isMarkdown(type)) return 'markdown'
if (isText(type)) return 'text'
return 'unsupported'
})
const showPageIndicator = computed(() => viewerType.value === 'pdf')
const showZoomControls = computed(() => ['pdf', 'image', 'svg'].includes(viewerType.value))
</script>
<template>
<div class="document-viewer bg-white rounded-xl border border-slate-200 overflow-hidden">
<!-- Toolbar -->
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-100 bg-slate-50/50">
<div class="flex items-center gap-2">
<!-- Zoom controls -->
<template v-if="showZoomControls">
<button
@click="zoomOut"
:disabled="zoom <= minZoom"
class="p-1.5 rounded hover:bg-slate-200/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('documentViewer.toolbar.zoomOut')"
>
<ZoomOut class="w-4 h-4 text-slate-500" />
</button>
<span class="text-xs text-slate-400 font-mono px-2 min-w-[40px] text-center">{{ zoom }}%</span>
<button
@click="zoomIn"
:disabled="zoom >= maxZoom"
class="p-1.5 rounded hover:bg-slate-200/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('documentViewer.toolbar.zoomIn')"
>
<ZoomIn class="w-4 h-4 text-slate-500" />
</button>
<div class="w-px h-4 bg-slate-200 mx-2" />
</template>
<!-- Download button -->
<button
v-if="allowDownload"
@click="downloadDocument"
class="p-1.5 rounded hover:bg-slate-200/50 transition-colors"
:title="t('documentViewer.toolbar.download')"
>
<Download class="w-4 h-4 text-slate-500" />
</button>
<!-- Open in new tab -->
<button
@click="openInNewTab"
class="p-1.5 rounded hover:bg-slate-200/50 transition-colors"
:title="t('documentViewer.toolbar.openInNewTab')"
>
<ExternalLink class="w-4 h-4 text-slate-500" />
</button>
</div>
<!-- Right side: Page indicator or progress -->
<div class="flex items-center gap-3">
<span v-if="showPageIndicator" class="text-xs text-slate-400 font-mono">
{{ t('documentViewer.toolbar.page', { current: currentPage, total: totalPages }) }}
</span>
<span v-if="requireFullRead" class="text-xs text-slate-500 font-medium">
{{ t('documentViewer.progress', { percent: readProgress }) }}
</span>
</div>
</div>
<!-- Content area -->
<div
ref="contentContainer"
class="document-content relative grid-bg overflow-auto"
:style="{ height: '500px' }"
@scroll="handleScroll"
>
<!-- Loading state -->
<div v-if="isLoading || contentLoading" class="flex items-center justify-center h-full">
<div class="text-center">
<Loader2 class="w-8 h-8 text-blue-600 animate-spin mx-auto mb-3" />
<p class="text-sm text-slate-500">{{ t('documentViewer.loading') }}</p>
</div>
</div>
<!-- Error state -->
<div v-else-if="error || contentError" class="flex items-center justify-center h-full p-8">
<div class="text-center max-w-sm">
<div class="w-14 h-14 rounded-xl bg-red-50 flex items-center justify-center mx-auto mb-4">
<AlertCircle class="w-7 h-7 text-red-600" />
</div>
<h3 class="font-semibold text-slate-900 mb-2">{{ t('documentViewer.error.title') }}</h3>
<p class="text-sm text-slate-500 mb-4">{{ error || contentError }}</p>
<div class="flex items-center justify-center gap-3">
<button
@click="retry"
class="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors flex items-center gap-2"
>
<RefreshCw class="w-4 h-4" />
{{ t('documentViewer.error.retry') }}
</button>
<button
@click="openInNewTab"
class="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors flex items-center gap-2"
>
<ExternalLink class="w-4 h-4" />
{{ t('documentViewer.error.openExternal') }}
</button>
</div>
</div>
</div>
<!-- PDF Viewer -->
<div v-else-if="viewerType === 'pdf'" class="h-full">
<iframe
:src="proxyUrl"
class="w-full h-full border-0"
:style="{ transform: `scale(${zoom / 100})`, transformOrigin: 'top left', width: `${10000 / zoom}%`, height: `${10000 / zoom}%` }"
:title="t('documentViewer.pdfViewer')"
/>
</div>
<!-- Image Viewer -->
<div v-else-if="viewerType === 'image'" class="flex items-center justify-center h-full p-8">
<img
:src="proxyUrl"
:alt="t('documentViewer.imageAlt')"
class="max-w-full max-h-full object-contain rounded-lg shadow-sm"
:style="{ transform: `scale(${zoom / 100})` }"
/>
</div>
<!-- SVG Viewer (sanitized) -->
<div v-else-if="viewerType === 'svg'" class="flex items-center justify-center h-full p-8">
<div
class="svg-container max-w-full max-h-full"
:style="{ transform: `scale(${zoom / 100})` }"
v-html="sanitizedContent"
/>
</div>
<!-- HTML Viewer (sanitized in sandboxed iframe) -->
<div v-else-if="viewerType === 'html'" class="h-full p-8">
<iframe
sandbox="allow-same-origin"
:srcdoc="sanitizedContent"
class="w-full h-full border border-slate-100 rounded-lg bg-white"
:title="t('documentViewer.htmlViewer')"
/>
</div>
<!-- Markdown Viewer (rendered and sanitized) -->
<div v-else-if="viewerType === 'markdown'" class="p-8">
<div
class="prose prose-slate max-w-none bg-white rounded-lg shadow-sm border border-slate-100 p-10"
v-html="sanitizedContent"
/>
</div>
<!-- Text Viewer -->
<div v-else-if="viewerType === 'text'" class="p-8">
<pre class="bg-white rounded-lg shadow-sm border border-slate-100 p-6 text-sm text-slate-700 font-mono whitespace-pre-wrap overflow-x-auto">{{ rawContent }}</pre>
</div>
<!-- Unsupported format -->
<div v-else class="flex items-center justify-center h-full p-8">
<div class="text-center max-w-sm">
<div class="w-14 h-14 rounded-xl bg-slate-100 flex items-center justify-center mx-auto mb-4">
<FileText class="w-7 h-7 text-slate-400" />
</div>
<h3 class="font-semibold text-slate-900 mb-2">{{ t('documentViewer.unsupported.title') }}</h3>
<p class="text-sm text-slate-500 mb-4">{{ t('documentViewer.unsupported.description') }}</p>
<button
@click="openInNewTab"
class="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors flex items-center gap-2 mx-auto"
>
<ExternalLink class="w-4 h-4" />
{{ t('documentViewer.unsupported.openExternal') }}
</button>
</div>
</div>
</div>
<!-- Progress bar (if requireFullRead) -->
<div v-if="requireFullRead" class="border-t border-slate-100">
<div class="h-1 bg-slate-100">
<div
class="h-full transition-all duration-300"
:class="hasCompletedRead ? 'bg-emerald-500' : 'bg-blue-500'"
:style="{ width: `${readProgress}%` }"
/>
</div>
<div class="px-4 py-2 flex items-center justify-between text-xs">
<span class="text-slate-500">
{{ hasCompletedRead ? t('documentViewer.readComplete') : t('documentViewer.scrollToRead') }}
</span>
<span :class="hasCompletedRead ? 'text-emerald-600 font-medium' : 'text-slate-400'">
{{ readProgress }}%
</span>
</div>
</div>
</div>
</template>
<style scoped>
.grid-bg {
background-image:
linear-gradient(rgba(0,0,0,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.02) 1px, transparent 1px);
background-size: 20px 20px;
}
.document-viewer :deep(.prose) {
font-family: 'IBM Plex Sans', system-ui, sans-serif;
}
.document-viewer :deep(.prose pre),
.document-viewer :deep(.prose code) {
font-family: 'IBM Plex Mono', monospace;
}
.svg-container :deep(svg) {
max-width: 100%;
max-height: 100%;
}
</style>

View File

@@ -0,0 +1,2 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export { default as DocumentViewer } from './DocumentViewer.vue'

View File

@@ -0,0 +1,196 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { ref, computed, watch, type Ref } from 'vue'
export type ContentType =
| 'application/pdf'
| 'image/png'
| 'image/jpeg'
| 'image/gif'
| 'image/webp'
| 'image/svg+xml'
| 'text/html'
| 'text/markdown'
| 'text/plain'
| 'unknown'
export interface DocumentProxyState {
proxyUrl: Ref<string>
contentType: Ref<ContentType>
isLoading: Ref<boolean>
error: Ref<string | null>
retry: () => Promise<void>
}
/**
* Composable for managing document proxy access
* @param documentId - Document ID
* @param url - URL of the source document
*/
export function useDocumentProxy(
documentId: Ref<string> | string,
url: Ref<string> | string
): DocumentProxyState {
const docId = typeof documentId === 'string' ? ref(documentId) : documentId
const docUrl = typeof url === 'string' ? ref(url) : url
const isLoading = ref(false)
const error = ref<string | null>(null)
const contentType = ref<ContentType>('unknown')
// Build proxy URL
const proxyUrl = computed(() => {
if (!docId.value || !docUrl.value) {
return ''
}
const baseUrl = (window as any).ACKIFY_BASE_URL || ''
const params = new URLSearchParams({
doc: docId.value,
url: docUrl.value
})
return `${baseUrl}/api/v1/proxy?${params.toString()}`
})
/**
* Detect content type from URL extension or HEAD request
*/
async function detectContentType(): Promise<void> {
if (!docUrl.value) {
contentType.value = 'unknown'
return
}
// First try to detect from URL extension
const urlLower = docUrl.value.toLowerCase()
const extensionMap: Record<string, ContentType> = {
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.html': 'text/html',
'.htm': 'text/html',
'.md': 'text/markdown',
'.markdown': 'text/markdown',
'.txt': 'text/plain',
'.text': 'text/plain'
}
// Check for extension match (before query params)
const urlWithoutQuery = urlLower.split('?')[0] ?? urlLower
for (const [ext, type] of Object.entries(extensionMap)) {
if (urlWithoutQuery.endsWith(ext)) {
contentType.value = type
return
}
}
// If no extension match, try HEAD request via proxy
if (proxyUrl.value) {
try {
isLoading.value = true
error.value = null
const response = await fetch(proxyUrl.value, {
method: 'HEAD',
credentials: 'include'
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const mimeType = response.headers.get('Content-Type')?.split(';')[0]?.trim()
if (mimeType) {
const mimeMap: Record<string, ContentType> = {
'application/pdf': 'application/pdf',
'image/png': 'image/png',
'image/jpeg': 'image/jpeg',
'image/gif': 'image/gif',
'image/webp': 'image/webp',
'image/svg+xml': 'image/svg+xml',
'text/html': 'text/html',
'text/markdown': 'text/markdown',
'text/x-markdown': 'text/markdown',
'text/plain': 'text/plain'
}
contentType.value = mimeMap[mimeType] || 'unknown'
} else {
contentType.value = 'unknown'
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to detect content type'
contentType.value = 'unknown'
} finally {
isLoading.value = false
}
} else {
contentType.value = 'unknown'
}
}
/**
* Retry loading the document
*/
async function retry(): Promise<void> {
error.value = null
await detectContentType()
}
// Watch for URL changes and re-detect content type
watch([docId, docUrl], () => {
detectContentType()
}, { immediate: true })
return {
proxyUrl,
contentType,
isLoading,
error,
retry
}
}
/**
* Check if content type is an image
*/
export function isImage(type: ContentType): boolean {
return type.startsWith('image/')
}
/**
* Check if content type is SVG (requires sanitization)
*/
export function isSvg(type: ContentType): boolean {
return type === 'image/svg+xml'
}
/**
* Check if content type is HTML (requires sanitization)
*/
export function isHtml(type: ContentType): boolean {
return type === 'text/html'
}
/**
* Check if content type is Markdown
*/
export function isMarkdown(type: ContentType): boolean {
return type === 'text/markdown'
}
/**
* Check if content type is PDF
*/
export function isPdf(type: ContentType): boolean {
return type === 'application/pdf'
}
/**
* Check if content type is plain text
*/
export function isText(type: ContentType): boolean {
return type === 'text/plain'
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"nav": {
"home": "Home",
"myConfirmations": "My confirmations",
"myDocuments": "My documents",
"admin": "Admin",
"administration": "Administration",
"login": "Sign in",
@@ -53,6 +54,61 @@
"error_send": "Failed to send magic link"
}
},
"home": {
"hero": {
"title": "Reading Confirmation",
"subtitle": "Cryptographically certify that your documents have been read and understood",
"createError": "Unable to create document",
"restricted": {
"title": "Creation restricted",
"description": "Document creation is restricted to administrators. Contact your administrator to get a document link."
},
"form": {
"label": "Document URL",
"placeholder": "URL, path or document reference",
"hint": "Accepts URLs, file paths or simple identifiers",
"submit": "Create",
"submitLogin": "Sign in"
}
},
"howItWorks": {
"title": "How does it work?",
"subtitle": "Ackify allows you to cryptographically prove that your documents have been read",
"step1": {
"title": "1. Access the document",
"description": "Enter the URL or reference of the document to confirm"
},
"step2": {
"title": "2. Read the content",
"description": "View the document in the integrated viewer or via the external link"
},
"step3": {
"title": "3. Confirm",
"description": "Your confirmation is recorded with an Ed25519 cryptographic signature"
}
},
"features": {
"crypto": {
"title": "Cryptographic security",
"description": "Non-repudiable Ed25519 signatures guaranteeing authenticity"
},
"instant": {
"title": "Instant",
"description": "Two-click confirmation with immediate cryptographic verification"
},
"timestamp": {
"title": "Precise timestamping",
"description": "Each confirmation is timestamped and chained to ensure integrity"
}
},
"saas": {
"badge": "Coming soon",
"title": "Need more?",
"description": "Team management, advanced API, dashboards, integrations and dedicated support for enterprises.",
"button": "Discover Ackify Pro",
"note": "Available soon"
}
},
"sign": {
"title": "Reading Confirmation",
"subtitle": "Certify your reading with an Ed25519 cryptographic confirmation",
@@ -60,6 +116,25 @@
"title": "Loading document...",
"description": "Please wait while we prepare the document for signing."
},
"external": {
"title": "External document",
"description": "Please read the document below before confirming your reading.",
"openDocument": "Open document",
"noUrl": "No URL available for this document"
},
"alreadySigned": {
"title": "Reading already confirmed",
"signedBy": "Confirmed by",
"email": "Email",
"signatureType": "Confirmation type",
"ed25519": "Ed25519 cryptographic",
"downloadProof": "Download proof"
},
"confirm": {
"title": "Confirm your reading",
"readRequired": "Please read the entire document before confirming.",
"certify": "I certify that I have read and understood the content of this document"
},
"noDocument": {
"title": "No document specified",
"description": "To sign a document, add the {code} parameter to the URL",
@@ -520,5 +595,209 @@
"previous": "Previous",
"skipToContent": "Skip to main content",
"refresh": "Refresh"
},
"documentViewer": {
"loading": "Loading document...",
"toolbar": {
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"download": "Download",
"openInNewTab": "Open in new tab",
"page": "Page {current} / {total}"
},
"progress": "{percent}% read",
"scrollToRead": "Scroll to read the document",
"readComplete": "Reading complete",
"pdfViewer": "PDF viewer",
"htmlViewer": "HTML content",
"imageAlt": "Document image",
"error": {
"title": "Unable to load document",
"loadFailed": "Failed to load document",
"retry": "Retry",
"openExternal": "Open link"
},
"unsupported": {
"title": "Unsupported format",
"description": "This document type cannot be displayed in the embedded viewer.",
"openExternal": "Open document"
}
},
"documentCreateForm": {
"url": {
"label": "Document",
"placeholder": "URL, PATH or document ID",
"helper": "Accepts URLs, file paths or simple identifiers"
},
"submit": "Create",
"submitting": "Creating...",
"title": {
"label": "Title",
"placeholder": "Auto-detected if empty"
},
"readMode": {
"label": "Reading mode",
"integrated": "Integrated viewer",
"external": "External link"
},
"options": {
"title": "Advanced options",
"allowDownload": "Allow download",
"requireFullRead": "Require full reading",
"verifyChecksum": "Verify checksum on each read"
},
"checksum": {
"label": "Checksum",
"placeholder": "Auto-calculated if empty"
},
"algorithm": {
"label": "Algorithm"
},
"description": {
"label": "Description",
"placeholder": "Document description..."
},
"error": {
"urlRequired": "Document URL is required",
"createFailed": "Failed to create document"
}
},
"myDocuments": {
"title": "My Documents",
"subtitle": "Manage documents you have created",
"listTitle": "Document list",
"results": "{count} result | {count} results",
"searchPlaceholder": "Search by title or URL...",
"signatureCount": "signature | signatures",
"totalCount": "{count} document in total | {count} documents in total",
"noResults": "No results",
"tryAnotherSearch": "Try another search",
"stats": {
"total": "Total",
"pending": "Pending",
"completed": "Completed",
"totalDocuments": "Total documents",
"pendingDocuments": "Pending signatures",
"completedDocuments": "Completed"
},
"columns": {
"document": "Document",
"status": "Status",
"createdAt": "Created",
"actions": "Actions"
},
"actions": {
"view": "View",
"copyLink": "Copy link",
"delete": "Delete"
},
"empty": {
"title": "No documents",
"description": "Create your first document to start tracking reading confirmations"
},
"deleteConfirm": {
"title": "Delete this document?",
"message": "This will permanently delete the document \"{title}\" and all associated data."
},
"pagination": {
"page": "Page {current}/{total}"
}
},
"documentEdit": {
"title": "Edit document",
"back": "Back",
"breadcrumb": {
"myDocuments": "My documents"
},
"unauthorized": {
"title": "Unauthorized access",
"description": "You do not have permission to access this document.",
"backToDocuments": "Back to my documents"
},
"shareLink": {
"title": "Share link",
"copy": "Copy",
"copied": "Copied!"
},
"stats": {
"expected": "Expected readers",
"confirmed": "Confirmed",
"pending": "Pending"
},
"metadata": {
"title": "Metadata",
"description": "Basic document information",
"titleLabel": "Title",
"titlePlaceholder": "Document title",
"urlLabel": "URL",
"urlPlaceholder": "https://example.com/document.pdf",
"checksumLabel": "Checksum",
"checksumPlaceholder": "File fingerprint (optional)",
"algorithmLabel": "Algorithm",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Document description...",
"createdBy": "Created by {by} on {date}",
"saving": "Saving..."
},
"metadataSaved": "Metadata saved successfully",
"readers": {
"title": "Expected readers",
"confirmed": "confirmed",
"add": "Add",
"filterPlaceholder": "Filter by email or name...",
"reader": "Reader",
"status": "Status",
"confirmedOn": "Confirmed on",
"statusConfirmed": "Confirmed",
"statusPending": "Pending",
"noReaders": "No expected readers"
},
"signersAdded": "{count} reader(s) added",
"signerRemoved": "Reader {email} removed",
"addSigners": {
"title": "Add readers",
"emailsLabel": "Email addresses",
"emailsPlaceholder": "email{'@'}example.com\nFirst Last <other{'@'}example.com>",
"emailsHelper": "One email address per line. Format: email or Name <email>",
"adding": "Adding...",
"add": "Add"
},
"removeSigner": {
"title": "Remove this reader?",
"message": "Are you sure you want to remove {email} from the expected readers list?"
},
"reminders": {
"title": "Email reminders",
"description": "Send reminders to pending readers",
"sent": "Reminders sent",
"toRemind": "To remind",
"lastSent": "Last sent",
"emailDisabled": "The email service is not configured.",
"sendToAll": "Send to all pending readers ({count})",
"sendToSelected": "Send to selected readers ({count})",
"sending": "Sending...",
"send": "Send reminders"
},
"confirmSendReminders": "Send reminders to {count} pending reader(s)?",
"confirmSendRemindersSelected": "Send reminders to {count} selected reader(s)?",
"remindersSentSuccess": "{count} reminder(s) sent successfully",
"remindersSentPartial": "{sent} reminder(s) sent, {failed} failed",
"remindersSentGeneric": "Reminders sent",
"sendReminders": {
"title": "Confirm sending"
},
"danger": {
"title": "Danger zone",
"description": "Irreversible actions on this document",
"deleteDocument": "Delete document",
"deleteDescription": "This will permanently delete the document and all associated data."
},
"deleteConfirm": {
"title": "Delete this document?",
"warning": "Warning!",
"message": "This action is irreversible. All confirmations, expected readers and history will be deleted.",
"deleting": "Deleting...",
"confirm": "Delete permanently"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"nav": {
"home": "Accueil",
"myConfirmations": "Mes confirmations",
"myDocuments": "Mes documents",
"admin": "Admin",
"administration": "Administration",
"login": "Se connecter",
@@ -53,6 +54,61 @@
"error_send": "Échec de l'envoi du lien magique"
}
},
"home": {
"hero": {
"title": "Confirmation de Lecture",
"subtitle": "Certifiez cryptographiquement que vos documents ont été lus et compris",
"createError": "Impossible de créer le document",
"restricted": {
"title": "Création restreinte",
"description": "La création de documents est réservée aux administrateurs. Contactez votre administrateur pour obtenir un lien de document."
},
"form": {
"label": "URL du document",
"placeholder": "URL, chemin ou référence du document",
"hint": "Accepte les URLs, chemins de fichiers ou identifiants simples",
"submit": "Créer",
"submitLogin": "Se connecter"
}
},
"howItWorks": {
"title": "Comment ça fonctionne ?",
"subtitle": "Ackify vous permet de prouver cryptographiquement que vos documents ont été lus",
"step1": {
"title": "1. Accédez au document",
"description": "Entrez l'URL ou la référence du document à confirmer"
},
"step2": {
"title": "2. Lisez le contenu",
"description": "Consultez le document dans le lecteur intégré ou via le lien externe"
},
"step3": {
"title": "3. Confirmez",
"description": "Votre confirmation est enregistrée avec une signature cryptographique Ed25519"
}
},
"features": {
"crypto": {
"title": "Sécurité cryptographique",
"description": "Signatures Ed25519 non répudiables garantissant l'authenticité"
},
"instant": {
"title": "Instantané",
"description": "Confirmation en deux clics avec vérification cryptographique immédiate"
},
"timestamp": {
"title": "Horodatage précis",
"description": "Chaque confirmation est horodatée et chaînée pour garantir l'intégrité"
}
},
"saas": {
"badge": "Bientôt disponible",
"title": "Besoin de plus ?",
"description": "Gestion d'équipes, API avancée, tableaux de bord, intégrations et support dédié pour les entreprises.",
"button": "Découvrir Ackify Pro",
"note": "Disponible prochainement"
}
},
"sign": {
"title": "Confirmation de Lecture",
"subtitle": "Certifiez votre lecture avec une confirmation cryptographique Ed25519",
@@ -60,6 +116,25 @@
"title": "Chargement du document...",
"description": "Veuillez patienter pendant que nous préparons le document pour la signature."
},
"external": {
"title": "Document externe",
"description": "Veuillez lire le document ci-dessous avant de confirmer votre lecture.",
"openDocument": "Ouvrir le document",
"noUrl": "Aucune URL disponible pour ce document"
},
"alreadySigned": {
"title": "Lecture déjà confirmée",
"signedBy": "Confirmé par",
"email": "Email",
"signatureType": "Type de confirmation",
"ed25519": "Ed25519 cryptographique",
"downloadProof": "Télécharger la preuve"
},
"confirm": {
"title": "Confirmer votre lecture",
"readRequired": "Veuillez lire l'intégralité du document avant de confirmer.",
"certify": "Je certifie avoir lu et compris le contenu de ce document"
},
"noDocument": {
"title": "Aucun document spécifié",
"description": "Pour signer un document, ajoutez le paramètre {code} à l'URL",
@@ -517,5 +592,209 @@
"previous": "Précédent",
"skipToContent": "Aller au contenu principal",
"refresh": "Actualiser"
},
"documentViewer": {
"loading": "Chargement du document...",
"toolbar": {
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière",
"download": "Télécharger",
"openInNewTab": "Ouvrir dans un nouvel onglet",
"page": "Page {current} / {total}"
},
"progress": "{percent}% lu",
"scrollToRead": "Faites défiler pour lire le document",
"readComplete": "Lecture complète",
"pdfViewer": "Lecteur PDF",
"htmlViewer": "Contenu HTML",
"imageAlt": "Document image",
"error": {
"title": "Impossible de charger le document",
"loadFailed": "Le chargement du document a échoué",
"retry": "Réessayer",
"openExternal": "Ouvrir le lien"
},
"unsupported": {
"title": "Format non supporté",
"description": "Ce type de document ne peut pas être affiché dans le lecteur intégré.",
"openExternal": "Ouvrir le document"
}
},
"documentCreateForm": {
"url": {
"label": "Document",
"placeholder": "URL, PATH ou ID du document",
"helper": "Accepte les URLs, les chemins de fichiers ou les identifiants simples"
},
"submit": "Créer",
"submitting": "Création...",
"title": {
"label": "Titre",
"placeholder": "Auto-détecté si vide"
},
"readMode": {
"label": "Mode de lecture",
"integrated": "Lecteur intégré",
"external": "Lien externe"
},
"options": {
"title": "Options avancées",
"allowDownload": "Autoriser le téléchargement",
"requireFullRead": "Exiger la lecture complète",
"verifyChecksum": "Vérifier le checksum à chaque lecture"
},
"checksum": {
"label": "Checksum",
"placeholder": "Auto-calculé si vide"
},
"algorithm": {
"label": "Algorithme"
},
"description": {
"label": "Description",
"placeholder": "Description du document..."
},
"error": {
"urlRequired": "L'URL du document est requise",
"createFailed": "Échec de la création du document"
}
},
"myDocuments": {
"title": "Mes documents",
"subtitle": "Gérer les documents que vous avez créés",
"listTitle": "Liste des documents",
"results": "{count} résultat | {count} résultats",
"searchPlaceholder": "Rechercher par titre ou URL...",
"signatureCount": "signature | signatures",
"totalCount": "{count} document au total | {count} documents au total",
"noResults": "Aucun résultat",
"tryAnotherSearch": "Essayez une autre recherche",
"stats": {
"total": "Total",
"pending": "En attente",
"completed": "Complétés",
"totalDocuments": "Documents totaux",
"pendingDocuments": "En attente de signatures",
"completedDocuments": "Complétés"
},
"columns": {
"document": "Document",
"status": "Statut",
"createdAt": "Créé le",
"actions": "Actions"
},
"actions": {
"view": "Voir",
"copyLink": "Copier le lien",
"delete": "Supprimer"
},
"empty": {
"title": "Aucun document",
"description": "Créez votre premier document pour commencer à suivre les confirmations de lecture"
},
"deleteConfirm": {
"title": "Supprimer ce document ?",
"message": "Cette action supprimera définitivement le document \"{title}\" et toutes les données associées."
},
"pagination": {
"page": "Page {current}/{total}"
}
},
"documentEdit": {
"title": "Édition du document",
"back": "Retour",
"breadcrumb": {
"myDocuments": "Mes documents"
},
"unauthorized": {
"title": "Accès non autorisé",
"description": "Vous n'avez pas les droits pour accéder à ce document.",
"backToDocuments": "Retour à mes documents"
},
"shareLink": {
"title": "Lien de partage",
"copy": "Copier",
"copied": "Copié !"
},
"stats": {
"expected": "Lecteurs attendus",
"confirmed": "Confirmés",
"pending": "En attente"
},
"metadata": {
"title": "Métadonnées",
"description": "Informations de base du document",
"titleLabel": "Titre",
"titlePlaceholder": "Titre du document",
"urlLabel": "URL",
"urlPlaceholder": "https://exemple.com/document.pdf",
"checksumLabel": "Checksum",
"checksumPlaceholder": "Empreinte du fichier (optionnel)",
"algorithmLabel": "Algorithme",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Description du document...",
"createdBy": "Créé par {by} le {date}",
"saving": "Enregistrement..."
},
"metadataSaved": "Métadonnées enregistrées avec succès",
"readers": {
"title": "Lecteurs attendus",
"confirmed": "confirmés",
"add": "Ajouter",
"filterPlaceholder": "Filtrer par email ou nom...",
"reader": "Lecteur",
"status": "Statut",
"confirmedOn": "Confirmé le",
"statusConfirmed": "Confirmé",
"statusPending": "En attente",
"noReaders": "Aucun lecteur attendu"
},
"signersAdded": "{count} lecteur(s) ajouté(s)",
"signerRemoved": "Lecteur {email} retiré",
"addSigners": {
"title": "Ajouter des lecteurs",
"emailsLabel": "Adresses email",
"emailsPlaceholder": "email{'@'}exemple.com\nNom Prénom <autre{'@'}exemple.com>",
"emailsHelper": "Une adresse email par ligne. Format: email ou Nom <email>",
"adding": "Ajout en cours...",
"add": "Ajouter"
},
"removeSigner": {
"title": "Retirer ce lecteur ?",
"message": "Êtes-vous sûr de vouloir retirer {email} de la liste des lecteurs attendus ?"
},
"reminders": {
"title": "Relances par email",
"description": "Envoyez des rappels aux lecteurs en attente",
"sent": "Rappels envoyés",
"toRemind": "À relancer",
"lastSent": "Dernier envoi",
"emailDisabled": "Le service d'envoi d'email n'est pas configuré.",
"sendToAll": "Envoyer à tous les lecteurs en attente ({count})",
"sendToSelected": "Envoyer aux lecteurs sélectionnés ({count})",
"sending": "Envoi en cours...",
"send": "Envoyer les rappels"
},
"confirmSendReminders": "Envoyer des rappels à {count} lecteur(s) en attente ?",
"confirmSendRemindersSelected": "Envoyer des rappels à {count} lecteur(s) sélectionné(s) ?",
"remindersSentSuccess": "{count} rappel(s) envoyé(s) avec succès",
"remindersSentPartial": "{sent} rappel(s) envoyé(s), {failed} échec(s)",
"remindersSentGeneric": "Rappels envoyés",
"sendReminders": {
"title": "Confirmer l'envoi"
},
"danger": {
"title": "Zone de danger",
"description": "Actions irréversibles sur ce document",
"deleteDocument": "Supprimer le document",
"deleteDescription": "Cette action supprimera définitivement le document et toutes les données associées."
},
"deleteConfirm": {
"title": "Supprimer ce document ?",
"warning": "Attention !",
"message": "Cette action est irréversible. Toutes les confirmations, lecteurs attendus et l'historique seront supprimés.",
"deleting": "Suppression...",
"confirm": "Supprimer définitivement"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,780 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePageTitle } from '@/composables/usePageTitle'
import { useAuthStore } from '@/stores/auth'
import { useI18n } from 'vue-i18n'
import {
getDocumentStatus,
updateDocumentMetadata,
addExpectedSigner,
removeExpectedSigner,
sendReminders,
deleteDocument,
type DocumentStatus,
} from '@/services/admin'
import { extractError } from '@/services/http'
import {
ArrowLeft,
Users,
CheckCircle,
Mail,
Plus,
Loader2,
Copy,
Clock,
X,
Trash2,
Search,
AlertCircle,
ChevronRight,
ExternalLink,
Check,
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { t, locale } = useI18n()
// Data
const docId = computed(() => route.params.id as string)
usePageTitle('documentEdit.title', { docId: docId.value })
const documentStatus = ref<DocumentStatus | null>(null)
const loading = ref(true)
const error = ref('')
const success = ref('')
const unauthorized = ref(false)
// Modals
const showAddSignersModal = ref(false)
const showDeleteConfirmModal = ref(false)
const showRemoveSignerModal = ref(false)
const showSendRemindersModal = ref(false)
const signerToRemove = ref('')
const remindersMessage = ref('')
// Metadata form
const metadataForm = ref<Partial<{
title: string
url: string
checksum: string
checksumAlgorithm: string
description: string
}>>({
title: '',
url: '',
checksum: '',
checksumAlgorithm: 'SHA-256',
description: '',
})
const savingMetadata = ref(false)
// Expected signers form
const signersEmails = ref('')
const addingSigners = ref(false)
const signerFilter = ref('')
// Reminders
const sendMode = ref<'all' | 'selected'>('all')
const selectedEmails = ref<string[]>([])
const sendingReminders = ref(false)
// Delete
const deletingDocument = ref(false)
// Copy feedback
const copied = ref(false)
// Computed
const shareLink = computed(() => {
if (!documentStatus.value) return ''
return documentStatus.value.shareLink
})
const stats = computed(() => documentStatus.value?.stats)
const reminderStats = computed(() => documentStatus.value?.reminderStats)
const smtpEnabled = computed(() => (window as any).ACKIFY_SMTP_ENABLED || false)
const expectedSigners = computed(() => documentStatus.value?.expectedSigners || [])
const filteredSigners = computed(() => {
const filter = signerFilter.value.toLowerCase().trim()
if (!filter) return expectedSigners.value
return expectedSigners.value.filter(signer =>
signer.email.toLowerCase().includes(filter) ||
(signer.name && signer.name.toLowerCase().includes(filter)) ||
(signer.userName && signer.userName.toLowerCase().includes(filter))
)
})
const documentMetadata = computed(() => documentStatus.value?.document)
const documentTitle = computed(() => documentMetadata.value?.title || docId.value)
// Methods
async function loadDocumentStatus() {
try {
loading.value = true
error.value = ''
unauthorized.value = false
const response = await getDocumentStatus(docId.value)
documentStatus.value = response.data
// Check authorization
if (!authStore.isAdmin && documentStatus.value.document?.createdBy !== authStore.user?.email) {
unauthorized.value = true
return
}
// Pre-fill metadata form if document exists
if (documentStatus.value.document) {
const doc = documentStatus.value.document
metadataForm.value = {
title: doc.title || '',
url: doc.url || '',
checksum: doc.checksum || '',
checksumAlgorithm: doc.checksumAlgorithm || 'SHA-256',
description: doc.description || '',
}
}
} catch (err: any) {
if (err?.response?.status === 403 || err?.response?.status === 404) {
unauthorized.value = true
} else {
error.value = extractError(err)
}
console.error('Failed to load document status:', err)
} finally {
loading.value = false
}
}
async function saveMetadata() {
try {
savingMetadata.value = true
error.value = ''
success.value = ''
await updateDocumentMetadata(docId.value, metadataForm.value)
success.value = t('documentEdit.metadataSaved')
await loadDocumentStatus()
setTimeout(() => (success.value = ''), 3000)
} catch (err) {
error.value = extractError(err)
console.error('Failed to save metadata:', err)
} finally {
savingMetadata.value = false
}
}
async function addSigners() {
if (!signersEmails.value.trim()) return
try {
addingSigners.value = true
error.value = ''
success.value = ''
const lines = signersEmails.value.split('\n').filter(l => l.trim())
let addedCount = 0
for (const line of lines) {
const trimmed = line.trim()
const match = trimmed.match(/^(.+?)\s*<(.+?)>$/)
const email = match && match[2] ? match[2].trim() : trimmed
const name = match && match[1] ? match[1].trim() : ''
try {
await addExpectedSigner(docId.value, { email, name })
addedCount++
} catch (err) {
console.error(`Failed to add ${email}:`, err)
}
}
showAddSignersModal.value = false
signersEmails.value = ''
success.value = t('documentEdit.signersAdded', { count: addedCount })
await loadDocumentStatus()
setTimeout(() => (success.value = ''), 3000)
} catch (err) {
error.value = extractError(err)
console.error('Failed to add signers:', err)
} finally {
addingSigners.value = false
}
}
function confirmRemoveSigner(email: string) {
signerToRemove.value = email
showRemoveSignerModal.value = true
}
async function removeSigner() {
const email = signerToRemove.value
if (!email) return
try {
error.value = ''
success.value = ''
await removeExpectedSigner(docId.value, email)
success.value = t('documentEdit.signerRemoved', { email })
showRemoveSignerModal.value = false
signerToRemove.value = ''
await loadDocumentStatus()
setTimeout(() => (success.value = ''), 3000)
} catch (err) {
error.value = extractError(err)
console.error('Failed to remove signer:', err)
}
}
function confirmSendReminders() {
remindersMessage.value =
sendMode.value === 'all'
? t('documentEdit.confirmSendReminders', { count: reminderStats.value?.pendingCount || 0 })
: t('documentEdit.confirmSendRemindersSelected', { count: selectedEmails.value.length })
showSendRemindersModal.value = true
}
async function sendRemindersAction() {
try {
sendingReminders.value = true
error.value = ''
success.value = ''
const normalizedLocale = locale.value.split('-')[0]
const response = await sendReminders(
docId.value,
{
emails: sendMode.value === 'selected' ? selectedEmails.value : undefined,
},
normalizedLocale
)
selectedEmails.value = []
showSendRemindersModal.value = false
if (response.data.result) {
const result = response.data.result
if (result.failed > 0) {
success.value = t('documentEdit.remindersSentPartial', { sent: result.successfullySent, failed: result.failed })
} else {
success.value = t('documentEdit.remindersSentSuccess', { count: result.successfullySent })
}
} else {
success.value = t('documentEdit.remindersSentGeneric')
}
await loadDocumentStatus()
setTimeout(() => (success.value = ''), 3000)
} catch (err) {
error.value = extractError(err)
console.error('Failed to send reminders:', err)
} finally {
sendingReminders.value = false
}
}
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(shareLink.value)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
function formatDate(dateString: string | undefined): string {
if (!dateString) return 'N/A'
const date = new Date(dateString)
return date.toLocaleDateString(locale.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function toggleEmailSelection(email: string) {
const index = selectedEmails.value.indexOf(email)
if (index > -1) {
selectedEmails.value.splice(index, 1)
} else {
selectedEmails.value.push(email)
}
}
async function handleDeleteDocument() {
try {
deletingDocument.value = true
error.value = ''
await deleteDocument(docId.value)
showDeleteConfirmModal.value = false
router.push('/documents')
} catch (err) {
error.value = extractError(err)
console.error('Failed to delete document:', err)
showDeleteConfirmModal.value = false
} finally {
deletingDocument.value = false
}
}
onMounted(async () => {
if (!authStore.initialized) {
await authStore.checkAuth()
}
loadDocumentStatus()
})
</script>
<template>
<div class="min-h-[calc(100vh-8rem)]">
<main class="mx-auto max-w-6xl px-4 sm:px-6 py-6 sm:py-8">
<!-- Breadcrumb -->
<nav class="flex items-center gap-2 text-sm mb-6">
<router-link to="/documents" class="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors">
{{ t('documentEdit.breadcrumb.myDocuments') }}
</router-link>
<ChevronRight :size="16" class="text-slate-300 dark:text-slate-600" />
<span class="text-slate-900 dark:text-slate-100 font-medium truncate max-w-[200px]">{{ documentTitle }}</span>
</nav>
<!-- Page Header -->
<div class="mb-8">
<div class="flex items-center space-x-3 mb-2">
<button
@click="router.push('/documents')"
class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
:aria-label="t('documentEdit.back')"
>
<ArrowLeft :size="20" class="text-slate-600 dark:text-slate-400" />
</button>
<h1 class="text-xl sm:text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
{{ documentTitle }}
</h1>
</div>
</div>
<!-- Alerts -->
<div v-if="error" class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
<div class="flex items-start">
<AlertCircle :size="20" class="mr-3 mt-0.5 text-red-600 dark:text-red-400 flex-shrink-0" />
<p class="text-sm text-red-700 dark:text-red-300">{{ error }}</p>
</div>
</div>
<div v-if="success" class="mb-6 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl p-4">
<div class="flex items-start">
<CheckCircle :size="20" class="mr-3 mt-0.5 text-emerald-600 dark:text-emerald-400 flex-shrink-0" />
<p class="text-sm text-emerald-700 dark:text-emerald-300">{{ success }}</p>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex flex-col items-center justify-center py-24">
<Loader2 :size="48" class="animate-spin text-blue-600" />
<p class="mt-4 text-slate-500 dark:text-slate-400">{{ t('common.loading') }}</p>
</div>
<!-- Unauthorized -->
<div v-else-if="unauthorized" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-8 text-center">
<div class="w-16 h-16 mx-auto bg-red-100 dark:bg-red-900/30 rounded-2xl flex items-center justify-center mb-4">
<AlertCircle :size="32" class="text-red-600 dark:text-red-400" />
</div>
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-2">{{ t('documentEdit.unauthorized.title') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-6">{{ t('documentEdit.unauthorized.description') }}</p>
<router-link
to="/documents"
class="inline-flex items-center gap-2 trust-gradient text-white font-medium rounded-lg px-6 py-2.5 hover:opacity-90 transition-opacity"
>
<ArrowLeft :size="16" />
{{ t('documentEdit.unauthorized.backToDocuments') }}
</router-link>
</div>
<!-- Document Content -->
<div v-else-if="documentStatus" class="space-y-6">
<!-- Share Link Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<h2 class="font-semibold text-slate-900 dark:text-slate-100 mb-4">{{ t('documentEdit.shareLink.title') }}</h2>
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1 relative">
<input
type="text"
:value="shareLink"
readonly
class="w-full px-4 py-2.5 pr-10 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm font-mono"
/>
<a
:href="shareLink"
target="_blank"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
<ExternalLink :size="16" />
</a>
</div>
<button
@click="copyToClipboard"
class="inline-flex items-center justify-center gap-2 bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
>
<Check v-if="copied" :size="16" class="text-emerald-500" />
<Copy v-else :size="16" />
{{ copied ? t('documentEdit.shareLink.copied') : t('documentEdit.shareLink.copy') }}
</button>
</div>
</div>
<!-- Stats Cards -->
<div v-if="stats && stats.expectedCount > 0" class="grid gap-4 grid-cols-2 lg:grid-cols-3">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<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 flex-shrink-0">
<Users :size="20" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ t('documentEdit.stats.expected') }}</p>
<p class="text-xl font-bold text-slate-900 dark:text-slate-100">{{ stats.expectedCount }}</p>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center flex-shrink-0">
<CheckCircle :size="20" class="text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ t('documentEdit.stats.confirmed') }}</p>
<p class="text-xl font-bold text-slate-900 dark:text-slate-100">{{ stats.signedCount }}</p>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center flex-shrink-0">
<Clock :size="20" class="text-amber-600 dark:text-amber-400" />
</div>
<div>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ t('documentEdit.stats.pending') }}</p>
<p class="text-xl font-bold text-slate-900 dark:text-slate-100">{{ stats.pendingCount }}</p>
</div>
</div>
</div>
</div>
<!-- Document Metadata -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('documentEdit.metadata.title') }}</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ t('documentEdit.metadata.description') }}</p>
</div>
<div class="p-6">
<form @submit.prevent="saveMetadata" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('documentEdit.metadata.titleLabel') }}</label>
<input v-model="metadataForm.title" :placeholder="t('documentEdit.metadata.titlePlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('documentEdit.metadata.urlLabel') }}</label>
<input v-model="metadataForm.url" type="url" :placeholder="t('documentEdit.metadata.urlPlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('documentEdit.metadata.checksumLabel') }}</label>
<input v-model="metadataForm.checksum" :placeholder="t('documentEdit.metadata.checksumPlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div class="md:min-w-[140px]">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('documentEdit.metadata.algorithmLabel') }}</label>
<select v-model="metadataForm.checksumAlgorithm" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="SHA-256">SHA-256</option>
<option value="SHA-512">SHA-512</option>
<option value="MD5">MD5</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('documentEdit.metadata.descriptionLabel') }}</label>
<textarea v-model="metadataForm.description" rows="4" :placeholder="t('documentEdit.metadata.descriptionPlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"></textarea>
</div>
<div v-if="documentMetadata" class="text-xs text-slate-500 dark:text-slate-400 pt-2 border-t border-slate-100 dark:border-slate-700">
{{ t('documentEdit.metadata.createdBy', { by: documentMetadata.createdBy, date: formatDate(documentMetadata.createdAt) }) }}
</div>
<div class="flex justify-end">
<button type="submit" :disabled="savingMetadata" class="trust-gradient text-white font-medium rounded-lg px-6 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<Loader2 v-if="savingMetadata" :size="16" class="animate-spin" />
{{ savingMetadata ? t('documentEdit.metadata.saving') : t('common.save') }}
</button>
</div>
</form>
</div>
</div>
<!-- Expected Readers -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('documentEdit.readers.title') }}</h2>
<p v-if="stats" class="text-sm text-slate-500 dark:text-slate-400">{{ stats.signedCount }} / {{ stats.expectedCount }} {{ t('documentEdit.readers.confirmed') }}</p>
</div>
<button @click="showAddSignersModal = true" class="trust-gradient text-white font-medium rounded-lg px-3 py-2 text-sm hover:opacity-90 transition-opacity inline-flex items-center gap-2">
<Plus :size="16" />
{{ t('documentEdit.readers.add') }}
</button>
</div>
</div>
<div class="p-6">
<div v-if="expectedSigners.length > 0">
<div class="relative mb-4">
<Search :size="16" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<input v-model="signerFilter" :placeholder="t('documentEdit.readers.filterPlaceholder')" class="w-full pl-9 pr-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<!-- Table Desktop -->
<div class="hidden md:block overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-slate-100 dark:border-slate-700">
<th class="px-4 py-3 w-10">
<input type="checkbox" class="rounded border-slate-300 dark:border-slate-600" @change="(e: any) => selectedEmails = e.target.checked ? expectedSigners.filter(s => !s.hasSigned).map(s => s.email) : []" />
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('documentEdit.readers.reader') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('documentEdit.readers.status') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('documentEdit.readers.confirmedOn') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tr v-for="signer in filteredSigners" :key="signer.email" class="hover:bg-slate-50 dark:hover:bg-slate-700/50">
<td class="px-4 py-3">
<input v-if="!signer.hasSigned" type="checkbox" class="rounded border-slate-300 dark:border-slate-600" :checked="selectedEmails.includes(signer.email)" @change="toggleEmailSelection(signer.email)" />
</td>
<td class="px-4 py-3">
<div>
<p class="font-medium text-slate-900 dark:text-slate-100">{{ signer.userName || signer.name || signer.email }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ signer.email }}</p>
</div>
</td>
<td class="px-4 py-3">
<span :class="['inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-full', signer.hasSigned ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400']">
{{ signer.hasSigned ? t('documentEdit.readers.statusConfirmed') : t('documentEdit.readers.statusPending') }}
</span>
</td>
<td class="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">
{{ signer.signedAt ? formatDate(signer.signedAt) : '-' }}
</td>
<td class="px-4 py-3">
<button v-if="!signer.hasSigned" @click="confirmRemoveSigner(signer.email)" class="p-1.5 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
<Trash2 :size="16" class="text-red-600 dark:text-red-400" />
</button>
<span v-else class="text-xs text-slate-400">-</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Cards Mobile -->
<div class="md:hidden space-y-3">
<div v-for="signer in filteredSigners" :key="signer.email" class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4">
<div class="flex items-start justify-between mb-2">
<div class="flex items-start gap-3">
<input v-if="!signer.hasSigned" type="checkbox" class="mt-1 rounded border-slate-300 dark:border-slate-600" :checked="selectedEmails.includes(signer.email)" @change="toggleEmailSelection(signer.email)" />
<div>
<p class="font-medium text-slate-900 dark:text-slate-100">{{ signer.userName || signer.name || signer.email }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ signer.email }}</p>
</div>
</div>
<span :class="['inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full', signer.hasSigned ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400']">
{{ signer.hasSigned ? t('documentEdit.readers.statusConfirmed') : t('documentEdit.readers.statusPending') }}
</span>
</div>
<div class="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span>{{ signer.signedAt ? formatDate(signer.signedAt) : '-' }}</span>
<button v-if="!signer.hasSigned" @click="confirmRemoveSigner(signer.email)" class="p-1 text-red-600 dark:text-red-400">
<Trash2 :size="14" />
</button>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-8">
<Users :size="48" class="mx-auto mb-4 text-slate-300 dark:text-slate-600" />
<p class="text-slate-500 dark:text-slate-400">{{ t('documentEdit.readers.noReaders') }}</p>
</div>
</div>
</div>
<!-- Email Reminders -->
<div v-if="reminderStats && stats && stats.expectedCount > 0 && (smtpEnabled || reminderStats.totalSent > 0)" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('documentEdit.reminders.title') }}</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ t('documentEdit.reminders.description') }}</p>
</div>
<div class="p-6 space-y-6">
<div class="grid gap-4 grid-cols-1 sm:grid-cols-3">
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('documentEdit.reminders.sent') }}</p>
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ reminderStats.totalSent }}</p>
</div>
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('documentEdit.reminders.toRemind') }}</p>
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ reminderStats.pendingCount }}</p>
</div>
<div v-if="reminderStats.lastSentAt" class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('documentEdit.reminders.lastSent') }}</p>
<p class="text-sm font-bold text-slate-900 dark:text-slate-100">{{ formatDate(reminderStats.lastSentAt) }}</p>
</div>
</div>
<div v-if="!smtpEnabled" class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
<p class="text-sm text-amber-800 dark:text-amber-200">{{ t('documentEdit.reminders.emailDisabled') }}</p>
</div>
<div v-if="smtpEnabled" class="space-y-4">
<div class="space-y-2">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="radio" v-model="sendMode" value="all" class="text-blue-600 focus:ring-blue-500" />
<span class="text-sm text-slate-700 dark:text-slate-300">{{ t('documentEdit.reminders.sendToAll', { count: reminderStats.pendingCount }) }}</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input type="radio" v-model="sendMode" value="selected" class="text-blue-600 focus:ring-blue-500" />
<span class="text-sm text-slate-700 dark:text-slate-300">{{ t('documentEdit.reminders.sendToSelected', { count: selectedEmails.length }) }}</span>
</label>
</div>
<button @click="confirmSendReminders" :disabled="sendingReminders || (sendMode === 'selected' && selectedEmails.length === 0)" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
<Mail :size="16" />
{{ sendingReminders ? t('documentEdit.reminders.sending') : t('documentEdit.reminders.send') }}
</button>
</div>
</div>
</div>
<!-- Danger Zone -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-red-200 dark:border-red-800/50">
<div class="p-6 border-b border-red-100 dark:border-red-800/30">
<h2 class="font-semibold text-red-600 dark:text-red-400">{{ t('documentEdit.danger.title') }}</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ t('documentEdit.danger.description') }}</p>
</div>
<div class="p-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<div>
<h3 class="font-semibold text-slate-900 dark:text-slate-100 mb-1">{{ t('documentEdit.danger.deleteDocument') }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('documentEdit.danger.deleteDescription') }}</p>
</div>
<button @click="showDeleteConfirmModal = true" class="inline-flex items-center justify-center gap-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg px-4 py-2.5 text-sm transition-colors flex-shrink-0">
<Trash2 :size="16" />
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
</main>
<!-- Add Signers Modal -->
<div v-if="showAddSignersModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showAddSignersModal = false">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-2xl w-full max-h-[90vh] overflow-auto">
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('documentEdit.addSigners.title') }}</h2>
<button @click="showAddSignersModal = false" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
<X :size="20" class="text-slate-400" />
</button>
</div>
<div class="p-6">
<form @submit.prevent="addSigners" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('documentEdit.addSigners.emailsLabel') }}</label>
<textarea v-model="signersEmails" rows="8" :placeholder="t('documentEdit.addSigners.emailsPlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"></textarea>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-2">{{ t('documentEdit.addSigners.emailsHelper') }}</p>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="showAddSignersModal = false" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
<button type="submit" :disabled="addingSigners || !signersEmails.trim()" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
<Loader2 v-if="addingSigners" :size="16" class="animate-spin" />
{{ addingSigners ? t('documentEdit.addSigners.adding') : t('documentEdit.addSigners.add') }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteConfirmModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showDeleteConfirmModal = false">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-red-200 dark:border-red-800 max-w-md w-full">
<div class="p-6 border-b border-red-100 dark:border-red-800/30 flex items-center justify-between">
<h2 class="font-semibold text-red-600 dark:text-red-400">{{ t('documentEdit.deleteConfirm.title') }}</h2>
<button @click="showDeleteConfirmModal = false" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
<X :size="20" class="text-slate-400" />
</button>
</div>
<div class="p-6 space-y-4">
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
<p class="font-semibold text-red-900 dark:text-red-200 mb-2">{{ t('documentEdit.deleteConfirm.warning') }}</p>
<p class="text-sm text-red-700 dark:text-red-300">{{ t('documentEdit.deleteConfirm.message') }}</p>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="showDeleteConfirmModal = false" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
<button @click="handleDeleteDocument" :disabled="deletingDocument" class="bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg px-4 py-2.5 text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
<Trash2 v-if="!deletingDocument" :size="16" />
<Loader2 v-else :size="16" class="animate-spin" />
{{ deletingDocument ? t('documentEdit.deleteConfirm.deleting') : t('documentEdit.deleteConfirm.confirm') }}
</button>
</div>
</div>
</div>
</div>
<!-- Remove Signer Modal -->
<div v-if="showRemoveSignerModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showRemoveSignerModal = false">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-md w-full">
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('documentEdit.removeSigner.title') }}</h2>
<button @click="showRemoveSignerModal = false" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
<X :size="20" class="text-slate-400" />
</button>
</div>
<div class="p-6 space-y-4">
<p class="text-sm text-slate-600 dark:text-slate-400">{{ t('documentEdit.removeSigner.message', { email: signerToRemove }) }}</p>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="showRemoveSignerModal = false" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
<button @click="removeSigner" class="bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg px-4 py-2.5 text-sm transition-colors">{{ t('common.delete') }}</button>
</div>
</div>
</div>
</div>
<!-- Send Reminders Modal -->
<div v-if="showSendRemindersModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showSendRemindersModal = false">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-md w-full">
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('documentEdit.sendReminders.title') }}</h2>
<button @click="showSendRemindersModal = false" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
<X :size="20" class="text-slate-400" />
</button>
</div>
<div class="p-6 space-y-4">
<p class="text-sm text-slate-600 dark:text-slate-400">{{ remindersMessage }}</p>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="showSendRemindersModal = false" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
<button @click="sendRemindersAction" :disabled="sendingReminders" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
<Loader2 v-if="sendingReminders" :size="16" class="animate-spin" />
{{ t('common.confirm') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -58,10 +58,11 @@
</div>
<!-- Signatures list -->
<div class="space-y-2">
<div class="space-y-2" data-testid="signatures-list">
<div
v-for="signature in documentData.signatures"
:key="signature.id"
data-testid="signature-item"
class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center justify-between gap-3"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
@@ -72,7 +73,7 @@
</div>
<span class="text-sm font-medium text-slate-900 dark:text-white truncate">{{ signature.userEmail }}</span>
</div>
<span class="text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap">{{ formatDateCompact(signature.signedAt) }}</span>
<span data-testid="signature-date" class="text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap">{{ formatDateCompact(signature.signedAt) }}</span>
</div>
</div>
</div>

View File

@@ -7,17 +7,33 @@ import { useSignatureStore } from '@/stores/signatures'
import { useI18n } from 'vue-i18n'
import { usePageTitle } from '@/composables/usePageTitle'
const { t } = useI18n()
const { t, locale } = useI18n()
usePageTitle('sign.title')
import { AlertTriangle, CheckCircle2, FileText, Info, Users, Loader2, Shield, Zap, Clock } from 'lucide-vue-next'
import {
AlertTriangle,
CheckCircle2,
FileText,
Users,
Loader2,
Shield,
Zap,
Clock,
ExternalLink,
Download,
Check,
Eye,
ArrowRight,
Sparkles,
Lock
} from 'lucide-vue-next'
import SignButton from '@/components/SignButton.vue'
import SignatureList from '@/components/SignatureList.vue'
import DocumentViewer from '@/components/viewer/DocumentViewer.vue'
import { documentService, type FindOrCreateDocumentResponse } from '@/services/documents'
import { detectReference } from '@/services/referenceDetector'
import { calculateFileChecksum } from '@/services/checksumCalculator'
import { updateDocumentMetadata } from '@/services/admin'
import DocumentForm from "@/components/DocumentForm.vue"
const route = useRoute()
const router = useRouter()
@@ -26,13 +42,15 @@ const signatureStore = useSignatureStore()
const docId = ref<string | undefined>(undefined)
const user = computed(() => authStore.user)
const isAdmin = computed(() => authStore.isAdmin)
// Check if document creation is restricted to admins
const onlyAdminCanCreate = (window as any).ACKIFY_ONLY_ADMIN_CAN_CREATE || false
const canCreateDocument = computed(() => !onlyAdminCanCreate || isAdmin.value)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const canCreateDocuments = computed(() => authStore.canCreateDocuments)
const currentDocument = ref<FindOrCreateDocumentResponse | null>(null)
// Quick create form
const quickCreateUrl = ref('')
const quickCreateLoading = ref(false)
const quickCreateError = ref<string | null>(null)
const documentSignatures = ref<any[]>([])
const loadingSignatures = ref(false)
const loadingDocument = ref(false)
@@ -41,6 +59,10 @@ const errorMessage = ref<string | null>(null)
const needsAuth = ref(false)
const calculatingChecksum = ref(false)
// New state for integrated viewer
const readComplete = ref(false)
const certifyChecked = ref(false)
// Check if current user has signed this document
const userHasSigned = computed(() => {
if (!user.value?.email || documentSignatures.value.length === 0) {
@@ -49,6 +71,26 @@ const userHasSigned = computed(() => {
return documentSignatures.value.some(sig => sig.userEmail === user.value?.email)
})
// Get user's signature if exists
const userSignature = computed(() => {
if (!user.value?.email || documentSignatures.value.length === 0) {
return null
}
return documentSignatures.value.find(sig => sig.userEmail === user.value?.email)
})
// Document properties
const isIntegratedMode = computed(() => currentDocument.value?.readMode === 'integrated')
const requiresFullRead = computed(() => currentDocument.value?.requireFullRead ?? false)
const allowDownload = computed(() => currentDocument.value?.allowDownload ?? false)
// Can confirm: checkbox checked AND (if requireFullRead: must have completed read)
const canConfirm = computed(() => {
if (!certifyChecked.value) return false
if (isIntegratedMode.value && requiresFullRead.value && !readComplete.value) return false
return true
})
async function loadDocumentSignatures() {
if (!docId.value) return
@@ -67,6 +109,8 @@ async function handleDocumentReference(ref: string) {
loadingDocument.value = true
errorMessage.value = null
needsAuth.value = false
readComplete.value = false
certifyChecked.value = false
console.log('Loading document for reference:', ref)
@@ -87,7 +131,6 @@ async function handleDocumentReference(ref: string) {
name: route.name as string,
query: { doc: doc.docId }
})
// Continue loading even after redirect
}
// If new document AND downloadable URL → calculate checksum
@@ -100,7 +143,6 @@ async function handleDocumentReference(ref: string) {
} catch (error: any) {
console.error('Failed to load/create document:', error)
// Handle 401 Unauthorized - user needs to authenticate
if (error.response?.status === 401) {
errorMessage.value = t('sign.error.authRequired')
needsAuth.value = true
@@ -117,6 +159,32 @@ function handleLoginClick() {
authStore.startOAuthLogin(route.fullPath)
}
async function handleQuickCreate() {
if (!quickCreateUrl.value.trim()) return
quickCreateError.value = null
quickCreateLoading.value = true
try {
// If not authenticated, redirect to login with next parameter
if (!isAuthenticated.value) {
// Store the URL to create after login
const nextUrl = `/documents/new?ref=${encodeURIComponent(quickCreateUrl.value.trim())}`
router.push({ name: 'auth-choice', query: { next: nextUrl } })
return
}
// Create document and redirect to edit page
const doc = await documentService.findOrCreateDocument(quickCreateUrl.value.trim())
router.push({ name: 'document-edit', params: { id: doc.docId } })
} catch (error: any) {
console.error('Failed to create document:', error)
quickCreateError.value = error.message || t('home.hero.createError')
} finally {
quickCreateLoading.value = false
}
}
async function calculateAndUpdateChecksum(docId: string, url: string) {
try {
calculatingChecksum.value = true
@@ -125,14 +193,12 @@ async function calculateAndUpdateChecksum(docId: string, url: string) {
const checksumData = await calculateFileChecksum(url)
console.log('Checksum calculated:', checksumData.checksum)
// Update document metadata with checksum (if user is admin)
if (authStore.isAdmin) {
await updateDocumentMetadata(docId, {
checksum: checksumData.checksum,
checksumAlgorithm: checksumData.algorithm
})
// Update local document reference
if (currentDocument.value) {
currentDocument.value.checksum = checksumData.checksum
currentDocument.value.checksumAlgorithm = checksumData.algorithm
@@ -144,20 +210,22 @@ async function calculateAndUpdateChecksum(docId: string, url: string) {
}
} catch (error) {
console.warn('Checksum calculation failed:', error)
// Don't fail the whole operation if checksum fails
} finally {
calculatingChecksum.value = false
}
}
function handleReadComplete() {
readComplete.value = true
}
async function handleSigned() {
showSuccessMessage.value = true
errorMessage.value = null
certifyChecked.value = false
// Reload signatures to show the new one
await loadDocumentSignatures()
// Hide success message after 5 seconds
setTimeout(() => {
showSuccessMessage.value = false
}, 5000)
@@ -168,12 +236,52 @@ function handleError(error: string) {
showSuccessMessage.value = false
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function downloadProof() {
if (!userSignature.value || !currentDocument.value) return
const proof = {
document: {
id: currentDocument.value.docId,
title: currentDocument.value.title,
url: currentDocument.value.url,
checksum: currentDocument.value.checksum,
algorithm: currentDocument.value.checksumAlgorithm
},
signature: {
email: userSignature.value.userEmail,
name: userSignature.value.userName,
signedAt: userSignature.value.signedAt,
signature: userSignature.value.signature,
payloadHash: userSignature.value.payloadHash,
nonce: userSignature.value.nonce
},
generatedAt: new Date().toISOString()
}
const blob = new Blob([JSON.stringify(proof, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `proof-${currentDocument.value.docId}-${Date.now()}.json`
a.click()
URL.revokeObjectURL(url)
}
// Helper to wait for auth to be initialized by App.vue
async function waitForAuth() {
// If already initialized, return immediately
if (authStore.initialized) return
// Otherwise wait for initialized to become true
return new Promise<void>((resolve) => {
const stopWatch = watch(
() => authStore.initialized,
@@ -188,33 +296,28 @@ async function waitForAuth() {
})
}
// Watch for route query changes (only for changes, not initial mount)
// Watch for route query changes
watch(() => route.query.doc, async (newRef, oldRef) => {
// Only process if the doc query parameter actually changed
if (newRef === oldRef) return
// Reset state
showSuccessMessage.value = false
errorMessage.value = null
needsAuth.value = false
docId.value = undefined
currentDocument.value = null
documentSignatures.value = []
readComplete.value = false
certifyChecked.value = false
// If we have a reference, load/create the document
if (newRef && typeof newRef === 'string') {
// Wait for App.vue to finish checking auth
await waitForAuth()
await handleDocumentReference(newRef)
}
})
onMounted(async () => {
// CRITICAL: Wait for App.vue to finish auth check before doing anything
// App.vue calls checkAuth() which will set initialized=true when done
await waitForAuth()
// Now handle the document reference if present in URL
const ref = route.query.doc as string | undefined
if (ref) {
await handleDocumentReference(ref)
@@ -224,17 +327,7 @@ onMounted(async () => {
<template>
<div class="min-h-[calc(100vh-8rem)]">
<!-- Main Content -->
<div class="mx-auto max-w-6xl px-4 sm:px-6 py-6 sm:py-8">
<!-- Page Header -->
<div class="mb-8 text-center">
<h1 class="mb-2 text-2xl sm:text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
{{ t('sign.title') }}
</h1>
<p class="text-base sm:text-lg text-slate-500 dark:text-slate-400">
{{ t('sign.subtitle') }}
</p>
</div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 py-6 sm:py-8">
<!-- Error Message -->
<transition
@@ -268,41 +361,192 @@ onMounted(async () => {
<div v-if="loadingDocument" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-12 text-center">
<Loader2 :size="48" class="mx-auto mb-4 animate-spin text-blue-600" />
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-2">{{ t('sign.loading.title') }}</h2>
<p class="text-slate-500 dark:text-slate-400">
{{ t('sign.loading.description') }}
</p>
<p class="text-slate-500 dark:text-slate-400">{{ t('sign.loading.description') }}</p>
</div>
<!-- No Document: Show help message -->
<div v-else-if="!docId" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-12 text-center">
<div class="w-14 h-14 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center mx-auto mb-4">
<FileText :size="28" class="text-blue-600 dark:text-blue-400" />
</div>
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-2">{{ t('sign.noDocument.title') }}</h2>
<p class="text-slate-500 dark:text-slate-400 mb-4 max-w-md mx-auto">
{{ t('sign.noDocument.description', { code: '?doc=' }) }}
</p>
<div class="text-sm text-slate-500 dark:text-slate-400 space-y-2 max-w-md mx-auto">
<p class="font-medium text-slate-700 dark:text-slate-300">{{ t('sign.noDocument.examples') }}</p>
<code class="block px-3 py-2 bg-slate-50 dark:bg-slate-900 rounded-lg text-xs font-mono text-slate-600 dark:text-slate-400">/?doc=https://example.com/policy.pdf</code>
<code class="block px-3 py-2 bg-slate-50 dark:bg-slate-900 rounded-lg text-xs font-mono text-slate-600 dark:text-slate-400">/?doc=/path/to/document</code>
<code class="block px-3 py-2 bg-slate-50 dark:bg-slate-900 rounded-lg text-xs font-mono text-slate-600 dark:text-slate-400">/?doc=my-unique-ref</code>
<!-- No Document: Hero Section -->
<div v-else-if="!docId" class="space-y-16">
<!-- Hero -->
<div class="text-center pt-8 sm:pt-12">
<h1 class="mb-4 text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
{{ t('home.hero.title') }}
</h1>
<p class="text-lg sm:text-xl text-slate-500 dark:text-slate-400 max-w-2xl mx-auto mb-8">
{{ t('home.hero.subtitle') }}
</p>
<!-- Document creation form -->
<DocumentForm v-if="canCreateDocument" class="mt-6" />
<!-- Quick Create Card -->
<div class="max-w-xl mx-auto">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 shadow-sm">
<!-- Error message -->
<div v-if="quickCreateError" class="mb-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
<div class="flex items-start gap-2">
<AlertTriangle :size="16" class="mt-0.5 text-red-600 dark:text-red-400 flex-shrink-0" />
<p class="text-sm text-red-800 dark:text-red-200">{{ quickCreateError }}</p>
</div>
</div>
<!-- Restricted message -->
<div v-else class="mt-4 accent-border bg-amber-50 dark:bg-amber-900/20 rounded-r-lg p-4 text-left">
<div class="flex items-start">
<AlertTriangle :size="18" class="mr-3 mt-0.5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<p class="text-sm text-amber-700 dark:text-amber-300">{{ t('sign.documentCreation.restrictedToAdmins') }}</p>
<!-- Creation restricted message -->
<div v-if="isAuthenticated && !canCreateDocuments" class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex items-start gap-3">
<Lock :size="20" class="mt-0.5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<div class="text-left">
<p class="font-medium text-amber-900 dark:text-amber-200">{{ t('home.hero.restricted.title') }}</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mt-1">{{ t('home.hero.restricted.description') }}</p>
</div>
</div>
</div>
<!-- Form -->
<form v-else @submit.prevent="handleQuickCreate" class="space-y-4">
<div>
<label for="quick-create-url" class="sr-only">{{ t('home.hero.form.label') }}</label>
<div class="flex gap-2">
<input
id="quick-create-url"
v-model="quickCreateUrl"
type="text"
:placeholder="t('home.hero.form.placeholder')"
:disabled="quickCreateLoading"
class="flex-1 px-4 py-3 text-sm border border-slate-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
type="submit"
:disabled="!quickCreateUrl.trim() || quickCreateLoading"
class="inline-flex items-center gap-2 trust-gradient text-white font-medium rounded-lg px-6 py-3 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
<Loader2 v-if="quickCreateLoading" :size="18" class="animate-spin" />
<template v-else>
{{ isAuthenticated ? t('home.hero.form.submit') : t('home.hero.form.submitLogin') }}
<ArrowRight :size="16" />
</template>
</button>
</div>
<p class="mt-2 text-xs text-slate-500 dark:text-slate-400">
{{ t('home.hero.form.hint') }}
</p>
</div>
</form>
</div>
</div>
</div>
<!-- How it Works Section -->
<div class="border-t border-slate-200 dark:border-slate-700 pt-16">
<div class="text-center mb-12">
<h2 class="mb-3 text-2xl sm:text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
{{ t('home.howItWorks.title') }}
</h2>
<p class="text-slate-500 dark:text-slate-400 max-w-2xl mx-auto">
{{ t('home.howItWorks.subtitle') }}
</p>
</div>
<!-- Steps Grid -->
<div class="grid gap-8 grid-cols-1 md:grid-cols-3 mb-12">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 text-center hover:shadow-md transition-shadow">
<div class="mb-4 inline-flex h-14 w-14 items-center justify-center rounded-xl bg-blue-50 dark:bg-blue-900/30">
<FileText :size="28" class="text-blue-600 dark:text-blue-400" />
</div>
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{{ t('home.howItWorks.step1.title') }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('home.howItWorks.step1.description') }}
</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 text-center hover:shadow-md transition-shadow">
<div class="mb-4 inline-flex h-14 w-14 items-center justify-center rounded-xl bg-blue-50 dark:bg-blue-900/30">
<Eye :size="28" class="text-blue-600 dark:text-blue-400" />
</div>
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{{ t('home.howItWorks.step2.title') }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('home.howItWorks.step2.description') }}
</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 text-center hover:shadow-md transition-shadow">
<div class="mb-4 inline-flex h-14 w-14 items-center justify-center rounded-xl bg-emerald-50 dark:bg-emerald-900/30">
<Shield :size="28" class="text-emerald-600 dark:text-emerald-400" />
</div>
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{{ t('home.howItWorks.step3.title') }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('home.howItWorks.step3.description') }}
</p>
</div>
</div>
<!-- Features -->
<div class="grid gap-6 grid-cols-1 md:grid-cols-3">
<div class="flex items-start space-x-3">
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/30 p-2 mt-1 flex-shrink-0">
<Shield :size="20" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="font-medium text-slate-900 dark:text-slate-100 mb-1">{{ t('home.features.crypto.title') }}</h4>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('home.features.crypto.description') }}
</p>
</div>
</div>
<div class="flex items-start space-x-3">
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/30 p-2 mt-1 flex-shrink-0">
<Zap :size="20" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="font-medium text-slate-900 dark:text-slate-100 mb-1">{{ t('home.features.instant.title') }}</h4>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('home.features.instant.description') }}
</p>
</div>
</div>
<div class="flex items-start space-x-3">
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/30 p-2 mt-1 flex-shrink-0">
<Clock :size="20" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="font-medium text-slate-900 dark:text-slate-100 mb-1">{{ t('home.features.timestamp.title') }}</h4>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('home.features.timestamp.description') }}
</p>
</div>
</div>
</div>
</div>
<!-- SaaS CTA Section -->
<div class="border-t border-slate-200 dark:border-slate-700 pt-16">
<div class="bg-gradient-to-br from-slate-50 to-blue-50 dark:from-slate-800 dark:to-blue-900/20 rounded-2xl border border-slate-200 dark:border-slate-700 p-8 sm:p-12 text-center">
<!-- Coming Soon Badge -->
<div class="inline-flex items-center gap-2 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-xs font-medium px-3 py-1 rounded-full mb-6">
<Sparkles :size="14" />
{{ t('home.saas.badge') }}
</div>
<h2 class="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-slate-100 mb-4">
{{ t('home.saas.title') }}
</h2>
<p class="text-slate-600 dark:text-slate-400 max-w-xl mx-auto mb-8">
{{ t('home.saas.description') }}
</p>
<button
disabled
class="inline-flex items-center gap-2 bg-slate-300 dark:bg-slate-600 text-slate-500 dark:text-slate-400 font-medium rounded-lg px-6 py-3 text-sm cursor-not-allowed"
>
{{ t('home.saas.button') }}
</button>
<p class="mt-4 text-xs text-slate-500 dark:text-slate-500">
{{ t('home.saas.note') }}
</p>
</div>
</div>
</div>
<!-- Main Content when doc ID is present -->
<div v-else-if="docId" class="space-y-6">
<div v-else-if="docId && currentDocument" class="space-y-6">
<!-- Success Message -->
<transition
enter-active-class="transition ease-out duration-300"
@@ -323,188 +567,193 @@ onMounted(async () => {
</div>
</transition>
<!-- Document Info Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<!-- Header with icon -->
<div class="flex items-start gap-4 mb-6">
<div class="w-14 h-14 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
<FileText :size="28" class="text-blue-600 dark:text-blue-400" />
</div>
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100">
{{ t('sign.document.title') }}<template v-if="currentDocument?.title"> : {{ currentDocument.title }}</template>
</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
<template v-if="currentDocument?.url">
<a
:href="currentDocument.url"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:underline font-mono text-xs break-all"
>
{{ currentDocument.url }}
</a>
</template>
<template v-else>
<span class="font-mono text-xs">{{ docId }}</span>
</template>
</p>
</div>
</div>
<!-- Sign Button -->
<div class="pb-4">
<SignButton
:doc-id="docId"
:signatures="documentSignatures"
@signed="handleSigned"
@error="handleError"
/>
</div>
<!-- Info Box (only shown if user hasn't signed yet) -->
<div v-if="!userHasSigned" class="accent-border bg-blue-50 dark:bg-blue-900/20 rounded-r-lg p-4">
<div class="flex items-start">
<Info :size="18" class="mr-3 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div class="flex-1 space-y-2 text-sm text-blue-800 dark:text-blue-200">
<p>{{ t('sign.info.description') }}</p>
<p class="font-medium">{{ t('sign.info.recorded') }}</p>
<ul class="list-disc space-y-1 pl-5 text-blue-700 dark:text-blue-300">
<li>{{ t('sign.info.email') }} : <strong class="text-blue-900 dark:text-blue-100">{{ user?.email }}</strong></li>
<li>{{ t('sign.info.timestamp') }}</li>
<li>{{ t('sign.info.signature') }}</li>
<li>{{ t('sign.info.hash') }}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Existing Confirmations -->
<div v-if="documentSignatures.length > 0" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<!-- Header -->
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
<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" />
</div>
<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.recorded', {}, documentSignatures.length) }}
</p>
</div>
</div>
</div>
<!-- List -->
<div class="p-6">
<SignatureList
:signatures="documentSignatures"
:loading="loadingSignatures"
:show-user-info="true"
:show-details="true"
/>
</div>
</div>
<!-- Empty State -->
<div v-else-if="!loadingSignatures" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-12 text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700">
<Users :size="28" class="text-slate-400" />
</div>
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">
{{ t('sign.empty.title') }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('sign.empty.description') }}
</p>
</div>
</div>
<!-- How it Works Section -->
<div class="mt-16 pt-12 border-t border-slate-200 dark:border-slate-700">
<div class="text-center mb-12">
<h2 class="mb-3 text-xl sm:text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
{{ t('sign.howItWorks.title') }}
</h2>
<p class="text-slate-500 dark:text-slate-400 max-w-2xl mx-auto">
{{ t('sign.howItWorks.subtitle') }}
</p>
</div>
<!-- Steps Grid -->
<div class="grid gap-6 sm:gap-8 grid-cols-1 md:grid-cols-3 mb-12">
<!-- Step 1 -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 text-center hover:shadow-md transition-shadow">
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-blue-50 dark:bg-blue-900/30">
<!-- Document Header -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 sm:p-6">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
<FileText :size="24" class="text-blue-600 dark:text-blue-400" />
</div>
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{{ t('sign.howItWorks.step1.title') }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('sign.howItWorks.step1.description', { code: '?doc=URL' }) }}
</p>
</div>
<!-- Step 2 -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 text-center hover:shadow-md transition-shadow">
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-blue-50 dark:bg-blue-900/30">
<Shield :size="24" class="text-blue-600 dark:text-blue-400" />
<div class="flex-1 min-w-0">
<h1 class="text-lg sm:text-xl font-semibold text-slate-900 dark:text-slate-100">
{{ currentDocument.title || t('sign.document.title') }}
</h1>
<p v-if="currentDocument.url" class="mt-1 text-sm text-slate-500 dark:text-slate-400 font-mono text-xs break-all">
{{ currentDocument.url }}
</p>
<p v-else class="mt-1 text-sm text-slate-500 dark:text-slate-400">
<span class="font-mono text-xs">{{ t('sign.document.id') }}: {{ docId }}</span>
</p>
</div>
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{{ t('sign.howItWorks.step2.title') }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('sign.howItWorks.step2.description') }}
</p>
</div>
<!-- Step 3 -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 text-center hover:shadow-md transition-shadow">
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 dark:bg-emerald-900/30">
<CheckCircle2 :size="24" class="text-emerald-600 dark:text-emerald-400" />
</div>
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{{ t('sign.howItWorks.step3.title') }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('sign.howItWorks.step3.description') }}
</p>
</div>
</div>
<!-- Features -->
<div class="grid gap-6 grid-cols-1 md:grid-cols-3">
<div class="flex items-start space-x-3">
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/30 p-2 mt-1 flex-shrink-0">
<Shield :size="20" class="text-blue-600 dark:text-blue-400" />
<!-- Two Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left: Document Zone (2/3) -->
<div class="lg:col-span-2 space-y-6">
<!-- Integrated Viewer -->
<div v-if="isIntegratedMode && currentDocument.url">
<DocumentViewer
:document-id="docId"
:url="currentDocument.url"
:allow-download="allowDownload"
:require-full-read="requiresFullRead"
@read-complete="handleReadComplete"
/>
</div>
<div>
<h4 class="font-medium text-slate-900 dark:text-slate-100 mb-1">{{ t('sign.howItWorks.features.crypto.title') }}</h4>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('sign.howItWorks.features.crypto.description') }}
<!-- External Mode -->
<div v-else class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-8 text-center">
<div class="w-16 h-16 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center mx-auto mb-4">
<ExternalLink :size="32" class="text-blue-600 dark:text-blue-400" />
</div>
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
{{ t('sign.external.title') }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-6 max-w-md mx-auto">
{{ t('sign.external.description') }}
</p>
<a
v-if="currentDocument.url"
:href="currentDocument.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 trust-gradient text-white font-medium rounded-lg px-6 py-3 text-sm hover:opacity-90 transition-opacity"
>
<ExternalLink :size="18" />
{{ t('sign.external.openDocument') }}
</a>
<p v-else class="text-sm text-slate-400 dark:text-slate-500 italic">
{{ t('sign.external.noUrl') }}
</p>
</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">
<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" />
</div>
<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) }}
</p>
</div>
</div>
</div>
<div class="p-4 sm:p-6">
<SignatureList
:signatures="documentSignatures"
:loading="loadingSignatures"
:show-user-info="true"
:show-details="true"
/>
</div>
</div>
</div>
<div class="flex items-start space-x-3">
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/30 p-2 mt-1 flex-shrink-0">
<Zap :size="20" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="font-medium text-slate-900 dark:text-slate-100 mb-1">{{ t('sign.howItWorks.features.instant.title') }}</h4>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('sign.howItWorks.features.instant.description') }}
</p>
</div>
</div>
<!-- Right: Confirmation Panel (1/3) -->
<div class="lg:col-span-1">
<div class="lg:sticky lg:top-24 space-y-4">
<!-- Already Signed Panel -->
<div v-if="userHasSigned && userSignature" class="bg-emerald-50 dark:bg-emerald-900/20 rounded-xl border border-emerald-200 dark:border-emerald-800 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-emerald-100 dark:bg-emerald-900/50 flex items-center justify-center">
<Check :size="20" class="text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<h3 class="font-semibold text-emerald-900 dark:text-emerald-200">{{ t('sign.alreadySigned.title') }}</h3>
<p class="text-xs text-emerald-700 dark:text-emerald-400">{{ formatDate(userSignature.signedAt) }}</p>
</div>
</div>
<div class="flex items-start space-x-3">
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/30 p-2 mt-1 flex-shrink-0">
<Clock :size="20" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="font-medium text-slate-900 dark:text-slate-100 mb-1">{{ t('sign.howItWorks.features.timestamp.title') }}</h4>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('sign.howItWorks.features.timestamp.description') }}
</p>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-emerald-700 dark:text-emerald-400">{{ t('sign.alreadySigned.signedBy') }}</span>
<span class="font-medium text-emerald-900 dark:text-emerald-200">{{ userSignature.userName || userSignature.userEmail }}</span>
</div>
<div class="flex justify-between">
<span class="text-emerald-700 dark:text-emerald-400">{{ t('sign.alreadySigned.email') }}</span>
<span class="font-mono text-xs text-emerald-900 dark:text-emerald-200">{{ userSignature.userEmail }}</span>
</div>
<div class="flex justify-between">
<span class="text-emerald-700 dark:text-emerald-400">{{ t('sign.alreadySigned.signatureType') }}</span>
<span class="font-mono text-xs text-emerald-900 dark:text-emerald-200">Ed25519</span>
</div>
</div>
<button
@click="downloadProof"
class="mt-4 w-full inline-flex items-center justify-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-4 py-2.5 text-sm transition-colors"
>
<Download :size="16" />
{{ t('sign.alreadySigned.downloadProof') }}
</button>
</div>
<!-- Not Yet Signed Panel -->
<div v-else class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<h3 class="font-semibold text-slate-900 dark:text-slate-100 mb-4">{{ t('sign.confirm.title') }}</h3>
<!-- Warning if requireFullRead and not completed -->
<div v-if="isIntegratedMode && requiresFullRead && !readComplete" class="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<div class="flex items-start gap-2">
<AlertTriangle :size="16" class="mt-0.5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<p class="text-sm text-amber-800 dark:text-amber-200">{{ t('sign.confirm.readRequired') }}</p>
</div>
</div>
<!-- Info box -->
<div class="accent-border bg-blue-50 dark:bg-blue-900/20 rounded-r-lg p-3 mb-4">
<p class="text-xs text-blue-800 dark:text-blue-200">{{ t('sign.info.description') }}</p>
</div>
<!-- What will be recorded -->
<div class="mb-4">
<p class="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">{{ t('sign.info.recorded') }}</p>
<ul class="text-xs text-slate-500 dark:text-slate-400 space-y-1">
<li class="flex items-center gap-2">
<div class="w-1 h-1 rounded-full bg-blue-500"></div>
{{ t('sign.info.email') }}: <span class="font-medium text-slate-700 dark:text-slate-300">{{ user?.email }}</span>
</li>
<li class="flex items-center gap-2">
<div class="w-1 h-1 rounded-full bg-blue-500"></div>
{{ t('sign.info.timestamp') }}
</li>
<li class="flex items-center gap-2">
<div class="w-1 h-1 rounded-full bg-blue-500"></div>
{{ t('sign.info.signature') }}
</li>
<li class="flex items-center gap-2">
<div class="w-1 h-1 rounded-full bg-blue-500"></div>
{{ t('sign.info.hash') }}
</li>
</ul>
</div>
<!-- Checkbox -->
<label class="flex items-start gap-3 mb-4 cursor-pointer">
<input
type="checkbox"
v-model="certifyChecked"
class="mt-0.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-slate-700 dark:text-slate-300">
{{ t('sign.confirm.certify') }}
</span>
</label>
<!-- Sign Button -->
<SignButton
:doc-id="docId"
:signatures="documentSignatures"
:disabled="!canConfirm"
@signed="handleSigned"
@error="handleError"
/>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,584 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { usePageTitle } from '@/composables/usePageTitle'
import { useAuthStore } from '@/stores/auth'
import { documentService, type MyDocument, type FindOrCreateDocumentResponse } from '@/services/documents'
import { extractError } from '@/services/http'
import DocumentCreateForm from '@/components/DocumentCreateForm.vue'
import {
FileText,
Clock,
CheckCircle,
Eye,
Copy,
Trash2,
Loader2,
Search,
ChevronLeft,
ChevronRight,
AlertCircle,
RefreshCw,
Check,
} from 'lucide-vue-next'
const router = useRouter()
const { t, locale } = useI18n()
const authStore = useAuthStore()
usePageTitle('myDocuments.title')
const documents = ref<MyDocument[]>([])
const loading = ref(true)
const searching = ref(false)
const error = ref('')
// Pagination & Search
const searchQuery = ref('')
const currentPage = ref(1)
const perPage = ref(20)
const totalDocsCount = ref(0)
let searchTimeout: ReturnType<typeof setTimeout> | null = null
// Delete confirmation
const deletingDocId = ref<string | null>(null)
const showDeleteConfirm = ref(false)
const docToDelete = ref<MyDocument | null>(null)
// Copy feedback
const copiedDocId = ref<string | null>(null)
// Computed
const totalPages = computed(() => Math.ceil(totalDocsCount.value / perPage.value) || 1)
// Stats
const totalDocuments = computed(() => totalDocsCount.value)
const pendingDocuments = computed(() =>
documents.value.filter(d => d.expectedSignerCount > 0 && d.signatureCount < d.expectedSignerCount).length
)
const completedDocuments = computed(() =>
documents.value.filter(d => d.expectedSignerCount > 0 && d.signatureCount >= d.expectedSignerCount).length
)
// Check if user can access this page
const canAccess = computed(() => {
if (!authStore.isAuthenticated) return false
if (authStore.isAdmin) return true
// Check ACKIFY_ONLY_ADMIN_CAN_CREATE window variable
const onlyAdminCanCreate = (window as any).ACKIFY_ONLY_ADMIN_CAN_CREATE || false
return !onlyAdminCanCreate
})
// Base URL for share links
const baseUrl = computed(() => (window as any).ACKIFY_BASE_URL || window.location.origin)
async function loadDocuments(isInitialLoad = false) {
try {
if (isInitialLoad) {
loading.value = true
} else {
searching.value = true
}
error.value = ''
const response = await documentService.listMyDocuments(
perPage.value,
currentPage.value,
searchQuery.value || undefined
)
documents.value = response.data
totalDocsCount.value = response.meta.total
} catch (err) {
error.value = extractError(err)
console.error('Failed to load documents:', err)
} finally {
loading.value = false
searching.value = false
}
}
function handleSearchInput() {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
loadDocuments()
}, 300)
}
watch(searchQuery, () => {
handleSearchInput()
})
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
loadDocuments()
}
}
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--
loadDocuments()
}
}
function handleDocumentCreated(_doc: FindOrCreateDocumentResponse) {
// Refresh the list after creation
loadDocuments()
}
function viewDocument(doc: MyDocument) {
router.push({ name: 'document-edit', params: { id: doc.id } })
}
async function copyShareLink(doc: MyDocument) {
const shareUrl = `${baseUrl.value}/sign?doc=${doc.id}`
try {
await navigator.clipboard.writeText(shareUrl)
copiedDocId.value = doc.id
setTimeout(() => {
copiedDocId.value = null
}, 2000)
} catch (err) {
console.error('Failed to copy link:', err)
}
}
function confirmDelete(doc: MyDocument) {
docToDelete.value = doc
showDeleteConfirm.value = true
}
async function deleteDocument() {
if (!docToDelete.value) return
try {
deletingDocId.value = docToDelete.value.id
await documentService.deleteDocument(docToDelete.value.id)
showDeleteConfirm.value = false
docToDelete.value = null
loadDocuments()
} catch (err) {
error.value = extractError(err)
console.error('Failed to delete document:', err)
} finally {
deletingDocId.value = null
}
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString(locale.value, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function truncateUrl(url: string, maxLength = 40): string {
if (!url || url.length <= maxLength) return url
return url.substring(0, maxLength) + '...'
}
function getStatusBadge(doc: MyDocument): { text: string; class: string } {
if (doc.expectedSignerCount === 0) {
return {
text: `${doc.signatureCount} ${t('myDocuments.signatureCount', doc.signatureCount)}`,
class: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300'
}
}
const isComplete = doc.signatureCount >= doc.expectedSignerCount
return {
text: `${doc.signatureCount}/${doc.expectedSignerCount}`,
class: isComplete
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400'
: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
}
}
onMounted(async () => {
// Check access and redirect if not allowed
if (!authStore.initialized) {
await authStore.checkAuth()
}
if (!canAccess.value) {
router.push({ name: 'home' })
return
}
loadDocuments(true)
})
</script>
<template>
<div class="min-h-[calc(100vh-8rem)]">
<!-- Main Content -->
<main class="mx-auto max-w-6xl px-4 sm:px-6 py-6 sm:py-8">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-50">
{{ t('myDocuments.title') }}
</h1>
<p class="mt-1 text-base text-slate-500 dark:text-slate-400">
{{ t('myDocuments.subtitle') }}
</p>
</div>
<div class="flex items-center gap-3">
<button
@click="loadDocuments()"
:disabled="loading || searching"
class="inline-flex items-center justify-center gap-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50"
>
<RefreshCw :size="16" :class="(loading || searching) ? 'animate-spin' : ''" />
<span class="hidden sm:inline">{{ t('common.refresh') }}</span>
</button>
</div>
</div>
<!-- Error Alert -->
<div v-if="error" class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
<div class="flex items-start">
<AlertCircle :size="20" class="mr-3 mt-0.5 text-red-600 dark:text-red-400 flex-shrink-0" />
<div class="flex-1">
<h3 class="font-medium text-red-900 dark:text-red-200">{{ t('common.error') }}</h3>
<p class="mt-1 text-sm text-red-700 dark:text-red-300">{{ error }}</p>
</div>
</div>
</div>
<!-- Create Form -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 mb-8">
<DocumentCreateForm
mode="compact"
:redirect-on-create="false"
@created="handleDocumentCreated"
/>
</div>
<div v-if="loading" class="flex flex-col items-center justify-center py-24">
<Loader2 :size="48" class="animate-spin text-blue-600" />
<p class="mt-4 text-slate-500 dark:text-slate-400">{{ t('common.loading') }}</p>
</div>
<!-- Content -->
<div v-else>
<!-- Stats Pills Mobile -->
<div class="md:hidden mb-6 grid grid-cols-3 gap-3">
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
<FileText :size="18" />
<span class="text-xl font-bold">{{ totalDocuments }}</span>
<span class="text-xs whitespace-nowrap">{{ t('myDocuments.stats.total') }}</span>
</div>
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-xl bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<Clock :size="18" />
<span class="text-xl font-bold">{{ pendingDocuments }}</span>
<span class="text-xs whitespace-nowrap">{{ t('myDocuments.stats.pending') }}</span>
</div>
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<CheckCircle :size="18" />
<span class="text-xl font-bold">{{ completedDocuments }}</span>
<span class="text-xs whitespace-nowrap">{{ t('myDocuments.stats.completed') }}</span>
</div>
</div>
<!-- Stats Cards Desktop -->
<div class="hidden md:grid mb-8 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<!-- Total Documents -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
<FileText :size="24" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('myDocuments.stats.totalDocuments') }}</p>
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ totalDocuments }}</p>
</div>
</div>
</div>
<!-- Pending -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
<Clock :size="24" class="text-amber-600 dark:text-amber-400" />
</div>
<div>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('myDocuments.stats.pendingDocuments') }}</p>
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ pendingDocuments }}</p>
</div>
</div>
</div>
<!-- Completed -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
<CheckCircle :size="24" class="text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('myDocuments.stats.completedDocuments') }}</p>
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ completedDocuments }}</p>
</div>
</div>
</div>
</div>
<!-- Documents List -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
<div class="flex flex-col gap-4">
<div>
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('myDocuments.listTitle') }}</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ t('myDocuments.results', { count: totalDocsCount }) }}
</p>
</div>
<div class="relative">
<Search v-if="!searching" :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<Loader2 v-else :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 animate-spin" />
<input
v-model="searchQuery"
type="text"
:placeholder="t('myDocuments.searchPlaceholder')"
class="w-full pl-10 pr-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
</div>
<div class="p-6">
<!-- Desktop Table -->
<div v-if="documents.length > 0" class="hidden md:block overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-slate-100 dark:border-slate-700">
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{{ t('myDocuments.columns.document') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{{ t('myDocuments.columns.status') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{{ t('myDocuments.columns.createdAt') }}
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{{ t('myDocuments.columns.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tr
v-for="doc in documents"
:key="doc.id"
class="hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors cursor-pointer"
@click="viewDocument(doc)"
>
<td class="px-4 py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0">
<FileText :size="18" class="text-slate-500 dark:text-slate-400" />
</div>
<div class="min-w-0">
<div class="font-medium text-slate-900 dark:text-slate-100 truncate">{{ doc.title || doc.id }}</div>
<div v-if="doc.url" class="text-xs text-slate-500 dark:text-slate-400 truncate max-w-[250px]">
{{ truncateUrl(doc.url) }}
</div>
</div>
</div>
</td>
<td class="px-4 py-4">
<span
:class="getStatusBadge(doc).class"
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
>
{{ getStatusBadge(doc).text }}
</span>
</td>
<td class="px-4 py-4 text-sm text-slate-500 dark:text-slate-400">
{{ formatDate(doc.createdAt) }}
</td>
<td class="px-4 py-4 text-right" @click.stop>
<div class="flex items-center justify-end gap-1">
<button
@click="viewDocument(doc)"
class="p-2 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
:title="t('myDocuments.actions.view')"
>
<Eye :size="16" />
</button>
<button
@click="copyShareLink(doc)"
class="p-2 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
:title="t('myDocuments.actions.copyLink')"
>
<Check v-if="copiedDocId === doc.id" :size="16" class="text-emerald-500" />
<Copy v-else :size="16" />
</button>
<button
@click="confirmDelete(doc)"
class="p-2 text-slate-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
:title="t('myDocuments.actions.delete')"
>
<Trash2 :size="16" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Mobile Cards -->
<div v-if="documents.length > 0" class="md:hidden space-y-4">
<div
v-for="doc in documents"
:key="doc.id"
class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4"
@click="viewDocument(doc)"
>
<!-- Document Title -->
<div class="flex items-start gap-3 mb-3">
<div class="w-10 h-10 rounded-lg bg-white dark:bg-slate-800 flex items-center justify-center flex-shrink-0">
<FileText :size="18" class="text-slate-500 dark:text-slate-400" />
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-slate-900 dark:text-slate-100 truncate">{{ doc.title || doc.id }}</h3>
<p v-if="doc.url" class="text-xs text-slate-500 dark:text-slate-400 truncate">{{ truncateUrl(doc.url, 30) }}</p>
</div>
<span
:class="getStatusBadge(doc).class"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0"
>
{{ getStatusBadge(doc).text }}
</span>
</div>
<!-- Meta Info -->
<div class="flex items-center gap-3 text-sm text-slate-500 dark:text-slate-400 mb-3">
<span>{{ formatDate(doc.createdAt) }}</span>
</div>
<!-- Actions -->
<div class="flex gap-2 pt-3 border-t border-slate-200 dark:border-slate-600" @click.stop>
<button
@click="viewDocument(doc)"
class="flex-1 inline-flex items-center justify-center gap-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
>
<Eye :size="16" />
{{ t('myDocuments.actions.view') }}
</button>
<button
@click="copyShareLink(doc)"
class="inline-flex items-center justify-center gap-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
>
<Check v-if="copiedDocId === doc.id" :size="16" class="text-emerald-500" />
<Copy v-else :size="16" />
</button>
<button
@click="confirmDelete(doc)"
class="inline-flex items-center justify-center bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-red-600 dark:text-red-400 font-medium rounded-lg px-3 py-2 text-sm hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 :size="16" />
</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="documents.length === 0" class="text-center py-12">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700">
<FileText :size="28" class="text-slate-400" />
</div>
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">
{{ searchQuery ? t('myDocuments.noResults') : t('myDocuments.empty.title') }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ searchQuery ? t('myDocuments.tryAnotherSearch') : t('myDocuments.empty.description') }}
</p>
</div>
<!-- Pagination -->
<div v-if="documents.length > 0 && totalPages > 1" class="flex items-center justify-between mt-6 pt-4 border-t border-slate-200 dark:border-slate-700">
<div class="text-sm text-slate-500 dark:text-slate-400 hidden md:block">
{{ t('myDocuments.totalCount', totalDocsCount) }}
</div>
<div class="flex items-center gap-2 w-full md:w-auto justify-between md:justify-end">
<button
:disabled="currentPage === 1"
@click="prevPage"
class="inline-flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft :size="16" />
{{ t('common.previous') }}
</button>
<span class="text-sm text-slate-500 dark:text-slate-400">
{{ t('myDocuments.pagination.page', { current: currentPage, total: totalPages }) }}
</span>
<button
:disabled="currentPage >= totalPages"
@click="nextPage"
class="inline-flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ t('common.next') }}
<ChevronRight :size="16" />
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Delete Confirmation Modal -->
<Teleport to="body">
<div
v-if="showDeleteConfirm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
@click.self="showDeleteConfirm = false"
>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-md w-full p-6">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
<Trash2 :size="24" class="text-red-600 dark:text-red-400" />
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
{{ t('myDocuments.deleteConfirm.title') }}
</h3>
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
{{ t('myDocuments.deleteConfirm.message', { title: docToDelete?.title || docToDelete?.id }) }}
</p>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button
@click="showDeleteConfirm = false"
class="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-200 bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
>
{{ t('common.cancel') }}
</button>
<button
@click="deleteDocument"
:disabled="deletingDocId !== null"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<Loader2 v-if="deletingDocId !== null" :size="16" class="animate-spin" />
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>

View File

@@ -196,7 +196,6 @@ onMounted(() => {
</div>
</div>
<!-- Create Document Section -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 mb-8">
<div class="flex items-start gap-4 mb-4">
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
@@ -213,6 +212,7 @@ onMounted(() => {
v-model="newDocId"
type="text"
required
data-testid="new-doc-input"
:placeholder="t('admin.documents.idPlaceholder')"
class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
@@ -223,6 +223,7 @@ onMounted(() => {
<button
type="submit"
:disabled="!newDocId || creating"
data-testid="create-doc-btn"
class="trust-gradient text-white font-medium rounded-lg px-6 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 sm:self-start"
>
<FileText v-if="!creating" :size="16" />
@@ -232,7 +233,6 @@ onMounted(() => {
</form>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex flex-col items-center justify-center py-24">
<Loader2 :size="48" class="animate-spin text-blue-600" />
<p class="mt-4 text-slate-500 dark:text-slate-400">{{ t('admin.loading') }}</p>
@@ -301,9 +301,7 @@ onMounted(() => {
</div>
</div>
<!-- Documents List Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<!-- Header -->
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
<div class="flex flex-col gap-4">
<div>
@@ -311,7 +309,6 @@ onMounted(() => {
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ t('admin.subtitle') }}</p>
</div>
<!-- Search -->
<div class="relative">
<Search v-if="!searching" :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<Loader2 v-else :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 animate-spin" />
@@ -325,7 +322,6 @@ onMounted(() => {
</div>
</div>
<!-- Content -->
<div class="p-6">
<!-- Desktop Table -->
<div v-if="documents.length > 0" class="hidden md:block overflow-x-auto">

View File

@@ -450,7 +450,6 @@ onMounted(() => {
<template>
<div class="min-h-[calc(100vh-8rem)]">
<main class="mx-auto max-w-6xl px-4 sm:px-6 py-6 sm:py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center space-x-3 mb-2">
<button
@@ -491,13 +490,11 @@ onMounted(() => {
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex flex-col items-center justify-center py-24">
<Loader2 :size="48" class="animate-spin text-blue-600" />
<p class="mt-4 text-slate-500 dark:text-slate-400">{{ t('common.loading') }}</p>
</div>
<!-- Content -->
<div v-else-if="documentStatus" class="space-y-6">
<!-- Stats Cards -->
<div v-if="stats && stats.expectedCount > 0" class="grid gap-4 grid-cols-2 lg:grid-cols-4">
@@ -616,7 +613,7 @@ onMounted(() => {
<Upload :size="16" />
{{ t('admin.documentDetail.importCSV') }}
</button>
<button @click="showAddSignersModal = true" class="trust-gradient text-white font-medium rounded-lg px-3 py-2 text-sm hover:opacity-90 transition-opacity inline-flex items-center gap-2">
<button @click="showAddSignersModal = true" data-testid="open-add-signers-btn" class="trust-gradient text-white font-medium rounded-lg px-3 py-2 text-sm hover:opacity-90 transition-opacity inline-flex items-center gap-2">
<Plus :size="16" />
{{ t('admin.documentDetail.addButton') }}
</button>
@@ -625,7 +622,6 @@ onMounted(() => {
</div>
<div class="p-6">
<div v-if="expectedSigners.length > 0">
<!-- Filter -->
<div class="relative mb-4">
<Search :size="16" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
<input v-model="signerFilter" :placeholder="t('admin.documentDetail.filterPlaceholder')" class="w-full pl-9 pr-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" name="ackify-signer-filter" autocomplete="off" data-1p-ignore data-lpignore="true" />
@@ -793,8 +789,7 @@ onMounted(() => {
</div>
</main>
<!-- Add Signers Modal -->
<div v-if="showAddSignersModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showAddSignersModal = false">
<div v-if="showAddSignersModal" data-testid="add-signers-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showAddSignersModal = false">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-2xl w-full max-h-[90vh] overflow-auto">
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('admin.documentDetail.addSigners') }}</h2>
@@ -806,12 +801,12 @@ onMounted(() => {
<form @submit.prevent="addSigners" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('admin.documentDetail.emailsLabel') }}</label>
<textarea v-model="signersEmails" rows="8" :placeholder="t('admin.documentDetail.emailsPlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"></textarea>
<textarea v-model="signersEmails" rows="8" data-testid="signers-textarea" :placeholder="t('admin.documentDetail.emailsPlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"></textarea>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-2">{{ t('admin.documentDetail.emailsHelper') }}</p>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="showAddSignersModal = false" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
<button type="submit" :disabled="addingSigners || !signersEmails.trim()" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
<button type="submit" :disabled="addingSigners || !signersEmails.trim()" data-testid="add-signers-btn" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
<Loader2 v-if="addingSigners" :size="16" class="animate-spin" />
{{ addingSigners ? t('admin.documentDetail.adding') : t('admin.documentDetail.addButton') }}
</button>
@@ -821,7 +816,6 @@ onMounted(() => {
</div>
</div>
<!-- Import CSV Modal -->
<div v-if="showImportCSVModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="closeImportCSVModal">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-3xl w-full max-h-[90vh] overflow-auto">
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
@@ -919,7 +913,6 @@ onMounted(() => {
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteConfirmModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showDeleteConfirmModal = false">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-red-200 dark:border-red-800 max-w-md w-full">
<div class="p-6 border-b border-red-100 dark:border-red-800/30 flex items-center justify-between">
@@ -991,7 +984,6 @@ onMounted(() => {
</div>
</div>
<!-- Remove Signer Modal -->
<div v-if="showRemoveSignerModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="cancelRemoveSigner">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-md w-full">
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
@@ -1010,7 +1002,6 @@ onMounted(() => {
</div>
</div>
<!-- Send Reminders Modal -->
<div v-if="showSendRemindersModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="cancelSendReminders">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-md w-full">
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">

View File

@@ -4,6 +4,8 @@ import { useAuthStore } from '@/stores/auth'
const HomePage = () => import('@/pages/HomePage.vue')
const SignaturesPage = () => import('@/pages/SignaturesPage.vue')
const MyDocumentsPage = () => import('@/pages/MyDocumentsPage.vue')
const DocumentEditPage = () => import('@/pages/DocumentEditPage.vue')
const AuthChoicePage = () => import('@/pages/AuthChoicePage.vue')
const AdminDashboard = () => import('@/pages/admin/AdminDashboard.vue')
const AdminDocumentDetail = () => import('@/pages/admin/AdminDocumentDetail.vue')
@@ -31,6 +33,18 @@ const routes: RouteRecordRaw[] = [
component: SignaturesPage,
meta: { requiresAuth: true }
},
{
path: '/documents',
name: 'my-documents',
component: MyDocumentsPage,
meta: { requiresAuth: true }
},
{
path: '/documents/:id',
name: 'document-edit',
component: DocumentEditPage,
meta: { requiresAuth: true }
},
{
path: '/admin',
name: 'admin',

View File

@@ -20,10 +20,37 @@ export interface FindOrCreateDocumentResponse {
checksum?: string
checksumAlgorithm?: string
description?: string
readMode: 'external' | 'integrated'
allowDownload: boolean
requireFullRead: boolean
verifyChecksum: boolean
createdAt: string
isNew: boolean // true if created, false if found
}
// MyDocument represents a document in the user's document list
export interface MyDocument {
id: string
title: string
url?: string
description: string
createdAt: string
updatedAt: string
signatureCount: number
expectedSignerCount: number
}
// PaginatedResponse for paginated API responses
export interface PaginatedResponse<T> {
data: T[]
meta: {
page: number
pageSize: number
total: number
totalPages: number
}
}
/**
* Document service for managing documents
*/
@@ -48,6 +75,43 @@ export const documentService = {
)
return response.data.data
},
/**
* List documents created by the current user
* @param limit Number of documents per page (default: 20)
* @param page Page number (1-indexed)
* @param search Optional search query
* @returns Paginated list of user's documents
*/
async listMyDocuments(
limit = 20,
page = 1,
search?: string
): Promise<PaginatedResponse<MyDocument>> {
const params: Record<string, any> = { limit, page }
if (search && search.trim()) {
params.search = search.trim()
}
const response = await http.get<{
data: MyDocument[]
success: boolean
meta: { page: number; pageSize: number; total: number; totalPages: number }
}>('/users/me/documents', { params })
return {
data: response.data.data,
meta: response.data.meta
}
},
/**
* Delete a document by ID
* @param docId Document ID to delete
*/
async deleteDocument(docId: string): Promise<void> {
await http.delete(`/admin/documents/${docId}`)
},
}
export default documentService

View File

@@ -18,6 +18,10 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => !!user.value)
const isAdmin = computed(() => user.value?.isAdmin ?? false)
// Check if user can create documents: admin OR only_admin_can_create is false
const onlyAdminCanCreate = computed(() => (window as any).ACKIFY_ONLY_ADMIN_CAN_CREATE || false)
const canCreateDocuments = computed(() => isAdmin.value || !onlyAdminCanCreate.value)
async function checkAuth() {
if (initialized.value) return
@@ -91,6 +95,7 @@ export const useAuthStore = defineStore('auth', () => {
initialized,
isAuthenticated,
isAdmin,
canCreateDocuments,
checkAuth,
fetchCurrentUser,
startOAuthLogin,

View File

@@ -74,7 +74,8 @@ describe('SignButton Component', () => {
// Should show signed status (no button visible)
expect(wrapper.find('button').exists()).toBe(false)
expect(wrapper.find('.signed-status').exists()).toBe(true)
// Should display signed confirmation (check i18n key or translated text)
expect(wrapper.text()).toMatch(/Signed|signButton\.confirmed/)
})
it('should not show signed status when different user has signed', async () => {