mirror of
https://github.com/btouchard/ackify.git
synced 2026-02-11 16:28:52 -06:00
wip
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@ client_secret*.json
|
||||
/docker.secret
|
||||
/samples/
|
||||
/docs/reports/
|
||||
/prompts/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
39
webapp/package-lock.json
generated
39
webapp/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
323
webapp/src/components/DocumentCreateForm.vue
Normal file
323
webapp/src/components/DocumentCreateForm.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
447
webapp/src/components/viewer/DocumentViewer.vue
Normal file
447
webapp/src/components/viewer/DocumentViewer.vue
Normal 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>
|
||||
2
webapp/src/components/viewer/index.ts
Normal file
2
webapp/src/components/viewer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
export { default as DocumentViewer } from './DocumentViewer.vue'
|
||||
196
webapp/src/composables/useDocumentProxy.ts
Normal file
196
webapp/src/composables/useDocumentProxy.ts
Normal 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
@@ -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
@@ -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
780
webapp/src/pages/DocumentEditPage.vue
Normal file
780
webapp/src/pages/DocumentEditPage.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
584
webapp/src/pages/MyDocumentsPage.vue
Normal file
584
webapp/src/pages/MyDocumentsPage.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user